mirror of
https://github.com/shlinkio/shlink.git
synced 2025-02-17 07:49:54 +03:00
commit
09c155b7d3
87 changed files with 1410 additions and 522 deletions
|
@ -8,17 +8,16 @@ data/migrations_template.txt
|
|||
data/GeoLite2-City.*
|
||||
data/database.sqlite
|
||||
data/shlink-tests.db
|
||||
**/.gitignore
|
||||
CHANGELOG.md
|
||||
UPGRADE.md
|
||||
composer.lock
|
||||
vendor
|
||||
docs
|
||||
indocker
|
||||
docker-*
|
||||
php*
|
||||
infection.json
|
||||
phpstan.neon
|
||||
php*xml*
|
||||
infection.json
|
||||
**/test*
|
||||
build*
|
||||
.github
|
||||
hooks
|
||||
**/.*
|
||||
|
|
2
.github/ISSUE_TEMPLATE/Bug.md
vendored
2
.github/ISSUE_TEMPLATE/Bug.md
vendored
|
@ -18,7 +18,7 @@ With that said, please fill in the information requested next. More information
|
|||
* Shlink Version: x.y.z
|
||||
* PHP Version: x.y.z
|
||||
* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted swoole|Docker image
|
||||
* Database engine used: MySQL|MariaDB|PostgreSQL|SQLite (x.y.z)
|
||||
* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z)
|
||||
|
||||
#### Summary
|
||||
|
||||
|
|
2
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
2
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
|
@ -18,7 +18,7 @@ With that said, please fill in the information requested next. More information
|
|||
* Shlink Version: x.y.z
|
||||
* PHP Version: x.y.z
|
||||
* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted swoole|Docker image
|
||||
* Database engine used: MySQL|MariaDB|PostgreSQL|SQLite (x.y.z)
|
||||
* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z)
|
||||
|
||||
#### Summary
|
||||
|
||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,6 +1,6 @@
|
|||
.idea
|
||||
build
|
||||
!hooks/build
|
||||
!docker/build
|
||||
composer.lock
|
||||
composer.phar
|
||||
vendor/
|
||||
|
|
|
@ -6,6 +6,9 @@ checks:
|
|||
code_rating: true
|
||||
duplication: true
|
||||
build:
|
||||
dependencies:
|
||||
override:
|
||||
- composer install --no-interaction --no-scripts --ignore-platform-reqs
|
||||
nodes:
|
||||
analysis:
|
||||
tests:
|
||||
|
|
14
.travis.yml
14
.travis.yml
|
@ -18,7 +18,7 @@ cache:
|
|||
|
||||
before_install:
|
||||
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
|
||||
- yes | pecl install swoole
|
||||
- yes | pecl install swoole-4.4.15
|
||||
- phpenv config-rm xdebug.ini || return 0
|
||||
|
||||
install:
|
||||
|
@ -37,17 +37,23 @@ script:
|
|||
|
||||
after_success:
|
||||
- rm -f build/clover.xml
|
||||
- wget https://phar.phpunit.de/phpcov-6.0.1.phar
|
||||
- phpdbg -qrr phpcov-6.0.1.phar merge build --clover build/clover.xml
|
||||
- wget https://phar.phpunit.de/phpcov-7.0.2.phar
|
||||
- phpdbg -qrr phpcov-7.0.2.phar merge build --clover build/clover.xml
|
||||
- wget https://scrutinizer-ci.com/ocular.phar
|
||||
- php ocular.phar code-coverage:upload --format=php-clover build/clover.xml
|
||||
|
||||
# Before deploying, build dist file for current travis tag
|
||||
before_deploy:
|
||||
- rm -f ocular.phar
|
||||
- ./build.sh ${TRAVIS_TAG#?}
|
||||
- if [[ ! -z $TRAVIS_TAG && "${TRAVIS_PHP_VERSION}" == "7.4" ]]; then ./build.sh ${TRAVIS_TAG#?} ; fi
|
||||
|
||||
deploy:
|
||||
- provider: script
|
||||
script: bash ./docker/build
|
||||
on:
|
||||
all_branches: true
|
||||
condition: $TRAVIS_PULL_REQUEST == 'false'
|
||||
php: '7.4'
|
||||
- provider: releases
|
||||
api_key:
|
||||
secure: a9dbZchocqeuOViwUeNH54bQR5Sz7rEYXx5b9WPFtnFn9LGKKUaLbA2U91UQ9QKPrcTpsALubUYbw2CnNmvCwzaY+R8lCD3gkU4ohsEnbpnw3deOeixI74sqBHJAuCH9FSaRDGILoBMtUKx2xlzIymFxkIsgIukkGbdkWHDlRWY3oTUUuw1SQ2Xk9KDsbJQtjIc1+G/O6gHaV4qv/R9W8NPmJExKTNDrAZbC1vIUnxqp4UpVo1hst8qPd1at94CndDYM5rG+7imGbdtxTxzamt819qdTO1OfvtctKawNAm7YXZrrWft6c7gI6j6SI4hxd+ZrrPBqbaRFHkZHjnNssO/yn4SaOHFFzccmu0MzvpPCf0qWZwd3sGHVYer1MnR2mHYqU84QPlW3nrHwJjkrpq3+q0JcBY6GsJs+RskHNtkMTKV05Iz6QUI5YZGwTpuXaRm036SmavjGc4IDlMaYCk/NmbB9BKpthJxLdUpczOHpnjXXHziotWD6cfEnbjU3byfD8HY5WrxSjsNT7SKmXN3hRof7bk985ewQVjGT42O3NbnfnqjQQWr/B7/zFTpLR4f526Bkq12CdCyf5lvrbq+POkLVdJ+uFfR7ds248Ue/jBQy6kM1tWmKF9QiwisFlA84eQ4CW3I93Rp97URv+AQa9zmbD0Ve3Udp+g6nF5I=
|
||||
|
|
33
CHANGELOG.md
33
CHANGELOG.md
|
@ -4,6 +4,39 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## 2.1.0 - 2020-03-28
|
||||
|
||||
#### Added
|
||||
|
||||
* [#626](https://github.com/shlinkio/shlink/issues/626) Added support for Microsoft SQL Server.
|
||||
* [#556](https://github.com/shlinkio/shlink/issues/556) Short code lengths can now be customized, both globally and on a per-short URL basis.
|
||||
* [#541](https://github.com/shlinkio/shlink/issues/541) Added a request ID that is returned on `X-Request-Id` header, can be provided from outside and is set in log entries.
|
||||
* [#642](https://github.com/shlinkio/shlink/issues/642) IP geolocation is now performed over the non-anonymized IP address when using swoole.
|
||||
* [#521](https://github.com/shlinkio/shlink/issues/521) The long URL for any existing short URL can now be edited using the `PATCH /short-urls/{shortCode}` endpoint.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#656](https://github.com/shlinkio/shlink/issues/656) Updated to PHPUnit 9.
|
||||
* [#641](https://github.com/shlinkio/shlink/issues/641) Added two new flags to the `visit:locate` command, `--retry` and `--all`.
|
||||
|
||||
* When `--retry` is provided, it will try to re-locate visits which IP address was originally considered not found, in case it was a temporal issue.
|
||||
* When `--all` is provided together with `--retry`, it will try to re-locate all existing visits. A warning and confirmation are displayed, as this can have side effects.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#665](https://github.com/shlinkio/shlink/issues/665) Fixed `base_url_redirect_to` simplified config option not being properly parsed.
|
||||
* [#663](https://github.com/shlinkio/shlink/issues/663) Fixed Shlink allowing short URLs to be created with an empty custom slug.
|
||||
* [#678](https://github.com/shlinkio/shlink/issues/678) Fixed `db` commands not running in a non-interactive way.
|
||||
|
||||
|
||||
## 2.0.5 - 2020-02-09
|
||||
|
||||
#### Added
|
||||
|
|
45
Dockerfile
45
Dockerfile
|
@ -1,15 +1,14 @@
|
|||
FROM php:7.4.1-alpine3.10
|
||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||
FROM php:7.4.2-alpine3.11 as base
|
||||
|
||||
ARG SHLINK_VERSION=2.0.0
|
||||
ARG SHLINK_VERSION=2.0.5
|
||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||
ENV SWOOLE_VERSION 4.4.12
|
||||
ENV COMPOSER_VERSION 1.9.1
|
||||
ENV SWOOLE_VERSION 4.4.15
|
||||
ENV LC_ALL "C"
|
||||
|
||||
WORKDIR /etc/shlink
|
||||
|
||||
RUN \
|
||||
# Install mysl and calendar
|
||||
# Install mysql and calendar
|
||||
docker-php-ext-install -j"$(nproc)" pdo_mysql calendar && \
|
||||
# Install sqlite
|
||||
apk add --no-cache sqlite-libs sqlite-dev && \
|
||||
|
@ -24,24 +23,36 @@ RUN \
|
|||
apk add --no-cache libzip-dev zlib-dev libpng-dev && \
|
||||
docker-php-ext-install -j"$(nproc)" zip gd
|
||||
|
||||
# Install swoole
|
||||
# First line fixes an error when installing pecl extensions. Found in https://github.com/docker-library/php/issues/233
|
||||
RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} && \
|
||||
pecl install swoole-${SWOOLE_VERSION} && \
|
||||
docker-php-ext-enable swoole && \
|
||||
apk del .phpize-deps
|
||||
# Install swoole and sqlsrv driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \
|
||||
docker-php-ext-enable swoole pdo_sqlsrv && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
rm mssql-tools_17.5.1.1-1_amd64.apk
|
||||
|
||||
|
||||
# Install shlink
|
||||
FROM base as builder
|
||||
COPY . .
|
||||
RUN rm -rf ./docker && \
|
||||
wget https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar && \
|
||||
COPY --from=composer:1.10.1 /usr/bin/composer ./composer.phar
|
||||
RUN apk add --no-cache git && \
|
||||
php composer.phar install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction && \
|
||||
php composer.phar clear-cache && \
|
||||
rm composer.*
|
||||
rm -r docker composer.* && \
|
||||
sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php
|
||||
|
||||
# Add shlink to the path to ease running it after container is created
|
||||
|
||||
# Prepare final image
|
||||
FROM base
|
||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||
|
||||
COPY --from=builder /etc/shlink .
|
||||
RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink
|
||||
RUN sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php
|
||||
|
||||
# Expose swoole port
|
||||
EXPOSE 8080
|
||||
|
|
15
README.md
15
README.md
|
@ -4,8 +4,9 @@
|
|||
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
|
||||
[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
|
||||
[![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink)
|
||||
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
|
||||
[![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/master/LICENSE)
|
||||
[![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://acel.me/donate)
|
||||
[![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate)
|
||||
|
||||
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own custom domain.
|
||||
|
||||
|
@ -35,8 +36,8 @@ A PHP-based self-hosted URL shortener that can be used to serve shortened URLs u
|
|||
|
||||
First, make sure the host where you are going to run shlink fulfills these requirements:
|
||||
|
||||
* PHP 7.4 or greater with JSON, APCu, intl, curl, PDO and gd extensions enabled.
|
||||
* MySQL, MariaDB, PostgreSQL or SQLite.
|
||||
* PHP 7.4 or greater with JSON, curl, PDO and gd extensions enabled.
|
||||
* MySQL, MariaDB, PostgreSQL, Microsoft SQL Server or SQLite.
|
||||
* The web server of your choice with PHP integration (Apache or Nginx recommended).
|
||||
|
||||
### Download
|
||||
|
@ -67,7 +68,7 @@ In order to run Shlink, you will need a built version of the project. There are
|
|||
|
||||
Despite how you built the project, you now need to configure it, by following these steps:
|
||||
|
||||
* If you are going to use MySQL, MariaDB or PostgreSQL, create an empty database with the name of your choice.
|
||||
* If you are going to use MySQL, MariaDB, PostgreSQL or Microsoft SQL Server, create an empty database with the name of your choice.
|
||||
* Recursively grant write permissions to the `data` directory. Shlink uses it to cache some information.
|
||||
* Setup the application by running the `bin/install` script. It is a command line tool that will guide you through the installation process. **Take into account that this tool has to be run directly on the server where you plan to host Shlink. Do not run it before uploading/moving it there.**
|
||||
* Generate your first API key by running `bin/cli api-key:generate`. You will need the key in order to interact with shlink's API.
|
||||
|
@ -96,7 +97,7 @@ Once Shlink is configured, you need to expose it to the web, either by using a t
|
|||
|
||||
location ~ \.php$ {
|
||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||
fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
|
||||
fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
|
||||
fastcgi_index index.php;
|
||||
include fastcgi.conf;
|
||||
}
|
||||
|
@ -238,7 +239,7 @@ Once shlink is installed, there are two main ways to interact with it:
|
|||
|
||||
It is probably a good idea to symlink the CLI entry point (`bin/cli`) to somewhere in your path, so that you can run shlink from any directory.
|
||||
|
||||
* **The REST API**. The complete docs on how to use the API can be found [here](https://shlink.io/api-docs), and a sandbox which also documents every endpoint can be found in the [API Spec](https://api-spec.shlink.io/) portal.
|
||||
* **The REST API**. The complete docs on how to use the API can be found [here](https://shlink.io/documentation/api-docs), and a sandbox which also documents every endpoint can be found in the [API Spec](https://api-spec.shlink.io/) portal.
|
||||
|
||||
However, you probably don't want to consume the raw API yourself. That's why a nice [web client](https://github.com/shlinkio/shlink-web-client) is provided that can be directly used from [https://app.shlink.io](https://app.shlink.io), or you can host it yourself too.
|
||||
|
||||
|
@ -344,4 +345,6 @@ Those are configured during Shlink's installation or via env vars when using the
|
|||
|
||||
Currently those are all shared for all domains serving the same Shlink instance, but the plan is to update that and allow specific ones for every existing domain.
|
||||
|
||||
---
|
||||
|
||||
> This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com)
|
||||
|
|
|
@ -9,7 +9,7 @@ echo 'Starting server...'
|
|||
vendor/bin/mezzio-swoole start -d
|
||||
sleep 2
|
||||
|
||||
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $*
|
||||
phpdbg -qrr vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $*
|
||||
testsExitCode=$?
|
||||
|
||||
vendor/bin/mezzio-swoole stop
|
||||
|
|
2
build.sh
2
build.sh
|
@ -25,7 +25,7 @@ cd "${builtcontent}"
|
|||
# Install dependencies
|
||||
echo "Installing dependencies with $composerBin..."
|
||||
${composerBin} self-update
|
||||
${composerBin} install --no-dev --optimize-autoloader --no-progress --no-interaction
|
||||
${composerBin} install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction
|
||||
|
||||
# Delete development files
|
||||
echo 'Deleting dev files...'
|
||||
|
|
|
@ -40,17 +40,20 @@
|
|||
"mezzio/mezzio-helpers": "^5.3",
|
||||
"mezzio/mezzio-platesrenderer": "^2.1",
|
||||
"mezzio/mezzio-problem-details": "^1.1",
|
||||
"mezzio/mezzio-swoole": "^2.4",
|
||||
"mezzio/mezzio-swoole": "^2.6",
|
||||
"monolog/monolog": "^2.0",
|
||||
"nikolaposa/monolog-factory": "^3.0",
|
||||
"ocramius/proxy-manager": "^2.6.0",
|
||||
"ocramius/proxy-manager": "^2.7.0",
|
||||
"phly/phly-event-dispatcher": "^1.0",
|
||||
"php-middleware/request-id": "^4.0",
|
||||
"predis/predis": "^1.1",
|
||||
"pugx/shortid-php": "^0.5",
|
||||
"shlinkio/shlink-common": "^2.7.0",
|
||||
"shlinkio/shlink-event-dispatcher": "^1.3",
|
||||
"shlinkio/shlink-installer": "^4.0.1",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.3.1",
|
||||
"ramsey/uuid": "^3.9",
|
||||
"shlinkio/shlink-common": "^3.0",
|
||||
"shlinkio/shlink-config": "^1.0",
|
||||
"shlinkio/shlink-event-dispatcher": "^1.4",
|
||||
"shlinkio/shlink-installer": "^4.3.1",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.4",
|
||||
"symfony/console": "^5.0",
|
||||
"symfony/filesystem": "^5.0",
|
||||
"symfony/lock": "^5.0",
|
||||
|
@ -58,14 +61,14 @@
|
|||
},
|
||||
"require-dev": {
|
||||
"devster/ubench": "^2.0",
|
||||
"dms/phpunit-arraysubset-asserts": "^0.1.0",
|
||||
"dms/phpunit-arraysubset-asserts": "^0.2.0",
|
||||
"eaglewu/swoole-ide-helper": "dev-master",
|
||||
"infection/infection": "^0.15.0",
|
||||
"phpstan/phpstan": "^0.12.3",
|
||||
"phpunit/phpunit": "^8.3",
|
||||
"phpunit/phpunit": "^9.0.1",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"shlinkio/php-coding-standard": "~2.1.0",
|
||||
"shlinkio/shlink-test-utils": "^1.3",
|
||||
"shlinkio/shlink-test-utils": "^1.4",
|
||||
"symfony/var-dumper": "^5.0"
|
||||
},
|
||||
"autoload": {
|
||||
|
@ -107,7 +110,7 @@
|
|||
"test:ci": [
|
||||
"@test:unit:ci",
|
||||
"@test:db:ci",
|
||||
"@test:api"
|
||||
"@test:api:ci"
|
||||
],
|
||||
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
|
||||
"test:unit:ci": "@test:unit --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml",
|
||||
|
@ -115,7 +118,8 @@
|
|||
"@test:db:sqlite",
|
||||
"@test:db:mysql",
|
||||
"@test:db:maria",
|
||||
"@test:db:postgres"
|
||||
"@test:db:postgres",
|
||||
"@test:db:ms"
|
||||
],
|
||||
"test:db:ci": [
|
||||
"@test:db:sqlite",
|
||||
|
@ -126,7 +130,9 @@
|
|||
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
|
||||
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite",
|
||||
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
|
||||
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
|
||||
"test:api": "bin/test/run-api-tests.sh",
|
||||
"test:api:ci": "@test:api --coverage-php build/coverage-api.cov",
|
||||
"test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage",
|
||||
"infect": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered",
|
||||
"infect:ci": "@infect --coverage=build",
|
||||
|
|
|
@ -30,6 +30,7 @@ return [
|
|||
Option\TaskWorkerNumConfigOption::class,
|
||||
Option\WebWorkerNumConfigOption::class,
|
||||
Option\RedisServersConfigOption::class,
|
||||
Option\ShortCodeLengthOption::class,
|
||||
],
|
||||
|
||||
'installation_commands' => [
|
||||
|
|
|
@ -8,7 +8,7 @@ use Shlinkio\Shlink\Common\Lock\RetryLockStoreDelegatorFactory;
|
|||
use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory;
|
||||
use Symfony\Component\Lock;
|
||||
|
||||
$localLockFactory = 'Shlinkio\Shlink\LocalLockFactory';
|
||||
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
|
||||
|
||||
return [
|
||||
|
||||
|
@ -21,7 +21,7 @@ return [
|
|||
Lock\Store\FlockStore::class => ConfigAbstractFactory::class,
|
||||
Lock\Store\RedisStore::class => ConfigAbstractFactory::class,
|
||||
Lock\LockFactory::class => ConfigAbstractFactory::class,
|
||||
$localLockFactory => ConfigAbstractFactory::class,
|
||||
LOCAL_LOCK_FACTORY => ConfigAbstractFactory::class,
|
||||
],
|
||||
'aliases' => [
|
||||
// With this config, a user could alias 'lock_store' => 'redis_lock_store' to override the default
|
||||
|
@ -44,7 +44,7 @@ return [
|
|||
Lock\Store\FlockStore::class => ['config.locks.locks_dir'],
|
||||
Lock\Store\RedisStore::class => [RedisFactory::SERVICE_NAME],
|
||||
Lock\LockFactory::class => ['lock_store'],
|
||||
$localLockFactory => ['local_lock_store'],
|
||||
LOCAL_LOCK_FACTORY => ['local_lock_store'],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -9,6 +9,7 @@ use Monolog\Handler;
|
|||
use Monolog\Logger;
|
||||
use Monolog\Processor;
|
||||
use MonologFactory\DiContainerLoggerFactory;
|
||||
use PhpMiddleware\RequestId;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
use const PHP_EOL;
|
||||
|
@ -20,11 +21,12 @@ $processors = [
|
|||
'psr3' => [
|
||||
'name' => Processor\PsrLogMessageProcessor::class,
|
||||
],
|
||||
'request_id' => RequestId\MonologProcessor::class,
|
||||
];
|
||||
$formatter = [
|
||||
'name' => Formatter\LineFormatter::class,
|
||||
'params' => [
|
||||
'format' => '[%datetime%] %channel%.%level_name% - %message%' . PHP_EOL,
|
||||
'format' => '[%datetime%] [%extra.request_id%] %channel%.%level_name% - %message%' . PHP_EOL,
|
||||
'allow_inline_line_breaks' => true,
|
||||
],
|
||||
];
|
||||
|
@ -80,6 +82,7 @@ return [
|
|||
'swoole-http-server' => [
|
||||
'logger' => [
|
||||
'logger-name' => 'Logger_Access',
|
||||
'format' => '%h %l %u "%r" %>s %b',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
|
|
@ -7,6 +7,7 @@ namespace Shlinkio\Shlink;
|
|||
use Laminas\Stratigility\Middleware\ErrorHandler;
|
||||
use Mezzio;
|
||||
use Mezzio\ProblemDetails;
|
||||
use PhpMiddleware\RequestId\RequestIdMiddleware;
|
||||
|
||||
return [
|
||||
|
||||
|
@ -21,6 +22,7 @@ return [
|
|||
'path' => '/rest',
|
||||
'middleware' => [
|
||||
Rest\Middleware\CrossDomainMiddleware::class,
|
||||
RequestIdMiddleware::class,
|
||||
ProblemDetails\ProblemDetailsMiddleware::class,
|
||||
],
|
||||
],
|
||||
|
|
38
config/autoload/request_id.global.php
Normal file
38
config/autoload/request_id.global.php
Normal file
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use PhpMiddleware\RequestId;
|
||||
|
||||
return [
|
||||
|
||||
'request_id' => [
|
||||
'allow_override' => true,
|
||||
'header_name' => 'X-Request-Id',
|
||||
],
|
||||
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
RequestId\Generator\RamseyUuid4StaticGenerator::class => InvokableFactory::class,
|
||||
RequestId\RequestIdProviderFactory::class => ConfigAbstractFactory::class,
|
||||
RequestId\RequestIdMiddleware::class => ConfigAbstractFactory::class,
|
||||
RequestId\MonologProcessor::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
RequestId\RequestIdProviderFactory::class => [
|
||||
RequestId\Generator\RamseyUuid4StaticGenerator::class,
|
||||
'config.request_id.allow_override',
|
||||
'config.request_id.header_name',
|
||||
],
|
||||
RequestId\RequestIdMiddleware::class => [
|
||||
RequestId\RequestIdProviderFactory::class,
|
||||
'config.request_id.header_name',
|
||||
],
|
||||
RequestId\MonologProcessor::class => [RequestId\RequestIdMiddleware::class],
|
||||
],
|
||||
|
||||
];
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
||||
|
||||
return [
|
||||
|
||||
'url_shortener' => [
|
||||
|
@ -11,6 +13,7 @@ return [
|
|||
],
|
||||
'validate_url' => false,
|
||||
'visits_webhooks' => [],
|
||||
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -19,11 +19,12 @@ return (new ConfigAggregator\ConfigAggregator([
|
|||
Mezzio\Swoole\ConfigProvider::class,
|
||||
ProblemDetails\ConfigProvider::class,
|
||||
Common\ConfigProvider::class,
|
||||
Config\ConfigProvider::class,
|
||||
IpGeolocation\ConfigProvider::class,
|
||||
EventDispatcher\ConfigProvider::class,
|
||||
Core\ConfigProvider::class,
|
||||
CLI\ConfigProvider::class,
|
||||
Rest\ConfigProvider::class,
|
||||
EventDispatcher\ConfigProvider::class,
|
||||
new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'),
|
||||
env('APP_ENV') === 'test'
|
||||
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')
|
||||
|
|
|
@ -5,13 +5,16 @@ declare(strict_types=1);
|
|||
use Laminas\ServiceManager\ServiceManager;
|
||||
use Symfony\Component\Lock;
|
||||
|
||||
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
|
||||
|
||||
chdir(dirname(__DIR__));
|
||||
|
||||
require 'vendor/autoload.php';
|
||||
|
||||
// This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name
|
||||
if (! class_exists('Shlinkio\Shlink\LocalLockFactory')) {
|
||||
class_alias(Lock\LockFactory::class, 'Shlinkio\Shlink\LocalLockFactory');
|
||||
// It needs to be placed here as individual config files will not be loaded once config is cached
|
||||
if (! class_exists(LOCAL_LOCK_FACTORY)) {
|
||||
class_alias(Lock\LockFactory::class, LOCAL_LOCK_FACTORY);
|
||||
}
|
||||
|
||||
// Build container
|
||||
|
|
|
@ -45,6 +45,13 @@ $buildDbConnection = function (): array {
|
|||
'dbname' => 'shlink_test',
|
||||
'charset' => 'utf8',
|
||||
],
|
||||
'mssql' => [
|
||||
'driver' => 'pdo_sqlsrv',
|
||||
'host' => $isCi ? '127.0.0.1' : 'shlink_db_ms',
|
||||
'user' => 'sa',
|
||||
'password' => $isCi ? '' : 'Passw0rd!',
|
||||
'dbname' => 'shlink_test',
|
||||
],
|
||||
];
|
||||
$driverConfigMap['maria'] = $driverConfigMap['mysql'];
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM php:7.4.1-fpm-alpine3.10
|
||||
FROM php:7.4.2-fpm-alpine3.11
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.18
|
||||
|
@ -65,6 +65,18 @@ RUN docker-php-ext-configure xdebug\
|
|||
# cleanup
|
||||
RUN rm /tmp/xdebug.tar.gz
|
||||
|
||||
# Install sqlsrv driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install pdo_sqlsrv && \
|
||||
docker-php-ext-enable pdo_sqlsrv && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
rm mssql-tools_17.5.1.1-1_amd64.apk
|
||||
|
||||
# Install composer
|
||||
RUN php -r "readfile('https://getcomposer.org/installer');" | php
|
||||
RUN chmod +x composer.phar
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
FROM php:7.4.1-alpine3.10
|
||||
FROM php:7.4.2-alpine3.11
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.18
|
||||
ENV APCU_BC_VERSION 1.0.5
|
||||
ENV INOTIFY_VERSION 2.0.0
|
||||
ENV SWOOLE_VERSION 4.4.12
|
||||
ENV SWOOLE_VERSION 4.4.15
|
||||
|
||||
RUN apk update
|
||||
|
||||
|
@ -66,12 +66,17 @@ RUN docker-php-ext-configure inotify\
|
|||
# cleanup
|
||||
RUN rm /tmp/inotify.tar.gz
|
||||
|
||||
# Install swoole
|
||||
# First line fixes an error when installing pecl extensions. Found in https://github.com/docker-library/php/issues/233
|
||||
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS && \
|
||||
pecl install swoole-${SWOOLE_VERSION} && \
|
||||
docker-php-ext-enable swoole && \
|
||||
apk del .phpize-deps
|
||||
# Install swoole and mssql driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \
|
||||
docker-php-ext-enable swoole pdo_sqlsrv && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
rm mssql-tools_17.5.1.1-1_amd64.apk
|
||||
|
||||
# Install composer
|
||||
RUN php -r "readfile('https://getcomposer.org/installer');" | php
|
||||
|
|
44
data/migrations/Version20200323190014.php
Normal file
44
data/migrations/Version20200323190014.php
Normal file
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20200323190014 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$visitLocations = $schema->getTable('visit_locations');
|
||||
$this->skipIf($visitLocations->hasColumn('is_empty'));
|
||||
|
||||
$visitLocations->addColumn('is_empty', Types::BOOLEAN, ['default' => false]);
|
||||
}
|
||||
|
||||
public function postUp(Schema $schema): void
|
||||
{
|
||||
$qb = $this->connection->createQueryBuilder();
|
||||
$qb->update('visit_locations')
|
||||
->set('is_empty', true)
|
||||
->where($qb->expr()->eq('country_code', ':empty'))
|
||||
->andWhere($qb->expr()->eq('country_name', ':empty'))
|
||||
->andWhere($qb->expr()->eq('region_name', ':empty'))
|
||||
->andWhere($qb->expr()->eq('city_name', ':empty'))
|
||||
->andWhere($qb->expr()->eq('timezone', ':empty'))
|
||||
->andWhere($qb->expr()->eq('lat', 0))
|
||||
->andWhere($qb->expr()->eq('lon', 0))
|
||||
->setParameter('empty', '')
|
||||
->execute();
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$visitLocations = $schema->getTable('visit_locations');
|
||||
$this->skipIf(!$visitLocations->hasColumn('is_empty'));
|
||||
|
||||
$visitLocations->dropColumn('is_empty');
|
||||
}
|
||||
}
|
|
@ -25,7 +25,10 @@ services:
|
|||
- shlink_db
|
||||
- shlink_db_postgres
|
||||
- shlink_db_maria
|
||||
- shlink_db_ms
|
||||
- shlink_redis
|
||||
environment:
|
||||
LC_ALL: C
|
||||
|
||||
shlink_swoole:
|
||||
container_name: shlink_swoole
|
||||
|
@ -42,7 +45,10 @@ services:
|
|||
- shlink_db
|
||||
- shlink_db_postgres
|
||||
- shlink_db_maria
|
||||
- shlink_db_ms
|
||||
- shlink_redis
|
||||
environment:
|
||||
LC_ALL: C
|
||||
|
||||
shlink_db:
|
||||
container_name: shlink_db
|
||||
|
@ -82,6 +88,15 @@ services:
|
|||
MYSQL_DATABASE: shlink
|
||||
MYSQL_INITDB_SKIP_TZINFO: 1
|
||||
|
||||
shlink_db_ms:
|
||||
container_name: shlink_db_ms
|
||||
image: mcr.microsoft.com/mssql/server:2019-latest
|
||||
ports:
|
||||
- "1433:1433"
|
||||
environment:
|
||||
ACCEPT_EULA: Y
|
||||
SA_PASSWORD: "Passw0rd!"
|
||||
|
||||
shlink_redis:
|
||||
container_name: shlink_redis
|
||||
image: redis:5.0-alpine
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
# Shlink Docker image
|
||||
|
||||
[![Docker build status](https://img.shields.io/docker/build/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
|
||||
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
|
||||
|
||||
This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime.
|
||||
|
@ -38,10 +37,10 @@ Or you can list all tags with:
|
|||
docker exec -it shlink_container shlink tag:list
|
||||
```
|
||||
|
||||
Or process remaining visits with:
|
||||
Or locate remaining visits with:
|
||||
|
||||
```bash
|
||||
docker exec -it shlink_container shlink visit:process
|
||||
docker exec -it shlink_container shlink visit:locate
|
||||
```
|
||||
|
||||
All shlink commands will work the same way.
|
||||
|
@ -56,9 +55,9 @@ docker exec -it shlink_container shlink
|
|||
|
||||
The image comes with a working sqlite database, but in production you will probably want to usa a distributed database.
|
||||
|
||||
It is possible to use a set of env vars to make this shlink instance interact with an external MySQL, MariaDB or PostgreSQL database.
|
||||
It is possible to use a set of env vars to make this shlink instance interact with an external MySQL, MariaDB, PostgreSQL or Microsoft SQL Server database.
|
||||
|
||||
* `DB_DRIVER`: **[Mandatory]**. Use the value **mysql**, **maria** or **postgres** to prevent the sqlite database to be used.
|
||||
* `DB_DRIVER`: **[Mandatory]**. Use the value **mysql**, **maria**, **postgres** or **mssql** to prevent the sqlite database to be used.
|
||||
* `DB_NAME`: [Optional]. The database name to be used. Defaults to **shlink**.
|
||||
* `DB_USER`: **[Mandatory]**. The username credential for the database server.
|
||||
* `DB_PASSWORD`: **[Mandatory]**. The password credential for the database server.
|
||||
|
@ -67,8 +66,9 @@ It is possible to use a set of env vars to make this shlink instance interact wi
|
|||
* Default value is based on the value provided for `DB_DRIVER`:
|
||||
* **mysql** or **maria** -> `3306`
|
||||
* **postgres** -> `5432`
|
||||
* **mssql** -> `1433`
|
||||
|
||||
> PostgreSQL is supported since v1.16.1 of this image. Do not try to use it with previous versions.
|
||||
> PostgreSQL is supported since v1.16.1 and Microsoft SQL server since v2.1.0. Do not try to use them with previous versions.
|
||||
|
||||
Taking this into account, you could run shlink on a local docker service like this:
|
||||
|
||||
|
@ -92,7 +92,7 @@ This is the complete list of supported env vars:
|
|||
|
||||
* `SHORT_DOMAIN_HOST`: The custom short domain used for this shlink instance. For example **doma.in**.
|
||||
* `SHORT_DOMAIN_SCHEMA`: Either **http** or **https**.
|
||||
* `DB_DRIVER`: **sqlite** (which is the default value), **mysql**, **maria** or **postgres**.
|
||||
* `DB_DRIVER`: **sqlite** (which is the default value), **mysql**, **maria**, **postgres** or **mssql**.
|
||||
* `DB_NAME`: The database name to be used when using an external database driver. Defaults to **shlink**.
|
||||
* `DB_USER`: The username credential to be used when using an external database driver.
|
||||
* `DB_PASSWORD`: The password credential to be used when using an external database driver.
|
||||
|
@ -101,6 +101,7 @@ This is the complete list of supported env vars:
|
|||
* Default value is based on the value provided for `DB_DRIVER`:
|
||||
* **mysql** or **maria** -> `3306`
|
||||
* **postgres** -> `5432`
|
||||
* **mssql** -> `1433`
|
||||
* `DISABLE_TRACK_PARAM`: The name of a query param that can be used to visit short URLs avoiding the visit to be tracked. This feature won't be available if not value is provided.
|
||||
* `DELETE_SHORT_URL_THRESHOLD`: The amount of visits on short URLs which will not allow them to be deleted. Defaults to `15`.
|
||||
* `VALIDATE_URLS`: Boolean which tells if shlink should validate a status 20x is returned (after following redirects) when trying to shorten a URL. Defaults to `false`.
|
||||
|
@ -111,6 +112,7 @@ This is the complete list of supported env vars:
|
|||
* `WEB_WORKER_NUM`: The amount of concurrent http requests this shlink instance will be able to server. Defaults to 16.
|
||||
* `TASK_WORKER_NUM`: The amount of concurrent background tasks this shlink instance will be able to execute. Defaults to 16.
|
||||
* `VISITS_WEBHOOKS`: A comma-separated list of URLs that will receive a `POST` request when a short URL receives a visit.
|
||||
* `DEFAULT_SHORT_CODES_LENGTH`: The length you want generated short codes to have. It defaults to 5 and has to be at least 4, so any value smaller than that will fall back to 4.
|
||||
* `REDIS_SERVERS`: A comma-separated list of redis servers where Shlink locks are stored (locks are used to prevent some operations to be run more than once in parallel).
|
||||
|
||||
This is important when running more than one Shlink instance ([Multi instance considerations](#multi-instance-considerations)). If not provided, Shlink stores locks on every instance separately.
|
||||
|
@ -144,6 +146,7 @@ docker run \
|
|||
-e WEB_WORKER_NUM=64 \
|
||||
-e TASK_WORKER_NUM=32 \
|
||||
-e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \
|
||||
-e DEFAULT_SHORT_CODES_LENGTH=6 \
|
||||
shlinkio/shlink:stable
|
||||
```
|
||||
|
||||
|
@ -168,6 +171,7 @@ The whole configuration should have this format, but it can be split into multip
|
|||
"base_path": "/my-campaign",
|
||||
"web_worker_num": 64,
|
||||
"task_worker_num": 32,
|
||||
"default_short_codes_length": 6,
|
||||
"redis_servers": [
|
||||
"tcp://172.20.0.1:6379",
|
||||
"tcp://172.20.0.2:6379"
|
||||
|
|
15
docker/build
Executable file
15
docker/build
Executable file
|
@ -0,0 +1,15 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
|
||||
|
||||
# If there is a tag, regardless the branch, build that docker tag and also "stable"
|
||||
if [[ ! -z $TRAVIS_TAG ]]; then
|
||||
docker build --build-arg SHLINK_VERSION=${TRAVIS_TAG#?} -t shlinkio/shlink:${TRAVIS_TAG#?} -t shlinkio/shlink:stable .
|
||||
docker push shlinkio/shlink:${TRAVIS_TAG#?}
|
||||
docker push shlinkio/shlink:stable
|
||||
# If build branch is develop, build latest (on master, when there's no tag, do not build anything)
|
||||
elif [[ "$TRAVIS_BRANCH" == 'develop' ]]; then
|
||||
docker build -t shlinkio/shlink:latest .
|
||||
docker push shlinkio/shlink:latest
|
||||
fi
|
|
@ -11,16 +11,21 @@ use function explode;
|
|||
use function Functional\contains;
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
||||
use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH;
|
||||
|
||||
$helper = new class {
|
||||
private const DB_DRIVERS_MAP = [
|
||||
'mysql' => 'pdo_mysql',
|
||||
'maria' => 'pdo_mysql',
|
||||
'postgres' => 'pdo_pgsql',
|
||||
'mssql' => 'pdo_sqlsrv',
|
||||
];
|
||||
private const DB_PORTS_MAP = [
|
||||
'mysql' => '3306',
|
||||
'maria' => '3306',
|
||||
'postgres' => '5432',
|
||||
'mssql' => '1433',
|
||||
];
|
||||
|
||||
public function getDbConfig(): array
|
||||
|
@ -68,6 +73,12 @@ $helper = new class {
|
|||
$redisServers = env('REDIS_SERVERS');
|
||||
return $redisServers === null ? null : ['servers' => $redisServers];
|
||||
}
|
||||
|
||||
public function getDefaultShortCodesLength(): int
|
||||
{
|
||||
$value = (int) env('DEFAULT_SHORT_CODES_LENGTH', DEFAULT_SHORT_CODES_LENGTH);
|
||||
return $value < MIN_SHORT_CODES_LENGTH ? MIN_SHORT_CODES_LENGTH : $value;
|
||||
}
|
||||
};
|
||||
|
||||
return [
|
||||
|
@ -94,6 +105,7 @@ return [
|
|||
],
|
||||
'validate_url' => (bool) env('VALIDATE_URLS', false),
|
||||
'visits_webhooks' => $helper->getVisitsWebhooks(),
|
||||
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
|
||||
],
|
||||
|
||||
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),
|
||||
|
|
|
@ -243,6 +243,10 @@
|
|||
"domain": {
|
||||
"description": "The domain to which the short URL will be attached",
|
||||
"type": "string"
|
||||
},
|
||||
"shortCodeLength": {
|
||||
"description": "The length for generated short code. It has to be at least 4 and defaults to 5. It will be ignored when customSlug is provided",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -112,6 +112,10 @@
|
|||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"longUrl": {
|
||||
"description": "The long URL this short URL will redirect to",
|
||||
"type": "string"
|
||||
},
|
||||
"validSince": {
|
||||
"description": "The date (in ISO-8601 format) from which this short code will be valid",
|
||||
"type": "string"
|
||||
|
@ -157,6 +161,7 @@
|
|||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"longUrl",
|
||||
"validSince",
|
||||
"validUntil",
|
||||
"maxVisits"
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
},
|
||||
|
||||
"externalDocs": {
|
||||
"url": "https://shlink.io/api-docs",
|
||||
"url": "https://shlink.io/documentation/api-docs",
|
||||
"description": "Find more info on how to start using this API here"
|
||||
},
|
||||
|
||||
|
|
10
hooks/build
10
hooks/build
|
@ -1,10 +0,0 @@
|
|||
#!/bin/bash
|
||||
set -ex
|
||||
|
||||
if [[ ${SOURCE_BRANCH} == 'develop' ]]; then
|
||||
SHLINK_RELEASE='latest'
|
||||
else
|
||||
SHLINK_RELEASE=${SOURCE_BRANCH#?}
|
||||
fi
|
||||
|
||||
docker build --build-arg SHLINK_VERSION=${SHLINK_RELEASE} -t ${IMAGE_NAME} .
|
|
@ -11,6 +11,7 @@ use Laminas\ServiceManager\Factory\InvokableFactory;
|
|||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
|
||||
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
|
||||
use Shlinkio\Shlink\Core\Service;
|
||||
use Shlinkio\Shlink\Core\Visit;
|
||||
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
|
@ -19,6 +20,8 @@ use Symfony\Component\Console as SymfonyCli;
|
|||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
|
||||
|
||||
return [
|
||||
|
||||
'dependencies' => [
|
||||
|
@ -52,16 +55,20 @@ return [
|
|||
],
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, 'Shlinkio\Shlink\LocalLockFactory'],
|
||||
GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY],
|
||||
|
||||
Command\ShortUrl\GenerateShortUrlCommand::class => [Service\UrlShortener::class, 'config.url_shortener.domain'],
|
||||
Command\ShortUrl\GenerateShortUrlCommand::class => [
|
||||
Service\UrlShortener::class,
|
||||
'config.url_shortener.domain',
|
||||
'config.url_shortener.default_short_codes_length',
|
||||
],
|
||||
Command\ShortUrl\ResolveUrlCommand::class => [Service\ShortUrl\ShortUrlResolver::class],
|
||||
Command\ShortUrl\ListShortUrlsCommand::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'],
|
||||
Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class],
|
||||
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
|
||||
|
||||
Command\Visit\LocateVisitsCommand::class => [
|
||||
Service\VisitService::class,
|
||||
Visit\VisitLocator::class,
|
||||
IpLocationResolverInterface::class,
|
||||
LockFactory::class,
|
||||
GeolocationDbUpdater::class,
|
||||
|
|
|
@ -11,8 +11,6 @@ use Symfony\Component\Console\Output\OutputInterface;
|
|||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
use function array_unshift;
|
||||
|
||||
abstract class AbstractDatabaseCommand extends AbstractLockedCommand
|
||||
{
|
||||
private ProcessHelper $processHelper;
|
||||
|
@ -27,7 +25,7 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand
|
|||
|
||||
protected function runPhpCommand(OutputInterface $output, array $command): void
|
||||
{
|
||||
array_unshift($command, $this->phpBinary);
|
||||
$command = [$this->phpBinary, ...$command, '--no-interaction'];
|
||||
$this->processHelper->mustRun($output, $command);
|
||||
}
|
||||
|
||||
|
|
|
@ -30,12 +30,14 @@ class GenerateShortUrlCommand extends Command
|
|||
|
||||
private UrlShortenerInterface $urlShortener;
|
||||
private array $domainConfig;
|
||||
private int $defaultShortCodeLength;
|
||||
|
||||
public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig)
|
||||
public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig, int $defaultShortCodeLength)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->urlShortener = $urlShortener;
|
||||
$this->domainConfig = $domainConfig;
|
||||
$this->defaultShortCodeLength = $defaultShortCodeLength;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
|
@ -87,6 +89,12 @@ class GenerateShortUrlCommand extends Command
|
|||
'd',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The domain to which this short URL will be attached.',
|
||||
)
|
||||
->addOption(
|
||||
'shortCodeLength',
|
||||
'l',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The length for generated short code (it will be ignored if --customSlug was provided).',
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -117,6 +125,7 @@ class GenerateShortUrlCommand extends Command
|
|||
$tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
|
||||
$customSlug = $input->getOption('customSlug');
|
||||
$maxVisits = $input->getOption('maxVisits');
|
||||
$shortCodeLength = $input->getOption('shortCodeLength') ?? $this->defaultShortCodeLength;
|
||||
|
||||
try {
|
||||
$shortUrl = $this->urlShortener->urlToShortCode(
|
||||
|
@ -129,6 +138,7 @@ class GenerateShortUrlCommand extends Command
|
|||
ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
|
||||
ShortUrlMetaInputFilter::FIND_IF_EXISTS => $input->getOption('findIfExists'),
|
||||
ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'),
|
||||
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
|
||||
]),
|
||||
);
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Entity\Visit;
|
|||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
@ -76,7 +77,7 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
|
|||
|
||||
$rows = map($paginator->getCurrentItems(), function (Visit $visit) {
|
||||
$rowData = $visit->jsonSerialize();
|
||||
$rowData['country'] = $visit->getVisitLocation()->getCountryName();
|
||||
$rowData['country'] = ($visit->getVisitLocation() ?? new UnknownVisitLocation())->getCountryName();
|
||||
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
|
||||
});
|
||||
ShlinkTable::fromOutput($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
|
||||
|
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Exception;
|
||||
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
|
||||
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
|
@ -14,12 +13,15 @@ use Shlinkio\Shlink\Common\Util\IpAddress;
|
|||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
|
||||
use Shlinkio\Shlink\Core\Service\VisitServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitGeolocationHelperInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitLocatorInterface;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
use Symfony\Component\Console\Exception\RuntimeException;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
|
@ -27,11 +29,11 @@ use Throwable;
|
|||
|
||||
use function sprintf;
|
||||
|
||||
class LocateVisitsCommand extends AbstractLockedCommand
|
||||
class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocationHelperInterface
|
||||
{
|
||||
public const NAME = 'visit:locate';
|
||||
|
||||
private VisitServiceInterface $visitService;
|
||||
private VisitLocatorInterface $visitLocator;
|
||||
private IpLocationResolverInterface $ipLocationResolver;
|
||||
private GeolocationDbUpdaterInterface $dbUpdater;
|
||||
|
||||
|
@ -39,13 +41,13 @@ class LocateVisitsCommand extends AbstractLockedCommand
|
|||
private ?ProgressBar $progressBar = null;
|
||||
|
||||
public function __construct(
|
||||
VisitServiceInterface $visitService,
|
||||
VisitLocatorInterface $visitLocator,
|
||||
IpLocationResolverInterface $ipLocationResolver,
|
||||
LockFactory $locker,
|
||||
GeolocationDbUpdaterInterface $dbUpdater
|
||||
) {
|
||||
parent::__construct($locker);
|
||||
$this->visitService = $visitService;
|
||||
$this->visitLocator = $visitLocator;
|
||||
$this->ipLocationResolver = $ipLocationResolver;
|
||||
$this->dbUpdater = $dbUpdater;
|
||||
}
|
||||
|
@ -54,32 +56,79 @@ class LocateVisitsCommand extends AbstractLockedCommand
|
|||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Resolves visits origin locations.');
|
||||
->setDescription('Resolves visits origin locations.')
|
||||
->addOption(
|
||||
'retry',
|
||||
'r',
|
||||
InputOption::VALUE_NONE,
|
||||
'Will retry the location of visits that were located with a not-found location, in case it was due to '
|
||||
. 'a temporal issue.',
|
||||
)
|
||||
->addOption(
|
||||
'all',
|
||||
'a',
|
||||
InputOption::VALUE_NONE,
|
||||
'When provided together with --retry, will locate all existing visits, regardless the fact that they '
|
||||
. 'have already been located.',
|
||||
);
|
||||
}
|
||||
|
||||
protected function initialize(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$this->io = new SymfonyStyle($input, $output);
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$retry = $input->getOption('retry');
|
||||
$all = $input->getOption('all');
|
||||
|
||||
if ($all && !$retry) {
|
||||
$this->io->writeln(
|
||||
'<comment>The <fg=yellow;options=bold>--all</> flag has no effect on its own. You have to provide it '
|
||||
. 'together with <fg=yellow;options=bold>--retry</>.</comment>',
|
||||
);
|
||||
}
|
||||
|
||||
if ($all && $retry && ! $this->warnAndVerifyContinue()) {
|
||||
throw new RuntimeException('Execution aborted');
|
||||
}
|
||||
}
|
||||
|
||||
private function warnAndVerifyContinue(): bool
|
||||
{
|
||||
$this->io->warning([
|
||||
'You are about to process the location of all existing visits your short URLs received.',
|
||||
'Since shlink saves visitors IP addresses anonymized, you could end up losing precision on some of '
|
||||
. 'your visits.',
|
||||
'Also, if you have a large amount of visits, this can be a very time consuming process. '
|
||||
. 'Continue at your own risk.',
|
||||
]);
|
||||
return $this->io->confirm('Do you want to proceed?', false);
|
||||
}
|
||||
|
||||
protected function lockedExecute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$this->io = new SymfonyStyle($input, $output);
|
||||
$retry = $input->getOption('retry');
|
||||
$all = $retry && $input->getOption('all');
|
||||
|
||||
try {
|
||||
$this->checkDbUpdate();
|
||||
|
||||
$this->visitService->locateUnlocatedVisits(
|
||||
[$this, 'getGeolocationDataForVisit'],
|
||||
static function (VisitLocation $location) use ($output): void {
|
||||
if (!$location->isEmpty()) {
|
||||
$output->writeln(
|
||||
sprintf(' [<info>Address located at "%s"</info>]', $location->getCountryName()),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
if ($all) {
|
||||
$this->visitLocator->locateAllVisits($this);
|
||||
} else {
|
||||
$this->visitLocator->locateUnlocatedVisits($this);
|
||||
if ($retry) {
|
||||
$this->visitLocator->locateVisitsWithEmptyLocation($this);
|
||||
}
|
||||
}
|
||||
|
||||
$this->io->success('Finished processing all IPs');
|
||||
$this->io->success('Finished locating visits');
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$this->io->error($e->getMessage());
|
||||
if ($e instanceof Exception && $this->io->isVerbose()) {
|
||||
if ($e instanceof Throwable && $this->io->isVerbose()) {
|
||||
$this->getApplication()->renderThrowable($e, $this->io);
|
||||
}
|
||||
|
||||
|
@ -87,7 +136,10 @@ class LocateVisitsCommand extends AbstractLockedCommand
|
|||
}
|
||||
}
|
||||
|
||||
public function getGeolocationDataForVisit(Visit $visit): Location
|
||||
/**
|
||||
* @throws IpCannotBeLocatedException
|
||||
*/
|
||||
public function geolocateVisit(Visit $visit): Location
|
||||
{
|
||||
if (! $visit->hasRemoteAddr()) {
|
||||
$this->io->writeln(
|
||||
|
@ -116,6 +168,14 @@ class LocateVisitsCommand extends AbstractLockedCommand
|
|||
}
|
||||
}
|
||||
|
||||
public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void
|
||||
{
|
||||
$message = ! $visitLocation->isEmpty()
|
||||
? sprintf(' [<info>Address located in "%s"</info>]', $visitLocation->getCountryName())
|
||||
: ' [<comment>Address not found</comment>]';
|
||||
$this->io->writeln($message);
|
||||
}
|
||||
|
||||
private function checkDbUpdate(): void
|
||||
{
|
||||
try {
|
||||
|
|
|
@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\CLI;
|
||||
|
||||
use function Shlinkio\Shlink\Common\loadConfigFromGlob;
|
||||
use function Shlinkio\Shlink\Config\loadConfigFromGlob;
|
||||
|
||||
class ConfigProvider
|
||||
{
|
||||
|
|
|
@ -18,6 +18,7 @@ use Symfony\Component\Console\Tester\CommandTester;
|
|||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Lock\LockInterface;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class CreateDatabaseCommandTest extends TestCase
|
||||
{
|
||||
|
@ -114,7 +115,8 @@ class CreateDatabaseCommandTest extends TestCase
|
|||
'/usr/local/bin/php',
|
||||
CreateDatabaseCommand::DOCTRINE_SCRIPT,
|
||||
CreateDatabaseCommand::DOCTRINE_CREATE_SCHEMA_COMMAND,
|
||||
], Argument::cetera());
|
||||
'--no-interaction',
|
||||
], Argument::cetera())->willReturn(new Process([]));
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
|
|
@ -15,6 +15,7 @@ use Symfony\Component\Console\Tester\CommandTester;
|
|||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Lock\LockInterface;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class MigrateDatabaseCommandTest extends TestCase
|
||||
{
|
||||
|
@ -53,7 +54,8 @@ class MigrateDatabaseCommandTest extends TestCase
|
|||
'/usr/local/bin/php',
|
||||
MigrateDatabaseCommand::DOCTRINE_MIGRATIONS_SCRIPT,
|
||||
MigrateDatabaseCommand::DOCTRINE_MIGRATE_COMMAND,
|
||||
], Argument::cetera());
|
||||
'--no-interaction',
|
||||
], Argument::cetera())->willReturn(new Process([]));
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
|
|
@ -31,7 +31,7 @@ class GenerateShortUrlCommandTest extends TestCase
|
|||
public function setUp(): void
|
||||
{
|
||||
$this->urlShortener = $this->prophesize(UrlShortener::class);
|
||||
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), self::DOMAIN_CONFIG);
|
||||
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), self::DOMAIN_CONFIG, 5);
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
$this->commandTester = new CommandTester($command);
|
||||
|
|
|
@ -15,18 +15,21 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
|||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Service\VisitService;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitGeolocationHelperInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitLocator;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Exception\RuntimeException;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Symfony\Component\Lock;
|
||||
|
||||
use function array_shift;
|
||||
use function sprintf;
|
||||
|
||||
use const PHP_EOL;
|
||||
|
||||
class LocateVisitsCommandTest extends TestCase
|
||||
{
|
||||
private CommandTester $commandTester;
|
||||
|
@ -38,7 +41,7 @@ class LocateVisitsCommandTest extends TestCase
|
|||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->visitService = $this->prophesize(VisitService::class);
|
||||
$this->visitService = $this->prophesize(VisitLocator::class);
|
||||
$this->ipResolver = $this->prophesize(IpLocationResolverInterface::class);
|
||||
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
|
||||
|
||||
|
@ -61,31 +64,53 @@ class LocateVisitsCommandTest extends TestCase
|
|||
$this->commandTester = new CommandTester($command);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function allPendingVisitsAreProcessed(): void
|
||||
{
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideArgs
|
||||
*/
|
||||
public function expectedSetOfVisitsIsProcessedBasedOnArgs(
|
||||
int $expectedUnlocatedCalls,
|
||||
int $expectedEmptyCalls,
|
||||
int $expectedAllCalls,
|
||||
bool $expectWarningPrint,
|
||||
array $args
|
||||
): void {
|
||||
$visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
|
||||
$location = new VisitLocation(Location::emptyInstance());
|
||||
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
|
||||
|
||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
|
||||
function (array $args) use ($visit, $location): void {
|
||||
$firstCallback = array_shift($args);
|
||||
$firstCallback($visit);
|
||||
|
||||
$secondCallback = array_shift($args);
|
||||
$secondCallback($location, $visit);
|
||||
},
|
||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will($mockMethodBehavior);
|
||||
$locateEmptyVisits = $this->visitService->locateVisitsWithEmptyLocation(Argument::cetera())->will(
|
||||
$mockMethodBehavior,
|
||||
);
|
||||
$locateAllVisits = $this->visitService->locateAllVisits(Argument::cetera())->will($mockMethodBehavior);
|
||||
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
|
||||
Location::emptyInstance(),
|
||||
);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$this->commandTester->setInputs(['y']);
|
||||
$this->commandTester->execute($args);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('Processing IP 1.2.3.0', $output);
|
||||
$locateVisits->shouldHaveBeenCalledOnce();
|
||||
$resolveIpLocation->shouldHaveBeenCalledOnce();
|
||||
if ($expectWarningPrint) {
|
||||
$this->assertStringContainsString('Continue at your own risk', $output);
|
||||
} else {
|
||||
$this->assertStringNotContainsString('Continue at your own risk', $output);
|
||||
}
|
||||
$locateVisits->shouldHaveBeenCalledTimes($expectedUnlocatedCalls);
|
||||
$locateEmptyVisits->shouldHaveBeenCalledTimes($expectedEmptyCalls);
|
||||
$locateAllVisits->shouldHaveBeenCalledTimes($expectedAllCalls);
|
||||
$resolveIpLocation->shouldHaveBeenCalledTimes(
|
||||
$expectedUnlocatedCalls + $expectedEmptyCalls + $expectedAllCalls,
|
||||
);
|
||||
}
|
||||
|
||||
public function provideArgs(): iterable
|
||||
{
|
||||
yield 'no args' => [1, 0, 0, false, []];
|
||||
yield 'retry' => [1, 1, 0, false, ['--retry' => true]];
|
||||
yield 'all' => [0, 0, 1, true, ['--retry' => true, '--all' => true]];
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -98,13 +123,7 @@ class LocateVisitsCommandTest extends TestCase
|
|||
$location = new VisitLocation(Location::emptyInstance());
|
||||
|
||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
|
||||
function (array $args) use ($visit, $location): void {
|
||||
$firstCallback = array_shift($args);
|
||||
$firstCallback($visit);
|
||||
|
||||
$secondCallback = array_shift($args);
|
||||
$secondCallback($location, $visit);
|
||||
},
|
||||
$this->invokeHelperMethods($visit, $location),
|
||||
);
|
||||
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
|
||||
Location::emptyInstance(),
|
||||
|
@ -137,13 +156,7 @@ class LocateVisitsCommandTest extends TestCase
|
|||
$location = new VisitLocation(Location::emptyInstance());
|
||||
|
||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
|
||||
function (array $args) use ($visit, $location): void {
|
||||
$firstCallback = array_shift($args);
|
||||
$firstCallback($visit);
|
||||
|
||||
$secondCallback = array_shift($args);
|
||||
$secondCallback($location, $visit);
|
||||
},
|
||||
$this->invokeHelperMethods($visit, $location),
|
||||
);
|
||||
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willThrow(WrongIpException::class);
|
||||
|
||||
|
@ -156,6 +169,17 @@ class LocateVisitsCommandTest extends TestCase
|
|||
$resolveIpLocation->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
private function invokeHelperMethods(Visit $visit, VisitLocation $location): callable
|
||||
{
|
||||
return function (array $args) use ($visit, $location): void {
|
||||
/** @var VisitGeolocationHelperInterface $helper */
|
||||
[$helper] = $args;
|
||||
|
||||
$helper->geolocateVisit($visit);
|
||||
$helper->onVisitLocated($location, $visit);
|
||||
};
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function noActionIsPerformedIfLockIsAcquired(): void
|
||||
{
|
||||
|
@ -212,4 +236,33 @@ class LocateVisitsCommandTest extends TestCase
|
|||
yield [true, '[Warning] GeoLite2 database update failed. Proceeding with old version.'];
|
||||
yield [false, 'GeoLite2 database download failed. It is not possible to locate visits.'];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function providingAllFlagOnItsOwnDisplaysNotice(): void
|
||||
{
|
||||
$this->commandTester->execute(['--all' => true]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('The --all flag has no effect on its own', $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideAbortInputs
|
||||
*/
|
||||
public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Execution aborted');
|
||||
|
||||
$this->commandTester->setInputs($inputs);
|
||||
$this->commandTester->execute(['--all' => true, '--retry' => true]);
|
||||
}
|
||||
|
||||
public function provideAbortInputs(): iterable
|
||||
{
|
||||
yield 'n' => [['n']];
|
||||
yield 'no' => [['no']];
|
||||
yield 'default' => [[PHP_EOL]];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
|||
use Mezzio\Router\RouterInterface;
|
||||
use Mezzio\Template\TemplateRendererInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler;
|
||||
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
||||
|
||||
|
@ -27,7 +28,7 @@ return [
|
|||
Service\UrlShortener::class => ConfigAbstractFactory::class,
|
||||
Service\VisitsTracker::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrlService::class => ConfigAbstractFactory::class,
|
||||
Service\VisitService::class => ConfigAbstractFactory::class,
|
||||
Visit\VisitLocator::class => ConfigAbstractFactory::class,
|
||||
Service\Tag\TagService::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
|
||||
|
@ -39,6 +40,8 @@ return [
|
|||
Action\QrCodeAction::class => ConfigAbstractFactory::class,
|
||||
|
||||
Middleware\QrCodeCacheMiddleware::class => ConfigAbstractFactory::class,
|
||||
|
||||
Resolver\PersistenceDomainResolver::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
|
@ -51,10 +54,10 @@ return [
|
|||
Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'],
|
||||
Options\UrlShortenerOptions::class => ['config.url_shortener'],
|
||||
|
||||
Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Options\UrlShortenerOptions::class],
|
||||
Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Resolver\PersistenceDomainResolver::class],
|
||||
Service\VisitsTracker::class => ['em', EventDispatcherInterface::class],
|
||||
Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class],
|
||||
Service\VisitService::class => ['em'],
|
||||
Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class, Util\UrlValidator::class],
|
||||
Visit\VisitLocator::class => ['em'],
|
||||
Service\Tag\TagService::class => ['em'],
|
||||
Service\ShortUrl\DeleteShortUrlService::class => [
|
||||
'em',
|
||||
|
@ -63,7 +66,7 @@ return [
|
|||
],
|
||||
Service\ShortUrl\ShortUrlResolver::class => ['em'],
|
||||
|
||||
Util\UrlValidator::class => ['httpClient'],
|
||||
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
|
||||
|
||||
Action\RedirectAction::class => [
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
|
@ -84,6 +87,8 @@ return [
|
|||
],
|
||||
|
||||
Middleware\QrCodeCacheMiddleware::class => [Cache::class],
|
||||
|
||||
Resolver\PersistenceDomainResolver::class => ['em'],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -44,4 +44,10 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
|||
->columnName('lon')
|
||||
->nullable(false)
|
||||
->build();
|
||||
|
||||
$builder->createField('isEmpty', Types::BOOLEAN)
|
||||
->columnName('is_empty')
|
||||
->option('default', false)
|
||||
->nullable(false)
|
||||
->build();
|
||||
};
|
||||
|
|
|
@ -10,7 +10,11 @@ use PUGX\Shortid\Factory as ShortIdFactory;
|
|||
|
||||
use function sprintf;
|
||||
|
||||
function generateRandomShortCode(int $length = 5): string
|
||||
const DEFAULT_SHORT_CODES_LENGTH = 5;
|
||||
const MIN_SHORT_CODES_LENGTH = 4;
|
||||
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
|
||||
|
||||
function generateRandomShortCode(int $length): string
|
||||
{
|
||||
static $shortIdFactory;
|
||||
if ($shortIdFactory === null) {
|
||||
|
|
|
@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
use Laminas\Stdlib\ArrayUtils;
|
||||
use Shlinkio\Shlink\Installer\Util\PathCollection;
|
||||
use Shlinkio\Shlink\Config\Collection\PathCollection;
|
||||
|
||||
use function array_flip;
|
||||
use function array_intersect_key;
|
||||
|
@ -24,7 +24,7 @@ class SimplifiedConfigParser
|
|||
'validate_url' => ['url_shortener', 'validate_url'],
|
||||
'invalid_short_url_redirect_to' => ['not_found_redirects', 'invalid_short_url'],
|
||||
'regular_404_redirect_to' => ['not_found_redirects', 'regular_404'],
|
||||
'base_url_redirect_to' => ['not_found_redirects', 'base_path'],
|
||||
'base_url_redirect_to' => ['not_found_redirects', 'base_url'],
|
||||
'db_config' => ['entity_manager', 'connection'],
|
||||
'delete_short_url_threshold' => ['delete_short_urls', 'visits_threshold'],
|
||||
'redis_servers' => ['cache', 'redis', 'servers'],
|
||||
|
@ -32,6 +32,7 @@ class SimplifiedConfigParser
|
|||
'web_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'worker_num'],
|
||||
'task_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'task_worker_num'],
|
||||
'visits_webhooks' => ['url_shortener', 'visits_webhooks'],
|
||||
'default_short_codes_length' => ['url_shortener', 'default_short_codes_length'],
|
||||
];
|
||||
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
|
||||
'delete_short_url_threshold' => [
|
||||
|
|
|
@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use function Shlinkio\Shlink\Common\loadConfigFromGlob;
|
||||
use function Shlinkio\Shlink\Config\loadConfigFromGlob;
|
||||
|
||||
class ConfigProvider
|
||||
{
|
||||
|
|
|
@ -12,6 +12,7 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
|||
use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver\SimpleDomainResolver;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
|
||||
use function array_reduce;
|
||||
|
@ -32,8 +33,9 @@ class ShortUrl extends AbstractEntity
|
|||
private ?Chronos $validSince = null;
|
||||
private ?Chronos $validUntil = null;
|
||||
private ?int $maxVisits = null;
|
||||
private ?Domain $domain;
|
||||
private ?Domain $domain = null;
|
||||
private bool $customSlugWasProvided;
|
||||
private int $shortCodeLength;
|
||||
|
||||
public function __construct(
|
||||
string $longUrl,
|
||||
|
@ -50,7 +52,8 @@ class ShortUrl extends AbstractEntity
|
|||
$this->validUntil = $meta->getValidUntil();
|
||||
$this->maxVisits = $meta->getMaxVisits();
|
||||
$this->customSlugWasProvided = $meta->hasCustomSlug();
|
||||
$this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode();
|
||||
$this->shortCodeLength = $meta->getShortCodeLength();
|
||||
$this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($this->shortCodeLength);
|
||||
$this->domain = ($domainResolver ?? new SimpleDomainResolver())->resolveDomain($meta->getDomain());
|
||||
}
|
||||
|
||||
|
@ -91,16 +94,19 @@ class ShortUrl extends AbstractEntity
|
|||
return $this;
|
||||
}
|
||||
|
||||
public function updateMeta(ShortUrlMeta $shortCodeMeta): void
|
||||
public function update(ShortUrlEdit $shortUrlEdit): void
|
||||
{
|
||||
if ($shortCodeMeta->hasValidSince()) {
|
||||
$this->validSince = $shortCodeMeta->getValidSince();
|
||||
if ($shortUrlEdit->hasValidSince()) {
|
||||
$this->validSince = $shortUrlEdit->validSince();
|
||||
}
|
||||
if ($shortCodeMeta->hasValidUntil()) {
|
||||
$this->validUntil = $shortCodeMeta->getValidUntil();
|
||||
if ($shortUrlEdit->hasValidUntil()) {
|
||||
$this->validUntil = $shortUrlEdit->validUntil();
|
||||
}
|
||||
if ($shortCodeMeta->hasMaxVisits()) {
|
||||
$this->maxVisits = $shortCodeMeta->getMaxVisits();
|
||||
if ($shortUrlEdit->hasMaxVisits()) {
|
||||
$this->maxVisits = $shortUrlEdit->maxVisits();
|
||||
}
|
||||
if ($shortUrlEdit->hasLongUrl()) {
|
||||
$this->longUrl = $shortUrlEdit->longUrl();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -119,7 +125,7 @@ class ShortUrl extends AbstractEntity
|
|||
throw ShortCodeCannotBeRegeneratedException::forShortUrlAlreadyPersisted();
|
||||
}
|
||||
|
||||
$this->shortCode = generateRandomShortCode();
|
||||
$this->shortCode = generateRandomShortCode($this->shortCodeLength);
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,6 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
|||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Common\Util\IpAddress;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
|
||||
|
||||
class Visit extends AbstractEntity implements JsonSerializable
|
||||
|
@ -60,9 +59,9 @@ class Visit extends AbstractEntity implements JsonSerializable
|
|||
return $this->shortUrl;
|
||||
}
|
||||
|
||||
public function getVisitLocation(): VisitLocationInterface
|
||||
public function getVisitLocation(): ?VisitLocationInterface
|
||||
{
|
||||
return $this->visitLocation ?? new UnknownVisitLocation();
|
||||
return $this->visitLocation;
|
||||
}
|
||||
|
||||
public function isLocatable(): bool
|
||||
|
|
|
@ -17,6 +17,7 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
|||
private float $latitude;
|
||||
private float $longitude;
|
||||
private string $timezone;
|
||||
private bool $isEmpty;
|
||||
|
||||
public function __construct(Location $location)
|
||||
{
|
||||
|
@ -43,6 +44,11 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
|||
return $this->cityName;
|
||||
}
|
||||
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return $this->isEmpty;
|
||||
}
|
||||
|
||||
private function exchangeLocationInfo(Location $info): void
|
||||
{
|
||||
$this->countryCode = $info->countryCode();
|
||||
|
@ -52,6 +58,15 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
|||
$this->latitude = $info->latitude();
|
||||
$this->longitude = $info->longitude();
|
||||
$this->timezone = $info->timeZone();
|
||||
$this->isEmpty = (
|
||||
$this->countryCode === '' &&
|
||||
$this->countryName === '' &&
|
||||
$this->regionName === '' &&
|
||||
$this->cityName === '' &&
|
||||
$this->latitude === 0.0 &&
|
||||
$this->longitude === 0.0 &&
|
||||
$this->timezone === ''
|
||||
);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
|
@ -64,18 +79,7 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
|||
'latitude' => $this->latitude,
|
||||
'longitude' => $this->longitude,
|
||||
'timezone' => $this->timezone,
|
||||
'isEmpty' => $this->isEmpty,
|
||||
];
|
||||
}
|
||||
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return
|
||||
$this->countryCode === '' &&
|
||||
$this->countryName === '' &&
|
||||
$this->regionName === '' &&
|
||||
$this->cityName === '' &&
|
||||
$this->latitude === 0.0 &&
|
||||
$this->longitude === 0.0 &&
|
||||
$this->timezone === '';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,7 +53,7 @@ class LocateShortUrlVisit
|
|||
}
|
||||
|
||||
if ($this->downloadOrUpdateGeoLiteDb($visitId)) {
|
||||
$this->locateVisit($visitId, $visit);
|
||||
$this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit);
|
||||
}
|
||||
|
||||
$this->eventDispatcher->dispatch(new VisitLocated($visitId));
|
||||
|
@ -80,12 +80,13 @@ class LocateShortUrlVisit
|
|||
return true;
|
||||
}
|
||||
|
||||
private function locateVisit(string $visitId, Visit $visit): void
|
||||
private function locateVisit(string $visitId, ?string $originalIpAddress, Visit $visit): void
|
||||
{
|
||||
$isLocatable = $originalIpAddress !== null || $visit->isLocatable();
|
||||
$addr = $originalIpAddress ?? $visit->getRemoteAddr();
|
||||
|
||||
try {
|
||||
$location = $visit->isLocatable()
|
||||
? $this->ipLocationResolver->resolveIpLocation($visit->getRemoteAddr())
|
||||
: Location::emptyInstance();
|
||||
$location = $isLocatable ? $this->ipLocationResolver->resolveIpLocation($addr) : Location::emptyInstance();
|
||||
|
||||
$visit->locate(new VisitLocation($location));
|
||||
$this->em->flush();
|
||||
|
|
|
@ -9,10 +9,12 @@ use JsonSerializable;
|
|||
final class ShortUrlVisited implements JsonSerializable
|
||||
{
|
||||
private string $visitId;
|
||||
private ?string $originalIpAddress;
|
||||
|
||||
public function __construct(string $visitId)
|
||||
public function __construct(string $visitId, ?string $originalIpAddress = null)
|
||||
{
|
||||
$this->visitId = $visitId;
|
||||
$this->originalIpAddress = $originalIpAddress;
|
||||
}
|
||||
|
||||
public function visitId(): string
|
||||
|
@ -20,8 +22,13 @@ final class ShortUrlVisited implements JsonSerializable
|
|||
return $this->visitId;
|
||||
}
|
||||
|
||||
public function originalIpAddress(): ?string
|
||||
{
|
||||
return $this->originalIpAddress;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return ['visitId' => $this->visitId];
|
||||
return ['visitId' => $this->visitId, 'originalIpAddress' => $this->originalIpAddress];
|
||||
}
|
||||
}
|
||||
|
|
106
module/Core/src/Model/ShortUrlEdit.php
Normal file
106
module/Core/src/Model/ShortUrlEdit.php
Normal file
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||
|
||||
use function array_key_exists;
|
||||
use function Shlinkio\Shlink\Core\parseDateField;
|
||||
|
||||
final class ShortUrlEdit
|
||||
{
|
||||
private bool $longUrlPropWasProvided = false;
|
||||
private ?string $longUrl = null;
|
||||
private bool $validSincePropWasProvided = false;
|
||||
private ?Chronos $validSince = null;
|
||||
private bool $validUntilPropWasProvided = false;
|
||||
private ?Chronos $validUntil = null;
|
||||
private bool $maxVisitsPropWasProvided = false;
|
||||
private ?int $maxVisits = null;
|
||||
|
||||
// Enforce named constructors
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public static function fromRawData(array $data): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->validateAndInit($data);
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function validateAndInit(array $data): void
|
||||
{
|
||||
$inputFilter = new ShortUrlMetaInputFilter($data);
|
||||
if (! $inputFilter->isValid()) {
|
||||
throw ValidationException::fromInputFilter($inputFilter);
|
||||
}
|
||||
|
||||
$this->longUrlPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::LONG_URL, $data);
|
||||
$this->validSincePropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_SINCE, $data);
|
||||
$this->validUntilPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_UNTIL, $data);
|
||||
$this->maxVisitsPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::MAX_VISITS, $data);
|
||||
|
||||
$this->longUrl = $inputFilter->getValue(ShortUrlMetaInputFilter::LONG_URL);
|
||||
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
|
||||
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
|
||||
$this->maxVisits = $this->getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS);
|
||||
}
|
||||
|
||||
private function getOptionalIntFromInputFilter(ShortUrlMetaInputFilter $inputFilter, string $fieldName): ?int
|
||||
{
|
||||
$value = $inputFilter->getValue($fieldName);
|
||||
return $value !== null ? (int) $value : null;
|
||||
}
|
||||
|
||||
public function longUrl(): ?string
|
||||
{
|
||||
return $this->longUrl;
|
||||
}
|
||||
|
||||
public function hasLongUrl(): bool
|
||||
{
|
||||
return $this->longUrlPropWasProvided && $this->longUrl !== null;
|
||||
}
|
||||
|
||||
public function validSince(): ?Chronos
|
||||
{
|
||||
return $this->validSince;
|
||||
}
|
||||
|
||||
public function hasValidSince(): bool
|
||||
{
|
||||
return $this->validSincePropWasProvided;
|
||||
}
|
||||
|
||||
public function validUntil(): ?Chronos
|
||||
{
|
||||
return $this->validUntil;
|
||||
}
|
||||
|
||||
public function hasValidUntil(): bool
|
||||
{
|
||||
return $this->validUntilPropWasProvided;
|
||||
}
|
||||
|
||||
public function maxVisits(): ?int
|
||||
{
|
||||
return $this->maxVisits;
|
||||
}
|
||||
|
||||
public function hasMaxVisits(): bool
|
||||
{
|
||||
return $this->maxVisitsPropWasProvided;
|
||||
}
|
||||
}
|
|
@ -8,22 +8,21 @@ use Cake\Chronos\Chronos;
|
|||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||
|
||||
use function array_key_exists;
|
||||
use function Shlinkio\Shlink\Core\parseDateField;
|
||||
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
||||
|
||||
final class ShortUrlMeta
|
||||
{
|
||||
private bool $validSincePropWasProvided = false;
|
||||
private ?Chronos $validSince = null;
|
||||
private bool $validUntilPropWasProvided = false;
|
||||
private ?Chronos $validUntil = null;
|
||||
private ?string $customSlug = null;
|
||||
private bool $maxVisitsPropWasProvided = false;
|
||||
private ?int $maxVisits = null;
|
||||
private ?bool $findIfExists = null;
|
||||
private ?string $domain = null;
|
||||
private int $shortCodeLength = 5;
|
||||
|
||||
// Force named constructors
|
||||
// Enforce named constructors
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
@ -54,15 +53,21 @@ final class ShortUrlMeta
|
|||
}
|
||||
|
||||
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
|
||||
$this->validSincePropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_SINCE, $data);
|
||||
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
|
||||
$this->validUntilPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_UNTIL, $data);
|
||||
$this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
|
||||
$maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS);
|
||||
$this->maxVisits = $maxVisits !== null ? (int) $maxVisits : null;
|
||||
$this->maxVisitsPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::MAX_VISITS, $data);
|
||||
$this->maxVisits = $this->getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS);
|
||||
$this->findIfExists = $inputFilter->getValue(ShortUrlMetaInputFilter::FIND_IF_EXISTS);
|
||||
$this->domain = $inputFilter->getValue(ShortUrlMetaInputFilter::DOMAIN);
|
||||
$this->shortCodeLength = $this->getOptionalIntFromInputFilter(
|
||||
$inputFilter,
|
||||
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH,
|
||||
) ?? DEFAULT_SHORT_CODES_LENGTH;
|
||||
}
|
||||
|
||||
private function getOptionalIntFromInputFilter(ShortUrlMetaInputFilter $inputFilter, string $fieldName): ?int
|
||||
{
|
||||
$value = $inputFilter->getValue($fieldName);
|
||||
return $value !== null ? (int) $value : null;
|
||||
}
|
||||
|
||||
public function getValidSince(): ?Chronos
|
||||
|
@ -72,7 +77,7 @@ final class ShortUrlMeta
|
|||
|
||||
public function hasValidSince(): bool
|
||||
{
|
||||
return $this->validSincePropWasProvided;
|
||||
return $this->validSince !== null;
|
||||
}
|
||||
|
||||
public function getValidUntil(): ?Chronos
|
||||
|
@ -82,7 +87,7 @@ final class ShortUrlMeta
|
|||
|
||||
public function hasValidUntil(): bool
|
||||
{
|
||||
return $this->validUntilPropWasProvided;
|
||||
return $this->validUntil !== null;
|
||||
}
|
||||
|
||||
public function getCustomSlug(): ?string
|
||||
|
@ -102,7 +107,7 @@ final class ShortUrlMeta
|
|||
|
||||
public function hasMaxVisits(): bool
|
||||
{
|
||||
return $this->maxVisitsPropWasProvided;
|
||||
return $this->maxVisits !== null;
|
||||
}
|
||||
|
||||
public function findIfExists(): bool
|
||||
|
@ -119,4 +124,9 @@ final class ShortUrlMeta
|
|||
{
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
public function getShortCodeLength(): int
|
||||
{
|
||||
return $this->shortCodeLength;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,14 +5,11 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core\Options;
|
||||
|
||||
use Laminas\Stdlib\AbstractOptions;
|
||||
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class AppOptions extends AbstractOptions
|
||||
{
|
||||
use StringUtilsTrait;
|
||||
|
||||
private string $name = '';
|
||||
private string $version = '1.0';
|
||||
private ?string $disableTrackParam = null;
|
||||
|
|
|
@ -12,33 +12,63 @@ use Shlinkio\Shlink\Core\Entity\Visit;
|
|||
class VisitRepository extends EntityRepository implements VisitRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* This method will allow you to iterate the whole list of unlocated visits, but loading them into memory in
|
||||
* smaller blocks of a specific size.
|
||||
* This will have side effects if you update those rows while you iterate them.
|
||||
* If you plan to do so, pass the first argument as false in order to disable applying offsets while slicing the
|
||||
* dataset
|
||||
*
|
||||
* @return iterable|Visit[]
|
||||
*/
|
||||
public function findUnlocatedVisits(bool $applyOffset = true, int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
|
||||
public function findUnlocatedVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
|
||||
{
|
||||
$dql = <<<DQL
|
||||
SELECT v FROM Shlinkio\Shlink\Core\Entity\Visit AS v WHERE v.visitLocation IS NULL
|
||||
DQL;
|
||||
$query = $this->getEntityManager()->createQuery($dql)
|
||||
->setMaxResults($blockSize);
|
||||
$remainingVisitsToProcess = $this->count(['visitLocation' => null]);
|
||||
$offset = 0;
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->select('v')
|
||||
->from(Visit::class, 'v')
|
||||
->where($qb->expr()->isNull('v.visitLocation'));
|
||||
|
||||
while ($remainingVisitsToProcess > 0) {
|
||||
$iterator = $query->setFirstResult($applyOffset ? $offset : null)->iterate();
|
||||
foreach ($iterator as $key => [$value]) {
|
||||
yield $key => $value;
|
||||
return $this->findVisitsForQuery($qb, $blockSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable|Visit[]
|
||||
*/
|
||||
public function findVisitsWithEmptyLocation(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->select('v')
|
||||
->from(Visit::class, 'v')
|
||||
->join('v.visitLocation', 'vl')
|
||||
->where($qb->expr()->isNotNull('v.visitLocation'))
|
||||
->andWhere($qb->expr()->eq('vl.isEmpty', ':isEmpty'))
|
||||
->setParameter('isEmpty', true);
|
||||
|
||||
return $this->findVisitsForQuery($qb, $blockSize);
|
||||
}
|
||||
|
||||
public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->select('v')
|
||||
->from(Visit::class, 'v');
|
||||
|
||||
return $this->findVisitsForQuery($qb, $blockSize);
|
||||
}
|
||||
|
||||
private function findVisitsForQuery(QueryBuilder $qb, int $blockSize): iterable
|
||||
{
|
||||
$originalQueryBuilder = $qb->setMaxResults($blockSize)
|
||||
->orderBy('v.id', 'ASC');
|
||||
$lastId = '0';
|
||||
|
||||
do {
|
||||
$qb = (clone $originalQueryBuilder)->andWhere($qb->expr()->gt('v.id', $lastId));
|
||||
$iterator = $qb->getQuery()->iterate();
|
||||
$resultsFound = false;
|
||||
|
||||
/** @var Visit $visit */
|
||||
foreach ($iterator as $key => [$visit]) {
|
||||
$resultsFound = true;
|
||||
yield $key => $visit;
|
||||
}
|
||||
|
||||
$remainingVisitsToProcess -= $blockSize;
|
||||
$offset += $blockSize;
|
||||
}
|
||||
// As the query is ordered by ID, we can take the last one every time in order to exclude the whole list
|
||||
$lastId = isset($visit) ? $visit->getId() : $lastId;
|
||||
} while ($resultsFound);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -13,15 +13,19 @@ interface VisitRepositoryInterface extends ObjectRepository
|
|||
public const DEFAULT_BLOCK_SIZE = 10000;
|
||||
|
||||
/**
|
||||
* This method will allow you to iterate the whole list of unlocated visits, but loading them into memory in
|
||||
* smaller blocks of a specific size.
|
||||
* This will have side effects if you update those rows while you iterate them.
|
||||
* If you plan to do so, pass the first argument as false in order to disable applying offsets while slicing the
|
||||
* dataset
|
||||
*
|
||||
* @return iterable|Visit[]
|
||||
*/
|
||||
public function findUnlocatedVisits(bool $applyOffset = true, int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable;
|
||||
public function findUnlocatedVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable;
|
||||
|
||||
/**
|
||||
* @return iterable|Visit[]
|
||||
*/
|
||||
public function findVisitsWithEmptyLocation(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable;
|
||||
|
||||
/**
|
||||
* @return iterable|Visit[]
|
||||
*/
|
||||
public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable;
|
||||
|
||||
/**
|
||||
* @return Visit[]
|
||||
|
|
|
@ -7,14 +7,16 @@ namespace Shlinkio\Shlink\Core\Service;
|
|||
use Doctrine\ORM;
|
||||
use Laminas\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
|
||||
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
|
||||
|
||||
class ShortUrlService implements ShortUrlServiceInterface
|
||||
{
|
||||
|
@ -22,11 +24,16 @@ class ShortUrlService implements ShortUrlServiceInterface
|
|||
|
||||
private ORM\EntityManagerInterface $em;
|
||||
private ShortUrlResolverInterface $urlResolver;
|
||||
private UrlValidatorInterface $urlValidator;
|
||||
|
||||
public function __construct(ORM\EntityManagerInterface $em, ShortUrlResolverInterface $urlResolver)
|
||||
{
|
||||
public function __construct(
|
||||
ORM\EntityManagerInterface $em,
|
||||
ShortUrlResolverInterface $urlResolver,
|
||||
UrlValidatorInterface $urlValidator
|
||||
) {
|
||||
$this->em = $em;
|
||||
$this->urlResolver = $urlResolver;
|
||||
$this->urlValidator = $urlValidator;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -59,11 +66,16 @@ class ShortUrlService implements ShortUrlServiceInterface
|
|||
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlMeta $shortUrlMeta): ShortUrl
|
||||
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit): ShortUrl
|
||||
{
|
||||
if ($shortUrlEdit->hasLongUrl()) {
|
||||
$this->urlValidator->validateUrl($shortUrlEdit->longUrl());
|
||||
}
|
||||
|
||||
$shortUrl = $this->urlResolver->resolveShortUrl($identifier);
|
||||
$shortUrl->updateMeta($shortUrlMeta);
|
||||
$shortUrl->update($shortUrlEdit);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
|
|
|
@ -6,9 +6,10 @@ namespace Shlinkio\Shlink\Core\Service;
|
|||
|
||||
use Laminas\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
|
||||
interface ShortUrlServiceInterface
|
||||
|
@ -26,6 +27,7 @@ interface ShortUrlServiceInterface
|
|||
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlMeta $shortUrlMeta): ShortUrl;
|
||||
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit): ShortUrl;
|
||||
}
|
||||
|
|
|
@ -6,12 +6,11 @@ namespace Shlinkio\Shlink\Core\Service;
|
|||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
|
||||
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
|
||||
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
|
||||
|
@ -24,17 +23,17 @@ class UrlShortener implements UrlShortenerInterface
|
|||
use TagManagerTrait;
|
||||
|
||||
private EntityManagerInterface $em;
|
||||
private UrlShortenerOptions $options;
|
||||
private UrlValidatorInterface $urlValidator;
|
||||
private DomainResolverInterface $domainResolver;
|
||||
|
||||
public function __construct(
|
||||
UrlValidatorInterface $urlValidator,
|
||||
EntityManagerInterface $em,
|
||||
UrlShortenerOptions $options
|
||||
DomainResolverInterface $domainResolver
|
||||
) {
|
||||
$this->urlValidator = $urlValidator;
|
||||
$this->em = $em;
|
||||
$this->options = $options;
|
||||
$this->domainResolver = $domainResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -53,13 +52,9 @@ class UrlShortener implements UrlShortenerInterface
|
|||
return $existingShortUrl;
|
||||
}
|
||||
|
||||
// If the URL validation is enabled, check that the URL actually exists
|
||||
if ($this->options->isUrlValidationEnabled()) {
|
||||
$this->urlValidator->validateUrl($url);
|
||||
}
|
||||
|
||||
$this->urlValidator->validateUrl($url);
|
||||
$this->em->beginTransaction();
|
||||
$shortUrl = new ShortUrl($url, $meta, new PersistenceDomainResolver($this->em));
|
||||
$shortUrl = new ShortUrl($url, $meta, $this->domainResolver);
|
||||
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
|
||||
|
||||
try {
|
||||
|
|
|
@ -1,70 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
|
||||
class VisitService implements VisitServiceInterface
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
public function __construct(EntityManagerInterface $em)
|
||||
{
|
||||
$this->em = $em;
|
||||
}
|
||||
|
||||
public function locateUnlocatedVisits(callable $geolocateVisit, ?callable $notifyVisitWithLocation = null): void
|
||||
{
|
||||
/** @var VisitRepository $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
$results = $repo->findUnlocatedVisits(false);
|
||||
$count = 0;
|
||||
$persistBlock = 200;
|
||||
|
||||
foreach ($results as $visit) {
|
||||
$count++;
|
||||
|
||||
try {
|
||||
/** @var Location $location */
|
||||
$location = $geolocateVisit($visit);
|
||||
} catch (IpCannotBeLocatedException $e) {
|
||||
if (! $e->isNonLocatableAddress()) {
|
||||
// Skip if the visit's IP could not be located because of an error
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the IP address is non-locatable, locate it as empty to prevent next processes to pick it again
|
||||
$location = Location::emptyInstance();
|
||||
}
|
||||
|
||||
$location = new VisitLocation($location);
|
||||
$this->locateVisit($visit, $location, $notifyVisitWithLocation);
|
||||
|
||||
// Flush and clear after X iterations
|
||||
if ($count % $persistBlock === 0) {
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
}
|
||||
|
||||
private function locateVisit(Visit $visit, VisitLocation $location, ?callable $notifyVisitWithLocation): void
|
||||
{
|
||||
$visit->locate($location);
|
||||
$this->em->persist($visit);
|
||||
|
||||
if ($notifyVisitWithLocation !== null) {
|
||||
$notifyVisitWithLocation($location, $visit);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
interface VisitServiceInterface
|
||||
{
|
||||
public function locateUnlocatedVisits(callable $geolocateVisit, ?callable $notifyVisitWithLocation = null): void;
|
||||
}
|
|
@ -39,7 +39,7 @@ class VisitsTracker implements VisitsTrackerInterface
|
|||
$this->em->persist($visit);
|
||||
$this->em->flush();
|
||||
|
||||
$this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId()));
|
||||
$this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId(), $visitor->getRemoteAddress()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -9,16 +9,19 @@ use GuzzleHttp\ClientInterface;
|
|||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
|
||||
class UrlValidator implements UrlValidatorInterface, RequestMethodInterface
|
||||
{
|
||||
private const MAX_REDIRECTS = 15;
|
||||
|
||||
private ClientInterface $httpClient;
|
||||
private UrlShortenerOptions $options;
|
||||
|
||||
public function __construct(ClientInterface $httpClient)
|
||||
public function __construct(ClientInterface $httpClient, UrlShortenerOptions $options)
|
||||
{
|
||||
$this->httpClient = $httpClient;
|
||||
$this->options = $options;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -26,6 +29,11 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface
|
|||
*/
|
||||
public function validateUrl(string $url): void
|
||||
{
|
||||
// If the URL validation is not enabled, skip check
|
||||
if (! $this->options->isUrlValidationEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->httpClient->request(self::METHOD_GET, $url, [
|
||||
RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS],
|
||||
|
|
|
@ -5,10 +5,13 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core\Validation;
|
||||
|
||||
use DateTime;
|
||||
use Laminas\InputFilter\Input;
|
||||
use Laminas\InputFilter\InputFilter;
|
||||
use Laminas\Validator;
|
||||
use Shlinkio\Shlink\Common\Validation;
|
||||
|
||||
use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH;
|
||||
|
||||
class ShortUrlMetaInputFilter extends InputFilter
|
||||
{
|
||||
use Validation\InputFactoryTrait;
|
||||
|
@ -19,6 +22,8 @@ class ShortUrlMetaInputFilter extends InputFilter
|
|||
public const MAX_VISITS = 'maxVisits';
|
||||
public const FIND_IF_EXISTS = 'findIfExists';
|
||||
public const DOMAIN = 'domain';
|
||||
public const SHORT_CODE_LENGTH = 'shortCodeLength';
|
||||
public const LONG_URL = 'longUrl';
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
|
@ -28,6 +33,8 @@ class ShortUrlMetaInputFilter extends InputFilter
|
|||
|
||||
private function initialize(): void
|
||||
{
|
||||
$this->add($this->createInput(self::LONG_URL, false));
|
||||
|
||||
$validSince = $this->createInput(self::VALID_SINCE, false);
|
||||
$validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM]));
|
||||
$this->add($validSince);
|
||||
|
@ -36,14 +43,18 @@ class ShortUrlMetaInputFilter extends InputFilter
|
|||
$validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM]));
|
||||
$this->add($validUntil);
|
||||
|
||||
$customSlug = $this->createInput(self::CUSTOM_SLUG, false);
|
||||
// FIXME The only way to enforce the NotEmpty validator to be evaluated when the value is provided but it's
|
||||
// empty, is by using the deprecated setContinueIfEmpty
|
||||
$customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true);
|
||||
$customSlug->getFilterChain()->attach(new Validation\SluggerFilter());
|
||||
$customSlug->getValidatorChain()->attach(new Validator\NotEmpty([
|
||||
Validator\NotEmpty::STRING,
|
||||
Validator\NotEmpty::SPACE,
|
||||
]));
|
||||
$this->add($customSlug);
|
||||
|
||||
$maxVisits = $this->createInput(self::MAX_VISITS, false);
|
||||
$maxVisits->getValidatorChain()->attach(new Validator\Digits())
|
||||
->attach(new Validator\GreaterThan(['min' => 1, 'inclusive' => true]));
|
||||
$this->add($maxVisits);
|
||||
$this->add($this->createPositiveNumberInput(self::MAX_VISITS));
|
||||
$this->add($this->createPositiveNumberInput(self::SHORT_CODE_LENGTH, MIN_SHORT_CODES_LENGTH));
|
||||
|
||||
$this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false));
|
||||
|
||||
|
@ -51,4 +62,13 @@ class ShortUrlMetaInputFilter extends InputFilter
|
|||
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator());
|
||||
$this->add($domain);
|
||||
}
|
||||
|
||||
private function createPositiveNumberInput(string $name, int $min = 1): Input
|
||||
{
|
||||
$input = $this->createInput($name, false);
|
||||
$input->getValidatorChain()->attach(new Validator\Digits())
|
||||
->attach(new Validator\GreaterThan(['min' => $min, 'inclusive' => true]));
|
||||
|
||||
return $input;
|
||||
}
|
||||
}
|
||||
|
|
20
module/Core/src/Visit/VisitGeolocationHelperInterface.php
Normal file
20
module/Core/src/Visit/VisitGeolocationHelperInterface.php
Normal file
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
|
||||
interface VisitGeolocationHelperInterface
|
||||
{
|
||||
/**
|
||||
* @throws IpCannotBeLocatedException
|
||||
*/
|
||||
public function geolocateVisit(Visit $visit): Location;
|
||||
|
||||
public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void;
|
||||
}
|
94
module/Core/src/Visit/VisitLocator.php
Normal file
94
module/Core/src/Visit/VisitLocator.php
Normal file
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
|
||||
class VisitLocator implements VisitLocatorInterface
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private VisitRepositoryInterface $repo;
|
||||
|
||||
public function __construct(EntityManagerInterface $em)
|
||||
{
|
||||
$this->em = $em;
|
||||
|
||||
/** @var VisitRepositoryInterface $repo */
|
||||
$repo = $em->getRepository(Visit::class);
|
||||
$this->repo = $repo;
|
||||
}
|
||||
|
||||
public function locateUnlocatedVisits(VisitGeolocationHelperInterface $helper): void
|
||||
{
|
||||
$this->locateVisits($this->repo->findUnlocatedVisits(), $helper);
|
||||
}
|
||||
|
||||
public function locateVisitsWithEmptyLocation(VisitGeolocationHelperInterface $helper): void
|
||||
{
|
||||
$this->locateVisits($this->repo->findVisitsWithEmptyLocation(), $helper);
|
||||
}
|
||||
|
||||
public function locateAllVisits(VisitGeolocationHelperInterface $helper): void
|
||||
{
|
||||
$this->locateVisits($this->repo->findAllVisits(), $helper);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable|Visit[] $results
|
||||
*/
|
||||
private function locateVisits(iterable $results, VisitGeolocationHelperInterface $helper): void
|
||||
{
|
||||
$count = 0;
|
||||
$persistBlock = 200;
|
||||
|
||||
foreach ($results as $visit) {
|
||||
$count++;
|
||||
|
||||
try {
|
||||
$location = $helper->geolocateVisit($visit);
|
||||
} catch (IpCannotBeLocatedException $e) {
|
||||
if (! $e->isNonLocatableAddress()) {
|
||||
// Skip if the visit's IP could not be located because of an error
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the IP address is non-locatable, locate it as empty to prevent next processes to pick it again
|
||||
$location = Location::emptyInstance();
|
||||
}
|
||||
|
||||
$location = new VisitLocation($location);
|
||||
$this->locateVisit($visit, $location, $helper);
|
||||
|
||||
// Flush and clear after X iterations
|
||||
if ($count % $persistBlock === 0) {
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
}
|
||||
|
||||
private function locateVisit(Visit $visit, VisitLocation $location, VisitGeolocationHelperInterface $helper): void
|
||||
{
|
||||
$prevLocation = $visit->getVisitLocation();
|
||||
|
||||
$visit->locate($location);
|
||||
$this->em->persist($visit);
|
||||
|
||||
// In order to avoid leaving orphan locations, remove the previous one
|
||||
if ($prevLocation !== null) {
|
||||
$this->em->remove($prevLocation);
|
||||
}
|
||||
|
||||
$helper->onVisitLocated($location, $visit);
|
||||
}
|
||||
}
|
14
module/Core/src/Visit/VisitLocatorInterface.php
Normal file
14
module/Core/src/Visit/VisitLocatorInterface.php
Normal file
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit;
|
||||
|
||||
interface VisitLocatorInterface
|
||||
{
|
||||
public function locateUnlocatedVisits(VisitGeolocationHelperInterface $helper): void;
|
||||
|
||||
public function locateVisitsWithEmptyLocation(VisitGeolocationHelperInterface $helper): void;
|
||||
|
||||
public function locateAllVisits(VisitGeolocationHelperInterface $helper): void;
|
||||
}
|
|
@ -40,15 +40,23 @@ class VisitRepositoryTest extends DatabaseTestCase
|
|||
* @test
|
||||
* @dataProvider provideBlockSize
|
||||
*/
|
||||
public function findUnlocatedVisitsReturnsProperVisits(int $blockSize): void
|
||||
public function findVisitsReturnsProperVisits(int $blockSize): void
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
$this->getEntityManager()->persist($shortUrl);
|
||||
$countIterable = function (iterable $results): int {
|
||||
$resultsCount = 0;
|
||||
foreach ($results as $value) {
|
||||
$resultsCount++;
|
||||
}
|
||||
|
||||
return $resultsCount;
|
||||
};
|
||||
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$visit = new Visit($shortUrl, Visitor::emptyInstance());
|
||||
|
||||
if ($i % 2 === 0) {
|
||||
if ($i >= 2) {
|
||||
$location = new VisitLocation(Location::emptyInstance());
|
||||
$this->getEntityManager()->persist($location);
|
||||
$visit->locate($location);
|
||||
|
@ -58,18 +66,20 @@ class VisitRepositoryTest extends DatabaseTestCase
|
|||
}
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$resultsCount = 0;
|
||||
$results = $this->repo->findUnlocatedVisits(true, $blockSize);
|
||||
foreach ($results as $value) {
|
||||
$resultsCount++;
|
||||
}
|
||||
$withEmptyLocation = $this->repo->findVisitsWithEmptyLocation($blockSize);
|
||||
$unlocated = $this->repo->findUnlocatedVisits($blockSize);
|
||||
$all = $this->repo->findAllVisits($blockSize);
|
||||
|
||||
$this->assertEquals(3, $resultsCount);
|
||||
// Important! assertCount will not work here, as this iterable object loads data dynamically and the count
|
||||
// is 0 if not iterated
|
||||
$this->assertEquals(2, $countIterable($unlocated));
|
||||
$this->assertEquals(4, $countIterable($withEmptyLocation));
|
||||
$this->assertEquals(6, $countIterable($all));
|
||||
}
|
||||
|
||||
public function provideBlockSize(): iterable
|
||||
{
|
||||
return map(range(1, 5), fn (int $value) => [$value]);
|
||||
return map(range(1, 10), fn (int $value) => [$value]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
|
@ -41,6 +41,8 @@ class SimplifiedConfigParserTest extends TestCase
|
|||
'validate_url' => true,
|
||||
'delete_short_url_threshold' => 50,
|
||||
'invalid_short_url_redirect_to' => 'foobar.com',
|
||||
'regular_404_redirect_to' => 'bar.com',
|
||||
'base_url_redirect_to' => 'foo.com',
|
||||
'redis_servers' => [
|
||||
'tcp://1.1.1.1:1111',
|
||||
'tcp://1.2.2.2:2222',
|
||||
|
@ -57,6 +59,7 @@ class SimplifiedConfigParserTest extends TestCase
|
|||
'http://my-api.com/api/v2.3/notify',
|
||||
'https://third-party.io/foo',
|
||||
],
|
||||
'default_short_codes_length' => 8,
|
||||
];
|
||||
$expected = [
|
||||
'app_options' => [
|
||||
|
@ -84,6 +87,7 @@ class SimplifiedConfigParserTest extends TestCase
|
|||
'http://my-api.com/api/v2.3/notify',
|
||||
'https://third-party.io/foo',
|
||||
],
|
||||
'default_short_codes_length' => 8,
|
||||
],
|
||||
|
||||
'delete_short_urls' => [
|
||||
|
@ -112,6 +116,8 @@ class SimplifiedConfigParserTest extends TestCase
|
|||
|
||||
'not_found_redirects' => [
|
||||
'invalid_short_url' => 'foobar.com',
|
||||
'regular_404' => 'bar.com',
|
||||
'base_url' => 'foo.com',
|
||||
],
|
||||
|
||||
'mezzio-swoole' => [
|
||||
|
|
|
@ -8,6 +8,13 @@ use PHPUnit\Framework\TestCase;
|
|||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||
|
||||
use function Functional\map;
|
||||
use function range;
|
||||
use function strlen;
|
||||
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
||||
|
||||
class ShortUrlTest extends TestCase
|
||||
{
|
||||
|
@ -48,4 +55,23 @@ class ShortUrlTest extends TestCase
|
|||
|
||||
$this->assertNotEquals($firstShortCode, $secondShortCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideLengths
|
||||
*/
|
||||
public function shortCodesHaveExpectedLength(?int $length, int $expectedLength): void
|
||||
{
|
||||
$shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData(
|
||||
[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $length],
|
||||
));
|
||||
|
||||
$this->assertEquals($expectedLength, strlen($shortUrl->getShortCode()));
|
||||
}
|
||||
|
||||
public function provideLengths(): iterable
|
||||
{
|
||||
yield [null, DEFAULT_SHORT_CODES_LENGTH];
|
||||
yield from map(range(4, 10), fn (int $value) => [$value, $value]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,7 +20,6 @@ use Shlinkio\Shlink\Core\EventDispatcher\LocateShortUrlVisit;
|
|||
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\VisitLocated;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
|
@ -130,13 +129,16 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
yield 'localhost' => [new Visit($shortUrl, new Visitor('', '', IpAddress::LOCALHOST))];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function locatableVisitsResolveToLocation(): void
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideIpAddresses
|
||||
*/
|
||||
public function locatableVisitsResolveToLocation(string $anonymizedIpAddress, ?string $originalIpAddress): void
|
||||
{
|
||||
$ipAddr = '1.2.3.0';
|
||||
$ipAddr = $originalIpAddress ?? $anonymizedIpAddress;
|
||||
$visit = new Visit(new ShortUrl(''), new Visitor('', '', $ipAddr));
|
||||
$location = new Location('', '', '', '', 0.0, 0.0, '');
|
||||
$event = new ShortUrlVisited('123');
|
||||
$event = new ShortUrlVisited('123', $originalIpAddress);
|
||||
|
||||
$findVisit = $this->em->find(Visit::class, '123')->willReturn($visit);
|
||||
$flush = $this->em->flush()->will(function (): void {
|
||||
|
@ -155,6 +157,12 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
$dispatch->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideIpAddresses(): iterable
|
||||
{
|
||||
yield 'no original IP address' => ['1.2.3.0', null];
|
||||
yield 'original IP address' => ['1.2.3.0', '1.2.3.4'];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function errorWhenUpdatingGeoLiteWithExistingCopyLogsWarning(): void
|
||||
{
|
||||
|
@ -209,7 +217,7 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
|
||||
($this->locateVisit)($event);
|
||||
|
||||
$this->assertEquals($visit->getVisitLocation(), new UnknownVisitLocation());
|
||||
$this->assertNull($visit->getVisitLocation());
|
||||
$findVisit->shouldHaveBeenCalledOnce();
|
||||
$flush->shouldNotHaveBeenCalled();
|
||||
$resolveIp->shouldNotHaveBeenCalled();
|
||||
|
|
|
@ -44,6 +44,18 @@ class ShortUrlMetaTest extends TestCase
|
|||
ShortUrlMetaInputFilter::VALID_UNTIL => 500,
|
||||
ShortUrlMetaInputFilter::DOMAIN => 4,
|
||||
]];
|
||||
yield [[
|
||||
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => 3,
|
||||
]];
|
||||
yield [[
|
||||
ShortUrlMetaInputFilter::CUSTOM_SLUG => '/',
|
||||
]];
|
||||
yield [[
|
||||
ShortUrlMetaInputFilter::CUSTOM_SLUG => '',
|
||||
]];
|
||||
yield [[
|
||||
ShortUrlMetaInputFilter::CUSTOM_SLUG => ' ',
|
||||
]];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
|
@ -5,16 +5,15 @@ declare(strict_types=1);
|
|||
namespace ShlinkioTest\Shlink\Core\Model;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
|
||||
use function random_int;
|
||||
use function str_repeat;
|
||||
use function strlen;
|
||||
use function substr;
|
||||
|
||||
class VisitorTest extends TestCase
|
||||
{
|
||||
use StringUtilsTrait;
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideParams
|
||||
|
@ -60,4 +59,15 @@ class VisitorTest extends TestCase
|
|||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function generateRandomString(int $length): string
|
||||
{
|
||||
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
$charactersLength = strlen($characters);
|
||||
$randomString = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$randomString .= $characters[random_int(0, $charactersLength - 1)];
|
||||
}
|
||||
return $randomString;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,12 +12,13 @@ use Prophecy\Argument;
|
|||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlService;
|
||||
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
|
||||
|
||||
use function count;
|
||||
|
||||
|
@ -26,6 +27,7 @@ class ShortUrlServiceTest extends TestCase
|
|||
private ShortUrlService $service;
|
||||
private ObjectProphecy $em;
|
||||
private ObjectProphecy $urlResolver;
|
||||
private ObjectProphecy $urlValidator;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
|
@ -34,8 +36,13 @@ class ShortUrlServiceTest extends TestCase
|
|||
$this->em->flush()->willReturn(null);
|
||||
|
||||
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
|
||||
$this->urlValidator = $this->prophesize(UrlValidatorInterface::class);
|
||||
|
||||
$this->service = new ShortUrlService($this->em->reveal(), $this->urlResolver->reveal());
|
||||
$this->service = new ShortUrlService(
|
||||
$this->em->reveal(),
|
||||
$this->urlResolver->reveal(),
|
||||
$this->urlValidator->reveal(),
|
||||
);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
@ -74,27 +81,47 @@ class ShortUrlServiceTest extends TestCase
|
|||
$this->service->setTagsByShortCode(new ShortUrlIdentifier($shortCode), ['foo', 'bar']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function updateMetadataByShortCodeUpdatesProvidedData(): void
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideShortUrlEdits
|
||||
*/
|
||||
public function updateMetadataByShortCodeUpdatesProvidedData(
|
||||
int $expectedValidateCalls,
|
||||
ShortUrlEdit $shortUrlEdit
|
||||
): void {
|
||||
$originalLongUrl = 'originalLongUrl';
|
||||
$shortUrl = new ShortUrl($originalLongUrl);
|
||||
|
||||
$findShortUrl = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier('abc123'))->willReturn($shortUrl);
|
||||
$flush = $this->em->flush()->willReturn(null);
|
||||
|
||||
$result = $this->service->updateMetadataByShortCode(new ShortUrlIdentifier('abc123'), ShortUrlMeta::fromRawData(
|
||||
$result = $this->service->updateMetadataByShortCode(new ShortUrlIdentifier('abc123'), $shortUrlEdit);
|
||||
|
||||
$this->assertSame($shortUrl, $result);
|
||||
$this->assertEquals($shortUrlEdit->validSince(), $shortUrl->getValidSince());
|
||||
$this->assertEquals($shortUrlEdit->validUntil(), $shortUrl->getValidUntil());
|
||||
$this->assertEquals($shortUrlEdit->maxVisits(), $shortUrl->getMaxVisits());
|
||||
$this->assertEquals($shortUrlEdit->longUrl() ?? $originalLongUrl, $shortUrl->getLongUrl());
|
||||
$findShortUrl->shouldHaveBeenCalled();
|
||||
$flush->shouldHaveBeenCalled();
|
||||
$this->urlValidator->validateUrl($shortUrlEdit->longUrl())->shouldHaveBeenCalledTimes($expectedValidateCalls);
|
||||
}
|
||||
|
||||
public function provideShortUrlEdits(): iterable
|
||||
{
|
||||
yield 'no long URL' => [0, ShortUrlEdit::fromRawData(
|
||||
[
|
||||
'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(),
|
||||
'validUntil' => Chronos::parse('2017-01-05 00:00:00')->toAtomString(),
|
||||
'maxVisits' => 5,
|
||||
],
|
||||
));
|
||||
|
||||
$this->assertSame($shortUrl, $result);
|
||||
$this->assertEquals(Chronos::parse('2017-01-01 00:00:00'), $shortUrl->getValidSince());
|
||||
$this->assertEquals(Chronos::parse('2017-01-05 00:00:00'), $shortUrl->getValidUntil());
|
||||
$this->assertEquals(5, $shortUrl->getMaxVisits());
|
||||
$findShortUrl->shouldHaveBeenCalled();
|
||||
$flush->shouldHaveBeenCalled();
|
||||
)];
|
||||
yield 'long URL' => [1, ShortUrlEdit::fromRawData(
|
||||
[
|
||||
'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(),
|
||||
'maxVisits' => 10,
|
||||
'longUrl' => 'modifiedLongUrl',
|
||||
],
|
||||
)];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,11 +13,11 @@ use Laminas\Diactoros\Uri;
|
|||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver\SimpleDomainResolver;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortener;
|
||||
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
|
||||
|
@ -33,6 +33,10 @@ class UrlShortenerTest extends TestCase
|
|||
public function setUp(): void
|
||||
{
|
||||
$this->urlValidator = $this->prophesize(UrlValidatorInterface::class);
|
||||
$this->urlValidator->validateUrl('http://foobar.com/12345/hello?foo=bar')->will(
|
||||
function (): void {
|
||||
},
|
||||
);
|
||||
|
||||
$this->em = $this->prophesize(EntityManagerInterface::class);
|
||||
$conn = $this->prophesize(Connection::class);
|
||||
|
@ -50,15 +54,10 @@ class UrlShortenerTest extends TestCase
|
|||
$repo->shortCodeIsInUse(Argument::cetera())->willReturn(false);
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
|
||||
|
||||
$this->setUrlShortener(false);
|
||||
}
|
||||
|
||||
private function setUrlShortener(bool $urlValidationEnabled): void
|
||||
{
|
||||
$this->urlShortener = new UrlShortener(
|
||||
$this->urlValidator->reveal(),
|
||||
$this->em->reveal(),
|
||||
new UrlShortenerOptions(['validate_url' => $urlValidationEnabled]),
|
||||
new SimpleDomainResolver(),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -119,24 +118,6 @@ class UrlShortenerTest extends TestCase
|
|||
);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function validatorIsCalledWhenUrlValidationIsEnabled(): void
|
||||
{
|
||||
$this->setUrlShortener(true);
|
||||
$validateUrl = $this->urlValidator->validateUrl('http://foobar.com/12345/hello?foo=bar')->will(
|
||||
function (): void {
|
||||
},
|
||||
);
|
||||
|
||||
$this->urlShortener->urlToShortCode(
|
||||
new Uri('http://foobar.com/12345/hello?foo=bar'),
|
||||
[],
|
||||
ShortUrlMeta::createEmpty(),
|
||||
);
|
||||
|
||||
$validateUrl->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function exceptionIsThrownWhenNonUniqueSlugIsProvided(): void
|
||||
{
|
||||
|
@ -175,6 +156,7 @@ class UrlShortenerTest extends TestCase
|
|||
$findExisting->shouldHaveBeenCalledOnce();
|
||||
$getRepo->shouldHaveBeenCalledOnce();
|
||||
$this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
$this->urlValidator->validateUrl(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,112 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Service;
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Exception;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
use Shlinkio\Shlink\Core\Service\VisitService;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
|
||||
use function array_shift;
|
||||
use function count;
|
||||
use function floor;
|
||||
use function func_get_args;
|
||||
use function Functional\map;
|
||||
use function range;
|
||||
use function sprintf;
|
||||
|
||||
class VisitServiceTest extends TestCase
|
||||
{
|
||||
private VisitService $visitService;
|
||||
private ObjectProphecy $em;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->em = $this->prophesize(EntityManager::class);
|
||||
$this->visitService = new VisitService($this->em->reveal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function locateVisitsIteratesAndLocatesUnlocatedVisits(): void
|
||||
{
|
||||
$unlocatedVisits = map(
|
||||
range(1, 200),
|
||||
fn (int $i) => new Visit(new ShortUrl(sprintf('short_code_%s', $i)), Visitor::emptyInstance()),
|
||||
);
|
||||
|
||||
$repo = $this->prophesize(VisitRepository::class);
|
||||
$findUnlocatedVisits = $repo->findUnlocatedVisits(false)->willReturn($unlocatedVisits);
|
||||
$getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal());
|
||||
|
||||
$persist = $this->em->persist(Argument::type(Visit::class))->will(function (): void {
|
||||
});
|
||||
$flush = $this->em->flush()->will(function (): void {
|
||||
});
|
||||
$clear = $this->em->clear()->will(function (): void {
|
||||
});
|
||||
|
||||
$this->visitService->locateUnlocatedVisits(fn () => Location::emptyInstance(), function (): void {
|
||||
$args = func_get_args();
|
||||
|
||||
$this->assertInstanceOf(VisitLocation::class, array_shift($args));
|
||||
$this->assertInstanceOf(Visit::class, array_shift($args));
|
||||
});
|
||||
|
||||
$findUnlocatedVisits->shouldHaveBeenCalledOnce();
|
||||
$getRepo->shouldHaveBeenCalledOnce();
|
||||
$persist->shouldHaveBeenCalledTimes(count($unlocatedVisits));
|
||||
$flush->shouldHaveBeenCalledTimes(floor(count($unlocatedVisits) / 200) + 1);
|
||||
$clear->shouldHaveBeenCalledTimes(floor(count($unlocatedVisits) / 200) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideIsNonLocatableAddress
|
||||
*/
|
||||
public function visitsWhichCannotBeLocatedAreIgnoredOrLocatedAsEmpty(bool $isNonLocatableAddress): void
|
||||
{
|
||||
$unlocatedVisits = [
|
||||
new Visit(new ShortUrl('foo'), Visitor::emptyInstance()),
|
||||
];
|
||||
|
||||
$repo = $this->prophesize(VisitRepository::class);
|
||||
$findUnlocatedVisits = $repo->findUnlocatedVisits(false)->willReturn($unlocatedVisits);
|
||||
$getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal());
|
||||
|
||||
$persist = $this->em->persist(Argument::type(Visit::class))->will(function (): void {
|
||||
});
|
||||
$flush = $this->em->flush()->will(function (): void {
|
||||
});
|
||||
$clear = $this->em->clear()->will(function (): void {
|
||||
});
|
||||
|
||||
$this->visitService->locateUnlocatedVisits(function () use ($isNonLocatableAddress): void {
|
||||
throw $isNonLocatableAddress
|
||||
? new IpCannotBeLocatedException('Cannot be located')
|
||||
: IpCannotBeLocatedException::forError(new Exception(''));
|
||||
});
|
||||
|
||||
$findUnlocatedVisits->shouldHaveBeenCalledOnce();
|
||||
$getRepo->shouldHaveBeenCalledOnce();
|
||||
$persist->shouldHaveBeenCalledTimes($isNonLocatableAddress ? 1 : 0);
|
||||
$flush->shouldHaveBeenCalledOnce();
|
||||
$clear->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideIsNonLocatableAddress(): iterable
|
||||
{
|
||||
yield 'The address is locatable' => [false];
|
||||
yield 'The address is non-locatable' => [true];
|
||||
}
|
||||
}
|
|
@ -13,17 +13,20 @@ use PHPUnit\Framework\TestCase;
|
|||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\Util\UrlValidator;
|
||||
|
||||
class UrlValidatorTest extends TestCase
|
||||
{
|
||||
private UrlValidator $urlValidator;
|
||||
private ObjectProphecy $httpClient;
|
||||
private UrlShortenerOptions $options;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->httpClient = $this->prophesize(ClientInterface::class);
|
||||
$this->urlValidator = new UrlValidator($this->httpClient->reveal());
|
||||
$this->options = new UrlShortenerOptions(['validate_url' => true]);
|
||||
$this->urlValidator = new UrlValidator($this->httpClient->reveal(), $this->options);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
@ -52,4 +55,15 @@ class UrlValidatorTest extends TestCase
|
|||
|
||||
$request->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function noCheckIsPerformedWhenUrlValidationIsDisabled(): void
|
||||
{
|
||||
$request = $this->httpClient->request(Argument::cetera())->willReturn(new Response());
|
||||
$this->options->validateUrl = false;
|
||||
|
||||
$this->urlValidator->validateUrl('');
|
||||
|
||||
$request->shouldNotHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
|
169
module/Core/test/Visit/VisitLocatorTest.php
Normal file
169
module/Core/test/Visit/VisitLocatorTest.php
Normal file
|
@ -0,0 +1,169 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Visit;
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Exception;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\MethodProphecy;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitGeolocationHelperInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitLocator;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
|
||||
use function array_shift;
|
||||
use function count;
|
||||
use function floor;
|
||||
use function func_get_args;
|
||||
use function Functional\map;
|
||||
use function range;
|
||||
use function sprintf;
|
||||
|
||||
class VisitLocatorTest extends TestCase
|
||||
{
|
||||
private VisitLocator $visitService;
|
||||
private ObjectProphecy $em;
|
||||
private ObjectProphecy $repo;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->em = $this->prophesize(EntityManager::class);
|
||||
$this->repo = $this->prophesize(VisitRepositoryInterface::class);
|
||||
$this->em->getRepository(Visit::class)->willReturn($this->repo->reveal());
|
||||
|
||||
$this->visitService = new VisitLocator($this->em->reveal());
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideMethodNames
|
||||
*/
|
||||
public function locateVisitsIteratesAndLocatesExpectedVisits(
|
||||
string $serviceMethodName,
|
||||
string $expectedRepoMethodName
|
||||
): void {
|
||||
$unlocatedVisits = map(
|
||||
range(1, 200),
|
||||
fn (int $i) => new Visit(new ShortUrl(sprintf('short_code_%s', $i)), Visitor::emptyInstance()),
|
||||
);
|
||||
|
||||
$findVisits = $this->mockRepoMethod($expectedRepoMethodName)->willReturn($unlocatedVisits);
|
||||
|
||||
$persist = $this->em->persist(Argument::type(Visit::class))->will(function (): void {
|
||||
});
|
||||
$flush = $this->em->flush()->will(function (): void {
|
||||
});
|
||||
$clear = $this->em->clear()->will(function (): void {
|
||||
});
|
||||
|
||||
$this->visitService->{$serviceMethodName}(new class implements VisitGeolocationHelperInterface {
|
||||
public function geolocateVisit(Visit $visit): Location
|
||||
{
|
||||
return Location::emptyInstance();
|
||||
}
|
||||
|
||||
public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void
|
||||
{
|
||||
$args = func_get_args();
|
||||
|
||||
Assert::assertInstanceOf(VisitLocation::class, array_shift($args));
|
||||
Assert::assertInstanceOf(Visit::class, array_shift($args));
|
||||
}
|
||||
});
|
||||
|
||||
$findVisits->shouldHaveBeenCalledOnce();
|
||||
$persist->shouldHaveBeenCalledTimes(count($unlocatedVisits));
|
||||
$flush->shouldHaveBeenCalledTimes(floor(count($unlocatedVisits) / 200) + 1);
|
||||
$clear->shouldHaveBeenCalledTimes(floor(count($unlocatedVisits) / 200) + 1);
|
||||
}
|
||||
|
||||
public function provideMethodNames(): iterable
|
||||
{
|
||||
yield 'locateUnlocatedVisits' => ['locateUnlocatedVisits', 'findUnlocatedVisits'];
|
||||
yield 'locateVisitsWithEmptyLocation' => ['locateVisitsWithEmptyLocation', 'findVisitsWithEmptyLocation'];
|
||||
yield 'locateAllVisits' => ['locateAllVisits', 'findAllVisits'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideIsNonLocatableAddress
|
||||
*/
|
||||
public function visitsWhichCannotBeLocatedAreIgnoredOrLocatedAsEmpty(
|
||||
string $serviceMethodName,
|
||||
string $expectedRepoMethodName,
|
||||
bool $isNonLocatableAddress
|
||||
): void {
|
||||
$unlocatedVisits = [
|
||||
new Visit(new ShortUrl('foo'), Visitor::emptyInstance()),
|
||||
];
|
||||
|
||||
$findVisits = $this->mockRepoMethod($expectedRepoMethodName)->willReturn($unlocatedVisits);
|
||||
|
||||
$persist = $this->em->persist(Argument::type(Visit::class))->will(function (): void {
|
||||
});
|
||||
$flush = $this->em->flush()->will(function (): void {
|
||||
});
|
||||
$clear = $this->em->clear()->will(function (): void {
|
||||
});
|
||||
|
||||
$this->visitService->{$serviceMethodName}(
|
||||
new class ($isNonLocatableAddress) implements VisitGeolocationHelperInterface {
|
||||
private bool $isNonLocatableAddress;
|
||||
|
||||
public function __construct(bool $isNonLocatableAddress)
|
||||
{
|
||||
$this->isNonLocatableAddress = $isNonLocatableAddress;
|
||||
}
|
||||
|
||||
public function geolocateVisit(Visit $visit): Location
|
||||
{
|
||||
throw $this->isNonLocatableAddress
|
||||
? new IpCannotBeLocatedException('Cannot be located')
|
||||
: IpCannotBeLocatedException::forError(new Exception(''));
|
||||
}
|
||||
|
||||
public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void
|
||||
{
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
$findVisits->shouldHaveBeenCalledOnce();
|
||||
$persist->shouldHaveBeenCalledTimes($isNonLocatableAddress ? 1 : 0);
|
||||
$flush->shouldHaveBeenCalledOnce();
|
||||
$clear->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideIsNonLocatableAddress(): iterable
|
||||
{
|
||||
yield 'locateUnlocatedVisits - locatable address' => ['locateUnlocatedVisits', 'findUnlocatedVisits', false];
|
||||
yield 'locateUnlocatedVisits - non-locatable address' => ['locateUnlocatedVisits', 'findUnlocatedVisits', true];
|
||||
yield 'locateVisitsWithEmptyLocation - locatable address' => [
|
||||
'locateVisitsWithEmptyLocation',
|
||||
'findVisitsWithEmptyLocation',
|
||||
false,
|
||||
];
|
||||
yield 'locateVisitsWithEmptyLocation - non-locatable address' => [
|
||||
'locateVisitsWithEmptyLocation',
|
||||
'findVisitsWithEmptyLocation',
|
||||
true,
|
||||
];
|
||||
yield 'locateAllVisits - locatable address' => ['locateAllVisits', 'findAllVisits', false];
|
||||
yield 'locateAllVisits - non-locatable address' => ['locateAllVisits', 'findAllVisits', true];
|
||||
}
|
||||
|
||||
private function mockRepoMethod(string $methodName): MethodProphecy
|
||||
{
|
||||
return (new MethodProphecy($this->repo, $methodName, new Argument\ArgumentsWildcard([])));
|
||||
}
|
||||
}
|
|
@ -38,6 +38,7 @@ return [
|
|||
Middleware\CrossDomainMiddleware::class => InvokableFactory::class,
|
||||
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class => InvokableFactory::class,
|
||||
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ConfigAbstractFactory::class,
|
||||
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
|
@ -75,6 +76,9 @@ return [
|
|||
Action\Tag\UpdateTagAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
|
||||
|
||||
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'],
|
||||
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [
|
||||
'config.url_shortener.default_short_codes_length',
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -13,7 +13,11 @@ return [
|
|||
Action\HealthAction::getRouteDef(),
|
||||
|
||||
// Short codes
|
||||
Action\ShortUrl\CreateShortUrlAction::getRouteDef([$contentNegotiationMiddleware, $dropDomainMiddleware]),
|
||||
Action\ShortUrl\CreateShortUrlAction::getRouteDef([
|
||||
$contentNegotiationMiddleware,
|
||||
$dropDomainMiddleware,
|
||||
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class,
|
||||
]),
|
||||
Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([$contentNegotiationMiddleware]),
|
||||
Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]),
|
||||
Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]),
|
||||
|
|
|
@ -8,8 +8,8 @@ use Laminas\Diactoros\Response\EmptyResponse;
|
|||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
|
||||
|
@ -28,10 +28,10 @@ class EditShortUrlAction extends AbstractRestAction
|
|||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$postData = (array) $request->getParsedBody();
|
||||
$shortUrlEdit = ShortUrlEdit::fromRawData((array) $request->getParsedBody());
|
||||
$identifier = ShortUrlIdentifier::fromApiRequest($request);
|
||||
|
||||
$this->shortUrlService->updateMetadataByShortCode($identifier, ShortUrlMeta::fromRawData($postData));
|
||||
$this->shortUrlService->updateMetadataByShortCode($identifier, $shortUrlEdit);
|
||||
return new EmptyResponse();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ use Closure;
|
|||
|
||||
use function Functional\first;
|
||||
use function Functional\map;
|
||||
use function Shlinkio\Shlink\Common\loadConfigFromGlob;
|
||||
use function Shlinkio\Shlink\Config\loadConfigFromGlob;
|
||||
use function sprintf;
|
||||
|
||||
class ConfigProvider
|
||||
|
|
|
@ -5,20 +5,18 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Rest\Entity;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
|
||||
|
||||
class ApiKey extends AbstractEntity
|
||||
{
|
||||
use StringUtilsTrait;
|
||||
|
||||
private string $key;
|
||||
private ?Chronos $expirationDate;
|
||||
private ?Chronos $expirationDate = null;
|
||||
private bool $enabled;
|
||||
|
||||
public function __construct(?Chronos $expirationDate = null)
|
||||
{
|
||||
$this->key = $this->generateV4Uuid();
|
||||
$this->key = Uuid::uuid4()->toString();
|
||||
$this->expirationDate = $expirationDate;
|
||||
$this->enabled = true;
|
||||
}
|
||||
|
@ -30,11 +28,7 @@ class ApiKey extends AbstractEntity
|
|||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
if ($this->expirationDate === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->expirationDate->lt(Chronos::now());
|
||||
return $this->expirationDate !== null && $this->expirationDate->lt(Chronos::now());
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest\Middleware\ShortUrl;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||
|
||||
class DefaultShortCodesLengthMiddleware implements MiddlewareInterface
|
||||
{
|
||||
private int $defaultShortCodesLength;
|
||||
|
||||
public function __construct(int $defaultShortCodesLength)
|
||||
{
|
||||
$this->defaultShortCodesLength = $defaultShortCodesLength;
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$body = $request->getParsedBody();
|
||||
if (! isset($body[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH])) {
|
||||
$body[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH] = $this->defaultShortCodesLength;
|
||||
}
|
||||
|
||||
return $handler->handle($request->withParsedBody($body));
|
||||
}
|
||||
}
|
|
@ -71,6 +71,32 @@ class EditShortUrlActionTest extends ApiTestCase
|
|||
return $matchingShortUrl['meta'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideLongUrls
|
||||
*/
|
||||
public function longUrlCanBeEditedIfItIsValid(string $longUrl, int $expectedStatus, ?string $expectedError): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$url = sprintf('/short-urls/%s', $shortCode);
|
||||
|
||||
$resp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => [
|
||||
'longUrl' => $longUrl,
|
||||
]]);
|
||||
|
||||
$this->assertEquals($expectedStatus, $resp->getStatusCode());
|
||||
if ($expectedError !== null) {
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
$this->assertEquals($expectedError, $payload['type']);
|
||||
}
|
||||
}
|
||||
|
||||
public function provideLongUrls(): iterable
|
||||
{
|
||||
yield 'valid URL' => ['https://shlink.io', self::STATUS_NO_CONTENT, null];
|
||||
yield 'invalid URL' => ['htt:foo', self::STATUS_BAD_REQUEST, 'INVALID_URL'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideInvalidUrls
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioApiTest\Shlink\Rest\Middleware\ShortUrl;
|
||||
|
||||
use Laminas\Diactoros\Response;
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||
use Shlinkio\Shlink\Rest\Middleware\ShortUrl\DefaultShortCodesLengthMiddleware;
|
||||
|
||||
class DefaultShortCodesLengthMiddlewareTest extends TestCase
|
||||
{
|
||||
private DefaultShortCodesLengthMiddleware $middleware;
|
||||
private ObjectProphecy $handler;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->handler = $this->prophesize(RequestHandlerInterface::class);
|
||||
$this->middleware = new DefaultShortCodesLengthMiddleware(8);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideBodies
|
||||
*/
|
||||
public function defaultValueIsInjectedInBodyWhenNotProvided(array $body, int $expectedLength): void
|
||||
{
|
||||
$request = ServerRequestFactory::fromGlobals()->withParsedBody($body);
|
||||
$handle = $this->handler->handle(Argument::that(function (ServerRequestInterface $req) use ($expectedLength) {
|
||||
$parsedBody = $req->getParsedBody();
|
||||
Assert::assertArrayHasKey(ShortUrlMetaInputFilter::SHORT_CODE_LENGTH, $parsedBody);
|
||||
Assert::assertEquals($expectedLength, $parsedBody[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH]);
|
||||
|
||||
return $req;
|
||||
}))->willReturn(new Response());
|
||||
|
||||
$this->middleware->process($request, $this->handler->reveal());
|
||||
|
||||
$handle->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideBodies(): iterable
|
||||
{
|
||||
yield 'value provided' => [[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => 6], 6];
|
||||
yield 'value not provided' => [[], 8];
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue