mirror of
https://github.com/shlinkio/shlink.git
synced 2024-11-27 16:26:37 +03:00
commit
750e6cff45
111 changed files with 1341 additions and 617 deletions
93
.github/workflows/ci.yml
vendored
93
.github/workflows/ci.yml
vendored
|
@ -21,7 +21,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php-version }}
|
php-version: ${{ matrix.php-version }}
|
||||||
tools: composer
|
tools: composer
|
||||||
extensions: swoole-4.6.7
|
extensions: swoole-4.7.1
|
||||||
coverage: none
|
coverage: none
|
||||||
- run: composer install --no-interaction --prefer-dist
|
- run: composer install --no-interaction --prefer-dist
|
||||||
- run: composer cs
|
- run: composer cs
|
||||||
|
@ -39,7 +39,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php-version }}
|
php-version: ${{ matrix.php-version }}
|
||||||
tools: composer
|
tools: composer
|
||||||
extensions: swoole-4.6.7
|
extensions: swoole-4.7.1
|
||||||
coverage: none
|
coverage: none
|
||||||
- run: composer install --no-interaction --prefer-dist
|
- run: composer install --no-interaction --prefer-dist
|
||||||
- run: composer stan
|
- run: composer stan
|
||||||
|
@ -48,7 +48,8 @@ jobs:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['8.0']
|
php-version: ['8.0', '8.1']
|
||||||
|
continue-on-error: ${{ matrix.php-version == '8.1' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
@ -57,10 +58,13 @@ jobs:
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php-version }}
|
php-version: ${{ matrix.php-version }}
|
||||||
tools: composer
|
tools: composer
|
||||||
extensions: swoole-4.6.7
|
extensions: swoole-4.7.1
|
||||||
coverage: pcov
|
coverage: pcov
|
||||||
ini-values: pcov.directory=module
|
ini-values: pcov.directory=module
|
||||||
- run: composer install --no-interaction --prefer-dist
|
- if: ${{ matrix.php-version == '8.1' }}
|
||||||
|
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||||
|
- if: ${{ matrix.php-version != '8.1' }}
|
||||||
|
run: composer install --no-interaction --prefer-dist
|
||||||
- run: composer test:unit:ci
|
- run: composer test:unit:ci
|
||||||
- uses: actions/upload-artifact@v2
|
- uses: actions/upload-artifact@v2
|
||||||
if: ${{ matrix.php-version == '8.0' }}
|
if: ${{ matrix.php-version == '8.0' }}
|
||||||
|
@ -74,7 +78,8 @@ jobs:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['8.0']
|
php-version: ['8.0', '8.1']
|
||||||
|
continue-on-error: ${{ matrix.php-version == '8.1' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
@ -83,10 +88,13 @@ jobs:
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php-version }}
|
php-version: ${{ matrix.php-version }}
|
||||||
tools: composer
|
tools: composer
|
||||||
extensions: swoole-4.6.7
|
extensions: swoole-4.7.1
|
||||||
coverage: pcov
|
coverage: pcov
|
||||||
ini-values: pcov.directory=module
|
ini-values: pcov.directory=module
|
||||||
- run: composer install --no-interaction --prefer-dist
|
- if: ${{ matrix.php-version == '8.1' }}
|
||||||
|
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||||
|
- if: ${{ matrix.php-version != '8.1' }}
|
||||||
|
run: composer install --no-interaction --prefer-dist
|
||||||
- run: composer test:db:sqlite:ci
|
- run: composer test:db:sqlite:ci
|
||||||
- uses: actions/upload-artifact@v2
|
- uses: actions/upload-artifact@v2
|
||||||
if: ${{ matrix.php-version == '8.0' }}
|
if: ${{ matrix.php-version == '8.0' }}
|
||||||
|
@ -100,7 +108,8 @@ jobs:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['8.0']
|
php-version: ['8.0', '8.1']
|
||||||
|
continue-on-error: ${{ matrix.php-version == '8.1' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
@ -111,16 +120,20 @@ jobs:
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php-version }}
|
php-version: ${{ matrix.php-version }}
|
||||||
tools: composer
|
tools: composer
|
||||||
extensions: swoole-4.6.7
|
extensions: swoole-4.7.1
|
||||||
coverage: none
|
coverage: none
|
||||||
- run: composer install --no-interaction --prefer-dist
|
- if: ${{ matrix.php-version == '8.1' }}
|
||||||
|
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||||
|
- if: ${{ matrix.php-version != '8.1' }}
|
||||||
|
run: composer install --no-interaction --prefer-dist
|
||||||
- run: composer test:db:mysql
|
- run: composer test:db:mysql
|
||||||
|
|
||||||
db-tests-maria:
|
db-tests-maria:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['8.0']
|
php-version: ['8.0', '8.1']
|
||||||
|
continue-on-error: ${{ matrix.php-version == '8.1' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
@ -131,16 +144,20 @@ jobs:
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php-version }}
|
php-version: ${{ matrix.php-version }}
|
||||||
tools: composer
|
tools: composer
|
||||||
extensions: swoole-4.6.7
|
extensions: swoole-4.7.1
|
||||||
coverage: none
|
coverage: none
|
||||||
- run: composer install --no-interaction --prefer-dist
|
- if: ${{ matrix.php-version == '8.1' }}
|
||||||
|
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||||
|
- if: ${{ matrix.php-version != '8.1' }}
|
||||||
|
run: composer install --no-interaction --prefer-dist
|
||||||
- run: composer test:db:maria
|
- run: composer test:db:maria
|
||||||
|
|
||||||
db-tests-postgres:
|
db-tests-postgres:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['8.0']
|
php-version: ['8.0', '8.1']
|
||||||
|
continue-on-error: ${{ matrix.php-version == '8.1' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
@ -151,16 +168,20 @@ jobs:
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php-version }}
|
php-version: ${{ matrix.php-version }}
|
||||||
tools: composer
|
tools: composer
|
||||||
extensions: swoole-4.6.7
|
extensions: swoole-4.7.1
|
||||||
coverage: none
|
coverage: none
|
||||||
- run: composer install --no-interaction --prefer-dist
|
- if: ${{ matrix.php-version == '8.1' }}
|
||||||
|
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||||
|
- if: ${{ matrix.php-version != '8.1' }}
|
||||||
|
run: composer install --no-interaction --prefer-dist
|
||||||
- run: composer test:db:postgres
|
- run: composer test:db:postgres
|
||||||
|
|
||||||
db-tests-ms:
|
db-tests-ms:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['8.0']
|
php-version: ['8.0', '8.1']
|
||||||
|
continue-on-error: ${{ matrix.php-version == '8.1' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
@ -169,13 +190,25 @@ jobs:
|
||||||
- name: Start database server
|
- name: Start database server
|
||||||
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms
|
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms
|
||||||
- name: Use PHP
|
- name: Use PHP
|
||||||
|
if: ${{ matrix.php-version == '8.1' }}
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php-version }}
|
php-version: ${{ matrix.php-version }}
|
||||||
tools: composer
|
tools: composer
|
||||||
extensions: swoole-4.6.7, pdo_sqlsrv-5.9.0
|
extensions: swoole-4.7.1, pdo_sqlsrv-5.10.0beta1
|
||||||
coverage: none
|
coverage: none
|
||||||
- run: composer install --no-interaction --prefer-dist
|
- name: Use PHP
|
||||||
|
if: ${{ matrix.php-version != '8.1' }}
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: ${{ matrix.php-version }}
|
||||||
|
tools: composer
|
||||||
|
extensions: swoole-4.7.1, pdo_sqlsrv-5.9.0
|
||||||
|
coverage: none
|
||||||
|
- if: ${{ matrix.php-version == '8.1' }}
|
||||||
|
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||||
|
- if: ${{ matrix.php-version != '8.1' }}
|
||||||
|
run: composer install --no-interaction --prefer-dist
|
||||||
- name: Create test database
|
- name: Create test database
|
||||||
run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
|
run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
|
||||||
- run: composer test:db:ms
|
- run: composer test:db:ms
|
||||||
|
@ -184,7 +217,8 @@ jobs:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['8.0']
|
php-version: ['8.0', '8.1']
|
||||||
|
continue-on-error: ${{ matrix.php-version == '8.1' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
@ -195,10 +229,13 @@ jobs:
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php-version }}
|
php-version: ${{ matrix.php-version }}
|
||||||
tools: composer
|
tools: composer
|
||||||
extensions: swoole-4.6.7
|
extensions: swoole-4.7.1
|
||||||
coverage: pcov
|
coverage: pcov
|
||||||
ini-values: pcov.directory=module
|
ini-values: pcov.directory=module
|
||||||
- run: composer install --no-interaction --prefer-dist
|
- if: ${{ matrix.php-version == '8.1' }}
|
||||||
|
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||||
|
- if: ${{ matrix.php-version != '8.1' }}
|
||||||
|
run: composer install --no-interaction --prefer-dist
|
||||||
- run: bin/test/run-api-tests.sh
|
- run: bin/test/run-api-tests.sh
|
||||||
- uses: actions/upload-artifact@v2
|
- uses: actions/upload-artifact@v2
|
||||||
if: ${{ matrix.php-version == '8.0' }}
|
if: ${{ matrix.php-version == '8.0' }}
|
||||||
|
@ -216,8 +253,9 @@ jobs:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php-version: ['8.0']
|
php-version: ['8.0', '8.1']
|
||||||
test-group: ['unit', 'db']
|
test-group: ['unit', 'db']
|
||||||
|
continue-on-error: ${{ matrix.php-version == '8.1' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
@ -226,10 +264,13 @@ jobs:
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php-version }}
|
php-version: ${{ matrix.php-version }}
|
||||||
tools: composer
|
tools: composer
|
||||||
extensions: swoole-4.6.7
|
extensions: swoole-4.7.1
|
||||||
coverage: pcov
|
coverage: pcov
|
||||||
ini-values: pcov.directory=module
|
ini-values: pcov.directory=module
|
||||||
- run: composer install --no-interaction --prefer-dist
|
- if: ${{ matrix.php-version == '8.1' }}
|
||||||
|
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||||
|
- if: ${{ matrix.php-version != '8.1' }}
|
||||||
|
run: composer install --no-interaction --prefer-dist
|
||||||
- uses: actions/download-artifact@v2
|
- uses: actions/download-artifact@v2
|
||||||
with:
|
with:
|
||||||
path: build
|
path: build
|
||||||
|
|
44
CHANGELOG.md
44
CHANGELOG.md
|
@ -4,6 +4,48 @@ 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).
|
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## [2.9.0] - 2021-10-10
|
||||||
|
### Added
|
||||||
|
* [#1015](https://github.com/shlinkio/shlink/issues/1015) Shlink now accepts configuration via env vars even when not using docker.
|
||||||
|
|
||||||
|
The config generated with the installing tool still has precedence over the env vars, so it cannot be combined. Either you use the tool, or use env vars.
|
||||||
|
|
||||||
|
* [#1149](https://github.com/shlinkio/shlink/issues/1149) Allowed to set custom defaults for the QR codes.
|
||||||
|
* [#1112](https://github.com/shlinkio/shlink/issues/1112) Added new option to define if the query string should be forwarded on a per-short URL basis.
|
||||||
|
|
||||||
|
The new `forwardQuery=true|false` param can be provided during short URL creation or edition, via REST API or CLI command, allowing to override the default behavior which makes the query string to always be forwarded.
|
||||||
|
|
||||||
|
* [#1105](https://github.com/shlinkio/shlink/issues/1105) Added support to define placeholders on not-found redirects, so that the redirected URL receives the originally visited path and/or domain.
|
||||||
|
|
||||||
|
Currently, `{DOMAIN}` and `{ORIGINAL_PATH}` placeholders are supported, and they can be used both in the redirected URL's path or query.
|
||||||
|
|
||||||
|
When they are used in the query, the values are URL encoded.
|
||||||
|
|
||||||
|
* [#1119](https://github.com/shlinkio/shlink/issues/1119) Added support to provide redis sentinel when using redis cache.
|
||||||
|
* [#1016](https://github.com/shlinkio/shlink/issues/1016) Added new option to send orphan visits to webhooks, via `NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS` env var or installer tool.
|
||||||
|
|
||||||
|
The option is disabled by default, as the payload is backwards incompatible. You will need to adapt your webhooks to treat the `shortUrl` property as optional before enabling this option.
|
||||||
|
|
||||||
|
* [#1104](https://github.com/shlinkio/shlink/issues/1104) Added ability to disable tracking based on IP addresses.
|
||||||
|
|
||||||
|
IP addresses can be provided in the form of fixed addresses, CIDR blocks, or wildcard patterns (192.168.*.*).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* [#1142](https://github.com/shlinkio/shlink/issues/1142) Replaced `doctrine/cache` package with `symfony/cache`.
|
||||||
|
* [#1157](https://github.com/shlinkio/shlink/issues/1157) All routes now support CORS, not only rest ones.
|
||||||
|
* [#1144](https://github.com/shlinkio/shlink/issues/1144) Added experimental builds under PHP 8.1.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* [#1164](https://github.com/shlinkio/shlink/issues/1164) Deprecated `SHORT_DOMAIN_HOST` and `SHORT_DOMAIN_SCHEMA` env vars. Use `DEFAULT_DOMAIN` and `USE_HTTPS=true|false` instead.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* [#1165](https://github.com/shlinkio/shlink/issues/1165) Fixed warning displayed when trying to locate visits and there are none pending.
|
||||||
|
* [#1172](https://github.com/shlinkio/shlink/pull/1172) Removed unneeded explicitly defined volumes in docker image.
|
||||||
|
|
||||||
|
|
||||||
## [2.8.1] - 2021-08-15
|
## [2.8.1] - 2021-08-15
|
||||||
### Added
|
### Added
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
@ -50,7 +92,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
||||||
* [#1046](https://github.com/shlinkio/shlink/issues/1046) Dropped support for PHP 7.4.
|
* [#1046](https://github.com/shlinkio/shlink/issues/1046) Dropped support for PHP 7.4.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
* *Nothing*
|
* [#1098](https://github.com/shlinkio/shlink/issues/1098) Fixed errors when using Redis for caching, caused by some third party lib bug that was fixed on dependencies update.
|
||||||
|
|
||||||
|
|
||||||
## [2.7.3] - 2021-08-02
|
## [2.7.3] - 2021-08-02
|
||||||
|
|
|
@ -2,9 +2,9 @@ FROM php:8.0.9-alpine3.14 as base
|
||||||
|
|
||||||
ARG SHLINK_VERSION=latest
|
ARG SHLINK_VERSION=latest
|
||||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||||
ENV SWOOLE_VERSION 4.7.0
|
ENV SWOOLE_VERSION 4.7.1
|
||||||
ENV PDO_SQLSRV_VERSION 5.9.0
|
ENV PDO_SQLSRV_VERSION 5.9.0
|
||||||
ENV MS_ODBC_SQL_VERSION 17.5.2.1
|
ENV MS_ODBC_SQL_VERSION 17.5.2.2
|
||||||
ENV LC_ALL "C"
|
ENV LC_ALL "C"
|
||||||
|
|
||||||
WORKDIR /etc/shlink
|
WORKDIR /etc/shlink
|
||||||
|
@ -68,11 +68,6 @@ RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink
|
||||||
# Expose default swoole port
|
# Expose default swoole port
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# Expose params config dir, since the user is expected to provide custom config from there
|
|
||||||
VOLUME /etc/shlink/config/params
|
|
||||||
# Expose data dir to allow persistent runtime data and SQLite db
|
|
||||||
VOLUME /etc/shlink/data
|
|
||||||
|
|
||||||
# Copy config specific for the image
|
# Copy config specific for the image
|
||||||
COPY docker/docker-entrypoint.sh docker-entrypoint.sh
|
COPY docker/docker-entrypoint.sh docker-entrypoint.sh
|
||||||
COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php
|
COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php
|
||||||
|
|
|
@ -18,7 +18,6 @@
|
||||||
"akrabat/ip-address-middleware": "^2.0",
|
"akrabat/ip-address-middleware": "^2.0",
|
||||||
"cakephp/chronos": "^2.2",
|
"cakephp/chronos": "^2.2",
|
||||||
"cocur/slugify": "^4.0",
|
"cocur/slugify": "^4.0",
|
||||||
"doctrine/cache": "^1.12",
|
|
||||||
"doctrine/migrations": "^3.2",
|
"doctrine/migrations": "^3.2",
|
||||||
"doctrine/orm": "^2.9",
|
"doctrine/orm": "^2.9",
|
||||||
"endroid/qr-code": "^4.2",
|
"endroid/qr-code": "^4.2",
|
||||||
|
@ -47,11 +46,12 @@
|
||||||
"predis/predis": "^1.1",
|
"predis/predis": "^1.1",
|
||||||
"pugx/shortid-php": "^0.7",
|
"pugx/shortid-php": "^0.7",
|
||||||
"ramsey/uuid": "^3.9",
|
"ramsey/uuid": "^3.9",
|
||||||
"shlinkio/shlink-common": "^3.7",
|
"rlanvin/php-ip": "3.0.0-rc2",
|
||||||
|
"shlinkio/shlink-common": "^4.0",
|
||||||
"shlinkio/shlink-config": "^1.2",
|
"shlinkio/shlink-config": "^1.2",
|
||||||
"shlinkio/shlink-event-dispatcher": "^2.1",
|
"shlinkio/shlink-event-dispatcher": "^2.1",
|
||||||
"shlinkio/shlink-importer": "^2.3.1",
|
"shlinkio/shlink-importer": "^2.3.1",
|
||||||
"shlinkio/shlink-installer": "^6.1",
|
"shlinkio/shlink-installer": "^6.2",
|
||||||
"shlinkio/shlink-ip-geolocation": "^2.0",
|
"shlinkio/shlink-ip-geolocation": "^2.0",
|
||||||
"symfony/console": "^5.3",
|
"symfony/console": "^5.3",
|
||||||
"symfony/filesystem": "^5.3",
|
"symfony/filesystem": "^5.3",
|
||||||
|
@ -64,7 +64,7 @@
|
||||||
"devster/ubench": "^2.1",
|
"devster/ubench": "^2.1",
|
||||||
"dms/phpunit-arraysubset-asserts": "^0.3.0",
|
"dms/phpunit-arraysubset-asserts": "^0.3.0",
|
||||||
"eaglewu/swoole-ide-helper": "dev-master",
|
"eaglewu/swoole-ide-helper": "dev-master",
|
||||||
"infection/infection": "^0.24.0",
|
"infection/infection": "^0.25.0",
|
||||||
"phpspec/prophecy-phpunit": "^2.0",
|
"phpspec/prophecy-phpunit": "^2.0",
|
||||||
"phpstan/phpstan": "^0.12.94",
|
"phpstan/phpstan": "^0.12.94",
|
||||||
"phpstan/phpstan-doctrine": "^0.12.42",
|
"phpstan/phpstan-doctrine": "^0.12.42",
|
||||||
|
@ -72,7 +72,7 @@
|
||||||
"phpunit/php-code-coverage": "^9.2",
|
"phpunit/php-code-coverage": "^9.2",
|
||||||
"phpunit/phpunit": "^9.5",
|
"phpunit/phpunit": "^9.5",
|
||||||
"roave/security-advisories": "dev-master",
|
"roave/security-advisories": "dev-master",
|
||||||
"shlinkio/php-coding-standard": "~2.1.1",
|
"shlinkio/php-coding-standard": "~2.2.0",
|
||||||
"shlinkio/shlink-test-utils": "^2.2",
|
"shlinkio/shlink-test-utils": "^2.2",
|
||||||
"symfony/var-dumper": "^5.3",
|
"symfony/var-dumper": "^5.3",
|
||||||
"veewee/composer-run-parallel": "^1.0"
|
"veewee/composer-run-parallel": "^1.0"
|
||||||
|
@ -84,6 +84,7 @@
|
||||||
"Shlinkio\\Shlink\\Core\\": "module/Core/src"
|
"Shlinkio\\Shlink\\Core\\": "module/Core/src"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
"config/constants.php",
|
||||||
"module/Core/functions/functions.php"
|
"module/Core/functions/functions.php"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
@ -4,11 +4,15 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink;
|
namespace Shlinkio\Shlink;
|
||||||
|
|
||||||
|
use function Shlinkio\Shlink\Common\env;
|
||||||
|
|
||||||
|
use const Shlinkio\Shlink\DEFAULT_DELETE_SHORT_URL_THRESHOLD;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'delete_short_urls' => [
|
'delete_short_urls' => [
|
||||||
'visits_threshold' => 15,
|
|
||||||
'check_visits_threshold' => true,
|
'check_visits_threshold' => true,
|
||||||
|
'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', DEFAULT_DELETE_SHORT_URL_THRESHOLD),
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
@ -2,10 +2,42 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Common;
|
|
||||||
|
|
||||||
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
|
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
|
||||||
|
|
||||||
|
use function Functional\contains;
|
||||||
|
use function Shlinkio\Shlink\Common\env;
|
||||||
|
|
||||||
|
return (static function (): array {
|
||||||
|
$driver = env('DB_DRIVER');
|
||||||
|
$isMysqlCompatible = contains(['maria', 'mysql'], $driver);
|
||||||
|
|
||||||
|
$resolveDriver = static fn () => match ($driver) {
|
||||||
|
'postgres' => 'pdo_pgsql',
|
||||||
|
'mssql' => 'pdo_sqlsrv',
|
||||||
|
default => 'pdo_mysql',
|
||||||
|
};
|
||||||
|
$resolveDefaultPort = static fn () => match ($driver) {
|
||||||
|
'postgres' => '5432',
|
||||||
|
'mssql' => '1433',
|
||||||
|
default => '3306',
|
||||||
|
};
|
||||||
|
$resolveConnection = static fn () => match (true) {
|
||||||
|
$driver === null || $driver === 'sqlite' => [
|
||||||
|
'driver' => 'pdo_sqlite',
|
||||||
|
'path' => 'data/database.sqlite',
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
'driver' => $resolveDriver(),
|
||||||
|
'dbname' => env('DB_NAME', 'shlink'),
|
||||||
|
'user' => env('DB_USER'),
|
||||||
|
'password' => env('DB_PASSWORD'),
|
||||||
|
'host' => env('DB_HOST', $driver === 'postgres' ? env('DB_UNIX_SOCKET') : null),
|
||||||
|
'port' => env('DB_PORT', $resolveDefaultPort()),
|
||||||
|
'unix_socket' => $isMysqlCompatible ? env('DB_UNIX_SOCKET') : null,
|
||||||
|
'charset' => 'utf8',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'entity_manager' => [
|
'entity_manager' => [
|
||||||
|
@ -14,12 +46,8 @@ return [
|
||||||
'load_mappings_using_functional_style' => true,
|
'load_mappings_using_functional_style' => true,
|
||||||
'default_repository_classname' => EntitySpecificationRepository::class,
|
'default_repository_classname' => EntitySpecificationRepository::class,
|
||||||
],
|
],
|
||||||
'connection' => [
|
'connection' => $resolveConnection(),
|
||||||
'user' => '',
|
|
||||||
'password' => '',
|
|
||||||
'dbname' => 'shlink',
|
|
||||||
'charset' => 'utf8',
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
})();
|
||||||
|
|
|
@ -10,6 +10,8 @@ return [
|
||||||
'password' => 'root',
|
'password' => 'root',
|
||||||
'driver' => 'pdo_mysql',
|
'driver' => 'pdo_mysql',
|
||||||
'host' => 'shlink_db',
|
'host' => 'shlink_db',
|
||||||
|
'dbname' => 'shlink',
|
||||||
|
'charset' => 'utf8',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
@ -2,12 +2,14 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use function Shlinkio\Shlink\Common\env;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'geolite2' => [
|
'geolite2' => [
|
||||||
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
|
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
|
||||||
'temp_dir' => __DIR__ . '/../../data',
|
'temp_dir' => __DIR__ . '/../../data',
|
||||||
'license_key' => 'G4Lm0C60yJsnkdPi', // Deprecated. Remove hardcoded license on v3
|
'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'), // Deprecated. Remove hardcoded license on v3
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
@ -24,6 +24,7 @@ return [
|
||||||
Option\UrlShortener\ShortDomainSchemaConfigOption::class,
|
Option\UrlShortener\ShortDomainSchemaConfigOption::class,
|
||||||
Option\UrlShortener\ValidateUrlConfigOption::class,
|
Option\UrlShortener\ValidateUrlConfigOption::class,
|
||||||
Option\Visit\VisitsWebhooksConfigOption::class,
|
Option\Visit\VisitsWebhooksConfigOption::class,
|
||||||
|
Option\Visit\OrphanVisitsWebhooksConfigOption::class,
|
||||||
Option\Redirect\BaseUrlRedirectConfigOption::class,
|
Option\Redirect\BaseUrlRedirectConfigOption::class,
|
||||||
Option\Redirect\InvalidShortUrlRedirectConfigOption::class,
|
Option\Redirect\InvalidShortUrlRedirectConfigOption::class,
|
||||||
Option\Redirect\Regular404RedirectConfigOption::class,
|
Option\Redirect\Regular404RedirectConfigOption::class,
|
||||||
|
@ -46,10 +47,15 @@ return [
|
||||||
Option\Tracking\IpAnonymizationConfigOption::class,
|
Option\Tracking\IpAnonymizationConfigOption::class,
|
||||||
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
|
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
|
||||||
Option\Tracking\DisableTrackParamConfigOption::class,
|
Option\Tracking\DisableTrackParamConfigOption::class,
|
||||||
|
Option\Tracking\DisableTrackingFromConfigOption::class,
|
||||||
Option\Tracking\DisableTrackingConfigOption::class,
|
Option\Tracking\DisableTrackingConfigOption::class,
|
||||||
Option\Tracking\DisableIpTrackingConfigOption::class,
|
Option\Tracking\DisableIpTrackingConfigOption::class,
|
||||||
Option\Tracking\DisableReferrerTrackingConfigOption::class,
|
Option\Tracking\DisableReferrerTrackingConfigOption::class,
|
||||||
Option\Tracking\DisableUaTrackingConfigOption::class,
|
Option\Tracking\DisableUaTrackingConfigOption::class,
|
||||||
|
Option\QrCode\DefaultSizeConfigOption::class,
|
||||||
|
Option\QrCode\DefaultMarginConfigOption::class,
|
||||||
|
Option\QrCode\DefaultFormatConfigOption::class,
|
||||||
|
Option\QrCode\DefaultErrorCorrectionConfigOption::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
'installation_commands' => [
|
'installation_commands' => [
|
||||||
|
|
|
@ -3,12 +3,13 @@
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||||
use Shlinkio\Shlink\Common\Cache\RedisFactory;
|
use Predis\ClientInterface as PredisClient;
|
||||||
use Shlinkio\Shlink\Common\Lock\RetryLockStoreDelegatorFactory;
|
|
||||||
use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory;
|
use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory;
|
||||||
use Symfony\Component\Lock;
|
use Symfony\Component\Lock;
|
||||||
|
|
||||||
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
|
use function Shlinkio\Shlink\Common\env;
|
||||||
|
|
||||||
|
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
|
@ -24,16 +25,12 @@ return [
|
||||||
LOCAL_LOCK_FACTORY => ConfigAbstractFactory::class,
|
LOCAL_LOCK_FACTORY => ConfigAbstractFactory::class,
|
||||||
],
|
],
|
||||||
'aliases' => [
|
'aliases' => [
|
||||||
// With this config, a user could alias 'lock_store' => 'redis_lock_store' to override the default
|
'lock_store' => env('REDIS_SERVERS') === null ? 'local_lock_store' : 'redis_lock_store',
|
||||||
'lock_store' => 'local_lock_store',
|
|
||||||
|
|
||||||
'redis_lock_store' => Lock\Store\RedisStore::class,
|
'redis_lock_store' => Lock\Store\RedisStore::class,
|
||||||
'local_lock_store' => Lock\Store\FlockStore::class,
|
'local_lock_store' => Lock\Store\FlockStore::class,
|
||||||
],
|
],
|
||||||
'delegators' => [
|
'delegators' => [
|
||||||
Lock\Store\RedisStore::class => [
|
|
||||||
RetryLockStoreDelegatorFactory::class,
|
|
||||||
],
|
|
||||||
Lock\LockFactory::class => [
|
Lock\LockFactory::class => [
|
||||||
LoggerAwareDelegatorFactory::class,
|
LoggerAwareDelegatorFactory::class,
|
||||||
],
|
],
|
||||||
|
@ -42,7 +39,7 @@ return [
|
||||||
|
|
||||||
ConfigAbstractFactory::class => [
|
ConfigAbstractFactory::class => [
|
||||||
Lock\Store\FlockStore::class => ['config.locks.locks_dir'],
|
Lock\Store\FlockStore::class => ['config.locks.locks_dir'],
|
||||||
Lock\Store\RedisStore::class => [RedisFactory::SERVICE_NAME],
|
Lock\Store\RedisStore::class => [PredisClient::class],
|
||||||
Lock\LockFactory::class => ['lock_store'],
|
Lock\LockFactory::class => ['lock_store'],
|
||||||
LOCAL_LOCK_FACTORY => ['local_lock_store'],
|
LOCAL_LOCK_FACTORY => ['local_lock_store'],
|
||||||
],
|
],
|
||||||
|
|
|
@ -7,12 +7,17 @@ use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
|
||||||
use Symfony\Component\Mercure\Hub;
|
use Symfony\Component\Mercure\Hub;
|
||||||
use Symfony\Component\Mercure\HubInterface;
|
use Symfony\Component\Mercure\HubInterface;
|
||||||
|
|
||||||
|
use function Shlinkio\Shlink\Common\env;
|
||||||
|
|
||||||
|
return (static function (): array {
|
||||||
|
$publicUrl = env('MERCURE_PUBLIC_HUB_URL');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'mercure' => [
|
'mercure' => [
|
||||||
'public_hub_url' => null,
|
'public_hub_url' => $publicUrl,
|
||||||
'internal_hub_url' => null,
|
'internal_hub_url' => env('MERCURE_INTERNAL_HUB_URL', $publicUrl),
|
||||||
'jwt_secret' => null,
|
'jwt_secret' => env('MERCURE_JWT_SECRET'),
|
||||||
'jwt_issuer' => 'Shlink',
|
'jwt_issuer' => 'Shlink',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
@ -34,3 +39,4 @@ return [
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
})();
|
||||||
|
|
|
@ -18,12 +18,12 @@ return [
|
||||||
'middleware' => [
|
'middleware' => [
|
||||||
ContentLengthMiddleware::class,
|
ContentLengthMiddleware::class,
|
||||||
ErrorHandler::class,
|
ErrorHandler::class,
|
||||||
|
Rest\Middleware\CrossDomainMiddleware::class,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'error-handler-rest' => [
|
'error-handler-rest' => [
|
||||||
'path' => '/rest',
|
'path' => '/rest',
|
||||||
'middleware' => [
|
'middleware' => [
|
||||||
Rest\Middleware\CrossDomainMiddleware::class,
|
|
||||||
RequestIdMiddleware::class,
|
RequestIdMiddleware::class,
|
||||||
ProblemDetails\ProblemDetailsMiddleware::class,
|
ProblemDetails\ProblemDetailsMiddleware::class,
|
||||||
],
|
],
|
||||||
|
|
21
config/autoload/qr-codes.global.php
Normal file
21
config/autoload/qr-codes.global.php
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use function Shlinkio\Shlink\Common\env;
|
||||||
|
|
||||||
|
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
|
||||||
|
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
|
||||||
|
use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN;
|
||||||
|
use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'qr_codes' => [
|
||||||
|
'size' => (int) env('DEFAULT_QR_CODE_SIZE', DEFAULT_QR_CODE_SIZE),
|
||||||
|
'margin' => (int) env('DEFAULT_QR_CODE_MARGIN', DEFAULT_QR_CODE_MARGIN),
|
||||||
|
'format' => env('DEFAULT_QR_CODE_FORMAT', DEFAULT_QR_CODE_FORMAT),
|
||||||
|
'error_correction' => env('DEFAULT_QR_CODE_ERROR_CORRECTION', DEFAULT_QR_CODE_ERROR_CORRECTION),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
|
@ -2,12 +2,23 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use function Shlinkio\Shlink\Common\env;
|
||||||
|
|
||||||
|
use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||||
|
use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'not_found_redirects' => [
|
'not_found_redirects' => [
|
||||||
'invalid_short_url' => null,
|
'invalid_short_url' => env('INVALID_SHORT_URL_REDIRECT_TO'),
|
||||||
'regular_404' => null,
|
'regular_404' => env('REGULAR_404_REDIRECT_TO'),
|
||||||
'base_url' => null,
|
'base_url' => env('BASE_URL_REDIRECT_TO'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'url_shortener' => [
|
||||||
|
// TODO Move these options to their own config namespace. Maybe "redirects".
|
||||||
|
'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
|
||||||
|
'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
21
config/autoload/redis.global.php
Normal file
21
config/autoload/redis.global.php
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use function Shlinkio\Shlink\Common\env;
|
||||||
|
|
||||||
|
return (static function (): array {
|
||||||
|
$redisServers = env('REDIS_SERVERS');
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$redisServers === null => [],
|
||||||
|
default => [
|
||||||
|
'cache' => [
|
||||||
|
'redis' => [
|
||||||
|
'servers' => $redisServers,
|
||||||
|
'sentinel_service' => env('REDIS_SENTINEL_SERVICE'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
})();
|
|
@ -4,10 +4,12 @@ declare(strict_types=1);
|
||||||
|
|
||||||
use Mezzio\Router\FastRouteRouter;
|
use Mezzio\Router\FastRouteRouter;
|
||||||
|
|
||||||
|
use function Shlinkio\Shlink\Common\env;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'router' => [
|
'router' => [
|
||||||
'base_path' => '',
|
'base_path' => env('BASE_PATH', ''),
|
||||||
|
|
||||||
'fastroute' => [
|
'fastroute' => [
|
||||||
FastRouteRouter::CONFIG_CACHE_ENABLED => true,
|
FastRouteRouter::CONFIG_CACHE_ENABLED => true,
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use function Shlinkio\Shlink\Common\env;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'mezzio-swoole' => [
|
'mezzio-swoole' => [
|
||||||
|
@ -10,11 +12,12 @@ return [
|
||||||
|
|
||||||
'swoole-http-server' => [
|
'swoole-http-server' => [
|
||||||
'host' => '0.0.0.0',
|
'host' => '0.0.0.0',
|
||||||
|
'port' => (int) env('PORT', 8080),
|
||||||
'process-name' => 'shlink',
|
'process-name' => 'shlink',
|
||||||
|
|
||||||
'options' => [
|
'options' => [
|
||||||
'worker_num' => 16,
|
'worker_num' => (int) env('WEB_WORKER_NUM', 16),
|
||||||
'task_worker_num' => 16,
|
'task_worker_num' => (int) env('TASK_WORKER_NUM', 16),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
|
@ -2,30 +2,35 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use function Shlinkio\Shlink\Common\env;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'tracking' => [
|
'tracking' => [
|
||||||
// Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations
|
// Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations
|
||||||
// This applies only if IP address tracking is enabled
|
// This applies only if IP address tracking is enabled
|
||||||
'anonymize_remote_addr' => true,
|
'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true),
|
||||||
|
|
||||||
// Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence
|
// Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence
|
||||||
'track_orphan_visits' => true,
|
'track_orphan_visits' => (bool) env('TRACK_ORPHAN_VISITS', true),
|
||||||
|
|
||||||
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence
|
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence
|
||||||
'disable_track_param' => null,
|
'disable_track_param' => env('DISABLE_TRACK_PARAM'),
|
||||||
|
|
||||||
// If true, visits will not be tracked at all
|
// If true, visits will not be tracked at all
|
||||||
'disable_tracking' => false,
|
'disable_tracking' => (bool) env('DISABLE_TRACKING', false),
|
||||||
|
|
||||||
// If true, visits will be tracked, but neither the IP address, nor the location will be resolved
|
// If true, visits will be tracked, but neither the IP address, nor the location will be resolved
|
||||||
'disable_ip_tracking' => false,
|
'disable_ip_tracking' => (bool) env('DISABLE_IP_TRACKING', false),
|
||||||
|
|
||||||
// If true, the referrer will not be tracked
|
// If true, the referrer will not be tracked
|
||||||
'disable_referrer_tracking' => false,
|
'disable_referrer_tracking' => (bool) env('DISABLE_REFERRER_TRACKING', false),
|
||||||
|
|
||||||
// If true, the user agent will not be tracked
|
// If true, the user agent will not be tracked
|
||||||
'disable_ua_tracking' => false,
|
'disable_ua_tracking' => (bool) env('DISABLE_UA_TRACKING', false),
|
||||||
|
|
||||||
|
// A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default
|
||||||
|
'disable_tracking_from' => env('DISABLE_TRACKING_FROM'),
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
@ -2,26 +2,29 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_CACHE_LIFETIME;
|
use function Shlinkio\Shlink\Common\env;
|
||||||
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_STATUS_CODE;
|
|
||||||
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
|
||||||
|
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
|
||||||
|
|
||||||
|
return (static function (): array {
|
||||||
|
$shortCodesLength = (int) env('DEFAULT_SHORT_CODES_LENGTH', DEFAULT_SHORT_CODES_LENGTH);
|
||||||
|
$shortCodesLength = $shortCodesLength < MIN_SHORT_CODES_LENGTH ? MIN_SHORT_CODES_LENGTH : $shortCodesLength;
|
||||||
|
$useHttps = env('USE_HTTPS'); // Deprecated. For v3, set this to true by default, instead of null
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'url_shortener' => [
|
'url_shortener' => [
|
||||||
'domain' => [
|
'domain' => [
|
||||||
'schema' => 'https',
|
// Deprecated SHORT_DOMAIN_* env vars
|
||||||
'hostname' => '',
|
'schema' => $useHttps !== null ? (bool) $useHttps : env('SHORT_DOMAIN_SCHEMA', 'http'),
|
||||||
|
'hostname' => env('DEFAULT_DOMAIN', env('SHORT_DOMAIN_HOST', '')),
|
||||||
],
|
],
|
||||||
'validate_url' => false, // Deprecated
|
'validate_url' => (bool) env('VALIDATE_URLS', false), // Deprecated
|
||||||
'visits_webhooks' => [],
|
'default_short_codes_length' => $shortCodesLength,
|
||||||
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
|
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
|
||||||
'auto_resolve_titles' => false,
|
'append_extra_path' => (bool) env('REDIRECT_APPEND_EXTRA_PATH', false),
|
||||||
'append_extra_path' => false,
|
|
||||||
|
|
||||||
// TODO Move these two options to their own config namespace. Maybe "redirects".
|
|
||||||
'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE,
|
|
||||||
'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME,
|
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
})();
|
||||||
|
|
19
config/autoload/webhooks.global.php
Normal file
19
config/autoload/webhooks.global.php
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use function Shlinkio\Shlink\Common\env;
|
||||||
|
|
||||||
|
return (static function (): array {
|
||||||
|
$webhooks = env('VISITS_WEBHOOKS');
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'url_shortener' => [
|
||||||
|
// TODO Move these options to their own config namespace
|
||||||
|
'visits_webhooks' => $webhooks === null ? [] : explode(',', $webhooks),
|
||||||
|
'notify_orphan_visits_to_webhooks' => (bool) env('NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS', false),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
})();
|
|
@ -8,7 +8,7 @@ use Laminas\ConfigAggregator;
|
||||||
use Laminas\Diactoros;
|
use Laminas\Diactoros;
|
||||||
use Mezzio;
|
use Mezzio;
|
||||||
use Mezzio\ProblemDetails;
|
use Mezzio\ProblemDetails;
|
||||||
use Mezzio\Swoole\ConfigProvider as SwooleConfigProvider;
|
use Mezzio\Swoole;
|
||||||
|
|
||||||
use function class_exists;
|
use function class_exists;
|
||||||
use function Shlinkio\Shlink\Common\env;
|
use function Shlinkio\Shlink\Common\env;
|
||||||
|
@ -17,7 +17,7 @@ return (new ConfigAggregator\ConfigAggregator([
|
||||||
Mezzio\ConfigProvider::class,
|
Mezzio\ConfigProvider::class,
|
||||||
Mezzio\Router\ConfigProvider::class,
|
Mezzio\Router\ConfigProvider::class,
|
||||||
Mezzio\Router\FastRouteRouter\ConfigProvider::class,
|
Mezzio\Router\FastRouteRouter\ConfigProvider::class,
|
||||||
class_exists(SwooleConfigProvider::class) ? SwooleConfigProvider::class : new ConfigAggregator\ArrayProvider([]),
|
class_exists(Swoole\ConfigProvider::class) ? Swoole\ConfigProvider::class : new ConfigAggregator\ArrayProvider([]),
|
||||||
ProblemDetails\ConfigProvider::class,
|
ProblemDetails\ConfigProvider::class,
|
||||||
Diactoros\ConfigProvider::class,
|
Diactoros\ConfigProvider::class,
|
||||||
Common\ConfigProvider::class,
|
Common\ConfigProvider::class,
|
||||||
|
@ -31,6 +31,7 @@ return (new ConfigAggregator\ConfigAggregator([
|
||||||
new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'),
|
new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'),
|
||||||
env('APP_ENV') === 'test'
|
env('APP_ENV') === 'test'
|
||||||
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')
|
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')
|
||||||
|
// Deprecated. When the SimplifiedConfigParser is removed, load only generated_config.php here
|
||||||
: new ConfigAggregator\LaminasConfigProvider('config/params/{generated_config.php,*.config.{php,json}}'),
|
: new ConfigAggregator\LaminasConfigProvider('config/params/{generated_config.php,*.config.{php,json}}'),
|
||||||
], 'data/cache/app_config.php', [
|
], 'data/cache/app_config.php', [
|
||||||
Core\Config\SimplifiedConfigParser::class,
|
Core\Config\SimplifiedConfigParser::class,
|
||||||
|
|
20
config/constants.php
Normal file
20
config/constants.php
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink;
|
||||||
|
|
||||||
|
use Fig\Http\Message\StatusCodeInterface;
|
||||||
|
|
||||||
|
const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15;
|
||||||
|
const DEFAULT_SHORT_CODES_LENGTH = 5;
|
||||||
|
const MIN_SHORT_CODES_LENGTH = 4;
|
||||||
|
const DEFAULT_REDIRECT_STATUS_CODE = StatusCodeInterface::STATUS_FOUND;
|
||||||
|
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
|
||||||
|
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
|
||||||
|
const CUSTOM_SLUGS_REGEXP = '/[^\pL\pN._~]/u'; // Any unicode letter or number, plus ".", "_" and "~" chars
|
||||||
|
const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside an html title tag
|
||||||
|
const DEFAULT_QR_CODE_SIZE = 300;
|
||||||
|
const DEFAULT_QR_CODE_MARGIN = 0;
|
||||||
|
const DEFAULT_QR_CODE_FORMAT = 'png';
|
||||||
|
const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
|
|
@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||||
use Laminas\ServiceManager\ServiceManager;
|
use Laminas\ServiceManager\ServiceManager;
|
||||||
use Symfony\Component\Lock;
|
use Symfony\Component\Lock;
|
||||||
|
|
||||||
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
|
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
|
||||||
|
|
||||||
chdir(dirname(__DIR__));
|
chdir(dirname(__DIR__));
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||||
|
|
||||||
ENV APCU_VERSION 5.1.20
|
ENV APCU_VERSION 5.1.20
|
||||||
ENV PDO_SQLSRV_VERSION 5.9.0
|
ENV PDO_SQLSRV_VERSION 5.9.0
|
||||||
ENV MS_ODBC_SQL_VERSION 17.5.2.1
|
ENV MS_ODBC_SQL_VERSION 17.5.2.2
|
||||||
|
|
||||||
RUN apk update
|
RUN apk update
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,10 @@ FROM php:8.0.9-alpine3.14
|
||||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||||
|
|
||||||
ENV APCU_VERSION 5.1.20
|
ENV APCU_VERSION 5.1.20
|
||||||
ENV PDO_SQLSRV_VERSION 5.9.0
|
|
||||||
ENV INOTIFY_VERSION 3.0.0
|
ENV INOTIFY_VERSION 3.0.0
|
||||||
ENV SWOOLE_VERSION 4.7.0
|
ENV SWOOLE_VERSION 4.7.1
|
||||||
ENV MS_ODBC_SQL_VERSION 17.5.2.1
|
ENV PDO_SQLSRV_VERSION 5.9.0
|
||||||
|
ENV MS_ODBC_SQL_VERSION 17.5.2.2
|
||||||
|
|
||||||
RUN apk update
|
RUN apk update
|
||||||
|
|
||||||
|
|
|
@ -60,10 +60,7 @@ final class Version20201102113208 extends AbstractMigration
|
||||||
->execute();
|
->execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function resolveOneApiKeyId(Result $result): string|int|null
|
||||||
* @return string|int|null
|
|
||||||
*/
|
|
||||||
private function resolveOneApiKeyId(Result $result)
|
|
||||||
{
|
{
|
||||||
$results = [];
|
$results = [];
|
||||||
while ($row = $result->fetchAssociative()) {
|
while ($row = $result->fetchAssociative()) {
|
||||||
|
|
26
data/migrations/Version20211002072605.php
Normal file
26
data/migrations/Version20211002072605.php
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ShlinkMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20211002072605 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$shortUrls = $schema->getTable('short_urls');
|
||||||
|
$this->skipIf($shortUrls->hasColumn('forward_query'));
|
||||||
|
$shortUrls->addColumn('forward_query', Types::BOOLEAN, ['default' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$shortUrls = $schema->getTable('short_urls');
|
||||||
|
$this->skipIf(! $shortUrls->hasColumn('forward_query'));
|
||||||
|
$shortUrls->dropColumn('forward_query');
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,8 +11,8 @@ It exposes a shlink instance served with [swoole](https://www.swoole.co.uk/), wh
|
||||||
|
|
||||||
The most basic way to run Shlink's docker image is by providing these mandatory env vars.
|
The most basic way to run Shlink's docker image is by providing these mandatory env vars.
|
||||||
|
|
||||||
* `SHORT_DOMAIN_HOST`: The custom short domain used for this shlink instance. For example **doma.in**.
|
* `DEFAULT_DOMAIN`: The default short domain used for this shlink instance. For example **doma.in**.
|
||||||
* `SHORT_DOMAIN_SCHEMA`: Either **http** or **https**.
|
* `USE_HTTPS`: Either **true** or **false**.
|
||||||
* `GEOLITE_LICENSE_KEY`: Your GeoLite2 license key. [Learn more](https://shlink.io/documentation/geolite-license-key/) about this.
|
* `GEOLITE_LICENSE_KEY`: Your GeoLite2 license key. [Learn more](https://shlink.io/documentation/geolite-license-key/) about this.
|
||||||
|
|
||||||
To run shlink on top of a local docker service, and using an internal SQLite database, do the following:
|
To run shlink on top of a local docker service, and using an internal SQLite database, do the following:
|
||||||
|
@ -21,8 +21,8 @@ To run shlink on top of a local docker service, and using an internal SQLite dat
|
||||||
docker run \
|
docker run \
|
||||||
--name shlink \
|
--name shlink \
|
||||||
-p 8080:8080 \
|
-p 8080:8080 \
|
||||||
-e SHORT_DOMAIN_HOST=doma.in \
|
-e DEFAULT_DOMAIN=doma.in \
|
||||||
-e SHORT_DOMAIN_SCHEMA=https \
|
-e USE_HTTPS=true \
|
||||||
-e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 \
|
-e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 \
|
||||||
shlinkio/shlink:stable
|
shlinkio/shlink:stable
|
||||||
```
|
```
|
||||||
|
|
|
@ -7,127 +7,7 @@ namespace Shlinkio\Shlink;
|
||||||
use Monolog\Handler\StreamHandler;
|
use Monolog\Handler\StreamHandler;
|
||||||
use Monolog\Logger;
|
use Monolog\Logger;
|
||||||
|
|
||||||
use function explode;
|
|
||||||
use function Functional\contains;
|
|
||||||
use function Shlinkio\Shlink\Common\env;
|
|
||||||
|
|
||||||
use const Shlinkio\Shlink\Core\DEFAULT_DELETE_SHORT_URL_THRESHOLD;
|
|
||||||
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_CACHE_LIFETIME;
|
|
||||||
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_STATUS_CODE;
|
|
||||||
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
|
|
||||||
{
|
|
||||||
$driver = env('DB_DRIVER');
|
|
||||||
$isMysql = contains(['maria', 'mysql'], $driver);
|
|
||||||
if ($driver === null || $driver === 'sqlite') {
|
|
||||||
return [
|
return [
|
||||||
'driver' => 'pdo_sqlite',
|
|
||||||
'path' => 'data/database.sqlite',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'driver' => self::DB_DRIVERS_MAP[$driver],
|
|
||||||
'dbname' => env('DB_NAME', 'shlink'),
|
|
||||||
'user' => env('DB_USER'),
|
|
||||||
'password' => env('DB_PASSWORD'),
|
|
||||||
'host' => env('DB_HOST', $driver === 'postgres' ? env('DB_UNIX_SOCKET') : null),
|
|
||||||
'port' => env('DB_PORT', self::DB_PORTS_MAP[$driver]),
|
|
||||||
'unix_socket' => $isMysql ? env('DB_UNIX_SOCKET') : null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getNotFoundRedirectsConfig(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'invalid_short_url' => env('INVALID_SHORT_URL_REDIRECT_TO'),
|
|
||||||
'regular_404' => env('REGULAR_404_REDIRECT_TO'),
|
|
||||||
'base_url' => env('BASE_URL_REDIRECT_TO'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getVisitsWebhooks(): array
|
|
||||||
{
|
|
||||||
$webhooks = env('VISITS_WEBHOOKS');
|
|
||||||
return $webhooks === null ? [] : explode(',', $webhooks);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRedisConfig(): ?array
|
|
||||||
{
|
|
||||||
$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;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getMercureConfig(): array
|
|
||||||
{
|
|
||||||
$publicUrl = env('MERCURE_PUBLIC_HUB_URL');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'public_hub_url' => $publicUrl,
|
|
||||||
'internal_hub_url' => env('MERCURE_INTERNAL_HUB_URL', $publicUrl),
|
|
||||||
'jwt_secret' => env('MERCURE_JWT_SECRET'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
|
||||||
|
|
||||||
'delete_short_urls' => [
|
|
||||||
'check_visits_threshold' => true,
|
|
||||||
'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', DEFAULT_DELETE_SHORT_URL_THRESHOLD),
|
|
||||||
],
|
|
||||||
|
|
||||||
'entity_manager' => [
|
|
||||||
'connection' => $helper->getDbConfig(),
|
|
||||||
],
|
|
||||||
|
|
||||||
'url_shortener' => [
|
|
||||||
'domain' => [
|
|
||||||
'schema' => env('SHORT_DOMAIN_SCHEMA', 'http'),
|
|
||||||
'hostname' => env('SHORT_DOMAIN_HOST', ''),
|
|
||||||
],
|
|
||||||
'validate_url' => (bool) env('VALIDATE_URLS', false),
|
|
||||||
'visits_webhooks' => $helper->getVisitsWebhooks(),
|
|
||||||
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
|
|
||||||
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
|
|
||||||
'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
|
|
||||||
'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
|
|
||||||
'append_extra_path' => (bool) env('REDIRECT_APPEND_EXTRA_PATH', false),
|
|
||||||
],
|
|
||||||
|
|
||||||
'tracking' => [
|
|
||||||
'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true),
|
|
||||||
'track_orphan_visits' => (bool) env('TRACK_ORPHAN_VISITS', true),
|
|
||||||
'disable_track_param' => env('DISABLE_TRACK_PARAM'),
|
|
||||||
'disable_tracking' => (bool) env('DISABLE_TRACKING', false),
|
|
||||||
'disable_ip_tracking' => (bool) env('DISABLE_IP_TRACKING', false),
|
|
||||||
'disable_referrer_tracking' => (bool) env('DISABLE_REFERRER_TRACKING', false),
|
|
||||||
'disable_ua_tracking' => (bool) env('DISABLE_UA_TRACKING', false),
|
|
||||||
],
|
|
||||||
|
|
||||||
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),
|
|
||||||
|
|
||||||
'logger' => [
|
'logger' => [
|
||||||
'Shlink' => [
|
'Shlink' => [
|
||||||
|
@ -143,34 +23,4 @@ return [
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'dependencies' => [
|
|
||||||
'aliases' => env('REDIS_SERVERS') === null ? [] : [
|
|
||||||
'lock_store' => 'redis_lock_store',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'cache' => [
|
|
||||||
'redis' => $helper->getRedisConfig(),
|
|
||||||
],
|
|
||||||
|
|
||||||
'router' => [
|
|
||||||
'base_path' => env('BASE_PATH', ''),
|
|
||||||
],
|
|
||||||
|
|
||||||
'mezzio-swoole' => [
|
|
||||||
'swoole-http-server' => [
|
|
||||||
'port' => (int) env('PORT', 8080),
|
|
||||||
'options' => [
|
|
||||||
'worker_num' => (int) env('WEB_WORKER_NUM', 16),
|
|
||||||
'task_worker_num' => (int) env('TASK_WORKER_NUM', 16),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'geolite2' => [
|
|
||||||
'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'), // Deprecated. Remove hardcoded license on v3
|
|
||||||
],
|
|
||||||
|
|
||||||
'mercure' => $helper->getMercureConfig(),
|
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
59
docs/adr/2021-08-05-migrate-to-a-new-caching-library.md
Normal file
59
docs/adr/2021-08-05-migrate-to-a-new-caching-library.md
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
# Migrate to a new caching library
|
||||||
|
|
||||||
|
* Status: Accepted
|
||||||
|
* Date: 2021-08-05
|
||||||
|
|
||||||
|
## Context and problem statement
|
||||||
|
|
||||||
|
Shlink has always used the `doctrine/cache` library to handle anything related with cache.
|
||||||
|
|
||||||
|
It was convenient, as it provided several adapters, and it was the library used by other doctrine packages.
|
||||||
|
|
||||||
|
However, after the creation of the caching PSRs ([PSR-6 - Cache](https://www.php-fig.org/psr/psr-6) and [PSR-16 - Simple cache](https://www.php-fig.org/psr/psr-16)), most library authors have moved to those interfaces, and the doctrine team has decided to recommend using any other existing package and decommission their own solution.
|
||||||
|
|
||||||
|
Also, Shlink needs support for Redis clusters and Redis sentinels, which is not supported by `doctrine/cache` Redis adapters.
|
||||||
|
|
||||||
|
## Considered option
|
||||||
|
|
||||||
|
After some research, the only packages that seem to support the capabilities required by Shlink and also seem healthy, are these:
|
||||||
|
|
||||||
|
* [Symfony cache](https://symfony.com/doc/current/components/cache.html)
|
||||||
|
* 🟢 PSR-6 compliant: **yes**
|
||||||
|
* 🟢 PSR-16 compliant: **yes**
|
||||||
|
* 🟢 APCu support: **yes**
|
||||||
|
* 🟢 Redis support: **yes**
|
||||||
|
* 🟢 Redis cluster support: **yes**
|
||||||
|
* 🟢 Redis sentinel support: **yes**
|
||||||
|
* 🟢 Can use redis through Predis: **yes**
|
||||||
|
* 🔴 Individual packages per adapter: **no**
|
||||||
|
* [Laminas cache](https://docs.laminas.dev/laminas-cache/)
|
||||||
|
* 🟢 PSR-6 compliant: **yes**
|
||||||
|
* 🟢 PSR-16 compliant: **yes**
|
||||||
|
* 🟢 APCu support: **yes**
|
||||||
|
* 🟢 Redis support: **yes**
|
||||||
|
* 🟢 Redis cluster support: **yes**
|
||||||
|
* 🔴 Redis sentinel support: **no**
|
||||||
|
* 🔴 Can use redis through Predis: **no**
|
||||||
|
* 🟢 Individual packages per adapter: **yes**
|
||||||
|
|
||||||
|
## Decision outcome
|
||||||
|
|
||||||
|
Even though Symfony packs all their adapters in a single component, which means we will install some code that will never be used, Laminas relies on the native redis extension for anything related with redis.
|
||||||
|
|
||||||
|
That would make Shlink more complex to install, so it seems Symfony's package is the option where it's easier to migrate to.
|
||||||
|
|
||||||
|
Also, it's important that the cache component can share the Redis integration (through `Predis`, in this case), as it's also used by other components (the lock component, to name one).
|
||||||
|
|
||||||
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
|
### Symfony cache
|
||||||
|
|
||||||
|
* Good because it supports Redis Sentinel.
|
||||||
|
* Good because it allows using a external `Predis` instance.
|
||||||
|
* Bad because it packs all the adapters in a single component.
|
||||||
|
|
||||||
|
### Laminas cache
|
||||||
|
|
||||||
|
* Good because allows installing only the adapters you are going to use, through separated packages.
|
||||||
|
* Bad because it requires the php-redis native extension in order to interact with Redis.
|
||||||
|
* Bad because it does ot seem to support Redis Sentinels.
|
|
@ -2,5 +2,6 @@
|
||||||
|
|
||||||
Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome.
|
Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome.
|
||||||
|
|
||||||
|
* [2021-08-05 Migrate to a new caching library](2021-08-05-migrate-to-a-new-caching-library.md)
|
||||||
* [2021-02-07 Track visits to 'base_url', 'invalid_short_url' and 'regular_404'](2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md)
|
* [2021-02-07 Track visits to 'base_url', 'invalid_short_url' and 'regular_404'](2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md)
|
||||||
* [2021-01-17 Support restrictions and permissions in API keys](2021-01-17-support-restrictions-and-permissions-in-api-keys.md)
|
* [2021-01-17 Support restrictions and permissions in API keys](2021-01-17-support-restrictions-and-permissions-in-api-keys.md)
|
||||||
|
|
|
@ -1,5 +1,18 @@
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"shortCode",
|
||||||
|
"shortUrl",
|
||||||
|
"longUrl",
|
||||||
|
"dateCreated",
|
||||||
|
"visitsCount",
|
||||||
|
"tags",
|
||||||
|
"meta",
|
||||||
|
"domain",
|
||||||
|
"title",
|
||||||
|
"crawlable",
|
||||||
|
"forwardQuery"
|
||||||
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"shortCode": {
|
"shortCode": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
@ -45,6 +58,10 @@
|
||||||
"crawlable": {
|
"crawlable": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt."
|
"description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt."
|
||||||
|
},
|
||||||
|
"forwardQuery": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Tells if this URL will forward the query params to the long URL when visited, as explained in [the docs](https://shlink.io/documentation/some-features/#query-params-forwarding)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
48
docs/swagger/definitions/ShortUrlEdition.json
Normal file
48
docs/swagger/definitions/ShortUrlEdition.json
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"validUntil": {
|
||||||
|
"description": "The date (in ISO-8601 format) until which this short code will be valid",
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"maxVisits": {
|
||||||
|
"description": "The maximum number of allowed visits for this short code",
|
||||||
|
"type": "number",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"validateUrl": {
|
||||||
|
"description": "Tells if the long URL (if provided) should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "The list of tags to set to the short URL."
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A descriptive title of the short URL.",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"crawlable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt."
|
||||||
|
},
|
||||||
|
"forwardQuery": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Tells if the query params should be forwarded from the short URL to the long one, as explained in [the docs](https://shlink.io/documentation/some-features/#query-params-forwarding)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -225,38 +225,18 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "../definitions/ShortUrlEdition.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": ["longUrl"],
|
||||||
"longUrl"
|
|
||||||
],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"longUrl": {
|
|
||||||
"description": "The URL to parse",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"tags": {
|
|
||||||
"description": "The URL to parse",
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"validSince": {
|
|
||||||
"description": "The date (in ISO-8601 format) from which this short code will be valid",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"validUntil": {
|
|
||||||
"description": "The date (in ISO-8601 format) until which this short code will be valid",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"customSlug": {
|
"customSlug": {
|
||||||
"description": "A unique custom slug to be used instead of the generated short code",
|
"description": "A unique custom slug to be used instead of the generated short code",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"maxVisits": {
|
|
||||||
"description": "The maximum number of allowed visits for this short code",
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"findIfExists": {
|
"findIfExists": {
|
||||||
"description": "Will force existing matching URL to be returned if found, instead of creating a new one",
|
"description": "Will force existing matching URL to be returned if found, instead of creating a new one",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
@ -272,17 +252,11 @@
|
||||||
"validateUrl": {
|
"validateUrl": {
|
||||||
"description": "Tells if the long URL should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
|
"description": "Tells if the long URL should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
|
||||||
"title": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "A descriptive title of the short URL."
|
|
||||||
},
|
|
||||||
"crawlable": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -112,48 +112,7 @@
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"$ref": "../definitions/ShortUrlEdition.json"
|
||||||
"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",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"validUntil": {
|
|
||||||
"description": "The date (in ISO-8601 format) until which this short code will be valid",
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"maxVisits": {
|
|
||||||
"description": "The maximum number of allowed visits for this short code",
|
|
||||||
"type": "number",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"validateUrl": {
|
|
||||||
"description": "Tells if the long URL (if provided) should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"tags": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"description": "The list of tags to set to the short URL."
|
|
||||||
},
|
|
||||||
"title": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "A descriptive title of the short URL.",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"crawlable": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
"Domains"
|
"Domains"
|
||||||
],
|
],
|
||||||
"summary": "Sets domain \"not found\" redirects",
|
"summary": "Sets domain \"not found\" redirects",
|
||||||
"description": "Sets the URLs that you want a visitor to get redirected to for \not found\" URLs for a specific domain",
|
"description": "Sets the URLs that you want a visitor to get redirected to for \"not found\" URLs for a specific domain",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"ApiKey": []
|
"ApiKey": []
|
||||||
|
|
|
@ -24,7 +24,7 @@ use Symfony\Component\Console as SymfonyCli;
|
||||||
use Symfony\Component\Lock\LockFactory;
|
use Symfony\Component\Lock\LockFactory;
|
||||||
use Symfony\Component\Process\PhpExecutableFinder;
|
use Symfony\Component\Process\PhpExecutableFinder;
|
||||||
|
|
||||||
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
|
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ class GenerateKeyCommand extends BaseCommand
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ApiKeyServiceInterface $apiKeyService,
|
private ApiKeyServiceInterface $apiKeyService,
|
||||||
private RoleResolverInterface $roleResolver
|
private RoleResolverInterface $roleResolver,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,7 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand
|
||||||
public function __construct(
|
public function __construct(
|
||||||
LockFactory $locker,
|
LockFactory $locker,
|
||||||
private ProcessRunnerInterface $processRunner,
|
private ProcessRunnerInterface $processRunner,
|
||||||
PhpExecutableFinder $phpFinder
|
PhpExecutableFinder $phpFinder,
|
||||||
) {
|
) {
|
||||||
parent::__construct($locker);
|
parent::__construct($locker);
|
||||||
$this->phpBinary = $phpFinder->find(false) ?: 'php';
|
$this->phpBinary = $phpFinder->find(false) ?: 'php';
|
||||||
|
|
|
@ -26,7 +26,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
|
||||||
ProcessRunnerInterface $processRunner,
|
ProcessRunnerInterface $processRunner,
|
||||||
PhpExecutableFinder $phpFinder,
|
PhpExecutableFinder $phpFinder,
|
||||||
private Connection $regularConn,
|
private Connection $regularConn,
|
||||||
private Connection $noDbNameConn
|
private Connection $noDbNameConn,
|
||||||
) {
|
) {
|
||||||
parent::__construct($locker, $processRunner, $phpFinder);
|
parent::__construct($locker, $processRunner, $phpFinder);
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,7 +33,7 @@ class GenerateShortUrlCommand extends BaseCommand
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private UrlShortenerInterface $urlShortener,
|
private UrlShortenerInterface $urlShortener,
|
||||||
private ShortUrlStringifierInterface $stringifier,
|
private ShortUrlStringifierInterface $stringifier,
|
||||||
private int $defaultShortCodeLength
|
private int $defaultShortCodeLength,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
@ -104,7 +104,19 @@ class GenerateShortUrlCommand extends BaseCommand
|
||||||
'no-validate-url',
|
'no-validate-url',
|
||||||
null,
|
null,
|
||||||
InputOption::VALUE_NONE,
|
InputOption::VALUE_NONE,
|
||||||
'Forces the long URL to not be validated, regardless what is globally configured.',
|
'[DEPRECATED] Forces the long URL to not be validated, regardless what is globally configured.',
|
||||||
|
)
|
||||||
|
->addOption(
|
||||||
|
'crawlable',
|
||||||
|
'r',
|
||||||
|
InputOption::VALUE_NONE,
|
||||||
|
'Tells if this URL will be included as "Allow" in Shlink\'s robots.txt.',
|
||||||
|
)
|
||||||
|
->addOption(
|
||||||
|
'no-forward-query',
|
||||||
|
'w',
|
||||||
|
InputOption::VALUE_NONE,
|
||||||
|
'Disables the forwarding of the query string to the long URL, when the new short URL is visited.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -156,6 +168,8 @@ class GenerateShortUrlCommand extends BaseCommand
|
||||||
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
|
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
|
||||||
ShortUrlInputFilter::VALIDATE_URL => $doValidateUrl,
|
ShortUrlInputFilter::VALIDATE_URL => $doValidateUrl,
|
||||||
ShortUrlInputFilter::TAGS => $tags,
|
ShortUrlInputFilter::TAGS => $tags,
|
||||||
|
ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'),
|
||||||
|
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
$io->writeln([
|
$io->writeln([
|
||||||
|
|
|
@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||||
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
|
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
|
||||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
|
||||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||||
|
@ -21,6 +20,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
use function Functional\map;
|
use function Functional\map;
|
||||||
use function Functional\select_keys;
|
use function Functional\select_keys;
|
||||||
|
use function Shlinkio\Shlink\Common\buildDateRange;
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
class GetVisitsCommand extends AbstractWithDateRangeCommand
|
class GetVisitsCommand extends AbstractWithDateRangeCommand
|
||||||
|
@ -73,7 +73,7 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
|
||||||
|
|
||||||
$paginator = $this->visitsHelper->visitsForShortUrl(
|
$paginator = $this->visitsHelper->visitsForShortUrl(
|
||||||
$identifier,
|
$identifier,
|
||||||
new VisitsParams(new DateRange($startDate, $endDate)),
|
new VisitsParams(buildDateRange($startDate, $endDate)),
|
||||||
);
|
);
|
||||||
|
|
||||||
$rows = map($paginator->getCurrentPageResults(), function (Visit $visit) {
|
$rows = map($paginator->getCurrentPageResults(), function (Visit $visit) {
|
||||||
|
|
|
@ -35,7 +35,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ShortUrlServiceInterface $shortUrlService,
|
private ShortUrlServiceInterface $shortUrlService,
|
||||||
private DataTransformerInterface $transformer
|
private DataTransformerInterface $transformer,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ final class LockedCommandConfig
|
||||||
private function __construct(
|
private function __construct(
|
||||||
private string $lockName,
|
private string $lockName,
|
||||||
private bool $isBlocking,
|
private bool $isBlocking,
|
||||||
private float $ttl = self::DEFAULT_TTL
|
private float $ttl = self::DEFAULT_TTL,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -35,7 +35,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private VisitLocatorInterface $visitLocator,
|
private VisitLocatorInterface $visitLocator,
|
||||||
private IpLocationResolverInterface $ipLocationResolver,
|
private IpLocationResolverInterface $ipLocationResolver,
|
||||||
LockFactory $locker
|
LockFactory $locker,
|
||||||
) {
|
) {
|
||||||
parent::__construct($locker);
|
parent::__construct($locker);
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||||
private DbUpdaterInterface $dbUpdater,
|
private DbUpdaterInterface $dbUpdater,
|
||||||
private Reader $geoLiteDbReader,
|
private Reader $geoLiteDbReader,
|
||||||
private LockFactory $locker,
|
private LockFactory $locker,
|
||||||
private TrackingOptions $trackingOptions
|
private TrackingOptions $trackingOptions,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -45,7 +45,7 @@ class GetVisitsCommandTest extends TestCase
|
||||||
$shortCode = 'abc123';
|
$shortCode = 'abc123';
|
||||||
$this->visitsHelper->visitsForShortUrl(
|
$this->visitsHelper->visitsForShortUrl(
|
||||||
new ShortUrlIdentifier($shortCode),
|
new ShortUrlIdentifier($shortCode),
|
||||||
new VisitsParams(new DateRange(null, null)),
|
new VisitsParams(DateRange::emptyInstance()),
|
||||||
)
|
)
|
||||||
->willReturn(new Paginator(new ArrayAdapter([])))
|
->willReturn(new Paginator(new ArrayAdapter([])))
|
||||||
->shouldBeCalledOnce();
|
->shouldBeCalledOnce();
|
||||||
|
@ -61,7 +61,7 @@ class GetVisitsCommandTest extends TestCase
|
||||||
$endDate = '2016-02-01';
|
$endDate = '2016-02-01';
|
||||||
$this->visitsHelper->visitsForShortUrl(
|
$this->visitsHelper->visitsForShortUrl(
|
||||||
new ShortUrlIdentifier($shortCode),
|
new ShortUrlIdentifier($shortCode),
|
||||||
new VisitsParams(new DateRange(Chronos::parse($startDate), Chronos::parse($endDate))),
|
new VisitsParams(DateRange::withStartAndEndDate(Chronos::parse($startDate), Chronos::parse($endDate))),
|
||||||
)
|
)
|
||||||
->willReturn(new Paginator(new ArrayAdapter([])))
|
->willReturn(new Paginator(new ArrayAdapter([])))
|
||||||
->shouldBeCalledOnce();
|
->shouldBeCalledOnce();
|
||||||
|
@ -80,7 +80,7 @@ class GetVisitsCommandTest extends TestCase
|
||||||
$startDate = 'foo';
|
$startDate = 'foo';
|
||||||
$info = $this->visitsHelper->visitsForShortUrl(
|
$info = $this->visitsHelper->visitsForShortUrl(
|
||||||
new ShortUrlIdentifier($shortCode),
|
new ShortUrlIdentifier($shortCode),
|
||||||
new VisitsParams(new DateRange()),
|
new VisitsParams(DateRange::emptyInstance()),
|
||||||
)->willReturn(new Paginator(new ArrayAdapter([])));
|
)->willReturn(new Paginator(new ArrayAdapter([])));
|
||||||
|
|
||||||
$this->commandTester->execute([
|
$this->commandTester->execute([
|
||||||
|
|
|
@ -238,11 +238,10 @@ class ListShortUrlsCommandTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string|array|null $expectedOrderBy
|
|
||||||
* @test
|
* @test
|
||||||
* @dataProvider provideOrderBy
|
* @dataProvider provideOrderBy
|
||||||
*/
|
*/
|
||||||
public function orderByIsProperlyComputed(array $commandArgs, $expectedOrderBy): void
|
public function orderByIsProperlyComputed(array $commandArgs, string|array|null $expectedOrderBy): void
|
||||||
{
|
{
|
||||||
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
|
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
|
||||||
'orderBy' => $expectedOrderBy,
|
'orderBy' => $expectedOrderBy,
|
||||||
|
|
|
@ -116,9 +116,8 @@ class GeolocationDbUpdaterTest extends TestCase
|
||||||
/**
|
/**
|
||||||
* @test
|
* @test
|
||||||
* @dataProvider provideSmallDays
|
* @dataProvider provideSmallDays
|
||||||
* @param string|int $buildEpoch
|
|
||||||
*/
|
*/
|
||||||
public function databaseIsNotUpdatedIfItIsYoungerThanOneWeek($buildEpoch): void
|
public function databaseIsNotUpdatedIfItIsYoungerThanOneWeek(string|int $buildEpoch): void
|
||||||
{
|
{
|
||||||
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true);
|
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true);
|
||||||
$getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch($buildEpoch));
|
$getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch($buildEpoch));
|
||||||
|
@ -161,10 +160,7 @@ class GeolocationDbUpdaterTest extends TestCase
|
||||||
$this->geolocationDbUpdater->checkDbUpdate();
|
$this->geolocationDbUpdater->checkDbUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function buildMetaWithBuildEpoch(string|int $buildEpoch): Metadata
|
||||||
* @param string|int $buildEpoch
|
|
||||||
*/
|
|
||||||
private function buildMetaWithBuildEpoch($buildEpoch): Metadata
|
|
||||||
{
|
{
|
||||||
return new Metadata([
|
return new Metadata([
|
||||||
'binary_format_major_version' => '',
|
'binary_format_major_version' => '',
|
||||||
|
|
|
@ -25,6 +25,8 @@ return [
|
||||||
Options\NotFoundRedirectOptions::class => ConfigAbstractFactory::class,
|
Options\NotFoundRedirectOptions::class => ConfigAbstractFactory::class,
|
||||||
Options\UrlShortenerOptions::class => ConfigAbstractFactory::class,
|
Options\UrlShortenerOptions::class => ConfigAbstractFactory::class,
|
||||||
Options\TrackingOptions::class => ConfigAbstractFactory::class,
|
Options\TrackingOptions::class => ConfigAbstractFactory::class,
|
||||||
|
Options\QrCodeOptions::class => ConfigAbstractFactory::class,
|
||||||
|
Options\WebhookOptions::class => ConfigAbstractFactory::class,
|
||||||
|
|
||||||
Service\UrlShortener::class => ConfigAbstractFactory::class,
|
Service\UrlShortener::class => ConfigAbstractFactory::class,
|
||||||
Service\ShortUrlService::class => ConfigAbstractFactory::class,
|
Service\ShortUrlService::class => ConfigAbstractFactory::class,
|
||||||
|
@ -86,6 +88,8 @@ return [
|
||||||
Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'],
|
Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'],
|
||||||
Options\UrlShortenerOptions::class => ['config.url_shortener'],
|
Options\UrlShortenerOptions::class => ['config.url_shortener'],
|
||||||
Options\TrackingOptions::class => ['config.tracking'],
|
Options\TrackingOptions::class => ['config.tracking'],
|
||||||
|
Options\QrCodeOptions::class => ['config.qr_codes'],
|
||||||
|
Options\WebhookOptions::class => ['config.url_shortener'], // TODO This config is currently under url_shortener
|
||||||
|
|
||||||
Service\UrlShortener::class => [
|
Service\UrlShortener::class => [
|
||||||
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class,
|
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class,
|
||||||
|
@ -125,7 +129,7 @@ return [
|
||||||
Util\DoctrineBatchHelper::class => ['em'],
|
Util\DoctrineBatchHelper::class => ['em'],
|
||||||
Util\RedirectResponseHelper::class => [Options\UrlShortenerOptions::class],
|
Util\RedirectResponseHelper::class => [Options\UrlShortenerOptions::class],
|
||||||
|
|
||||||
Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class],
|
Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class, 'Logger_Shlink'],
|
||||||
|
|
||||||
Action\RedirectAction::class => [
|
Action\RedirectAction::class => [
|
||||||
Service\ShortUrl\ShortUrlResolver::class,
|
Service\ShortUrl\ShortUrlResolver::class,
|
||||||
|
@ -138,6 +142,7 @@ return [
|
||||||
Service\ShortUrl\ShortUrlResolver::class,
|
Service\ShortUrl\ShortUrlResolver::class,
|
||||||
ShortUrl\Helper\ShortUrlStringifier::class,
|
ShortUrl\Helper\ShortUrlStringifier::class,
|
||||||
'Logger_Shlink',
|
'Logger_Shlink',
|
||||||
|
Options\QrCodeOptions::class,
|
||||||
],
|
],
|
||||||
Action\RobotsAction::class => [Crawling\CrawlingHelper::class],
|
Action\RobotsAction::class => [Crawling\CrawlingHelper::class],
|
||||||
|
|
||||||
|
|
|
@ -100,4 +100,9 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||||
->columnName('crawlable')
|
->columnName('crawlable')
|
||||||
->option('default', false)
|
->option('default', false)
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
|
$builder->createField('forwardQuery', Types::BOOLEAN)
|
||||||
|
->columnName('forward_query')
|
||||||
|
->option('default', true)
|
||||||
|
->build();
|
||||||
};
|
};
|
||||||
|
|
|
@ -58,7 +58,7 @@ return [
|
||||||
'httpClient',
|
'httpClient',
|
||||||
'em',
|
'em',
|
||||||
'Logger_Shlink',
|
'Logger_Shlink',
|
||||||
'config.url_shortener.visits_webhooks',
|
Options\WebhookOptions::class,
|
||||||
ShortUrl\Transformer\ShortUrlDataTransformer::class,
|
ShortUrl\Transformer\ShortUrlDataTransformer::class,
|
||||||
Options\AppOptions::class,
|
Options\AppOptions::class,
|
||||||
],
|
],
|
||||||
|
|
|
@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Core;
|
||||||
|
|
||||||
use Cake\Chronos\Chronos;
|
use Cake\Chronos\Chronos;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use Fig\Http\Message\StatusCodeInterface;
|
|
||||||
use Jaybizzle\CrawlerDetect\CrawlerDetect;
|
use Jaybizzle\CrawlerDetect\CrawlerDetect;
|
||||||
use Laminas\InputFilter\InputFilter;
|
use Laminas\InputFilter\InputFilter;
|
||||||
use PUGX\Shortid\Factory as ShortIdFactory;
|
use PUGX\Shortid\Factory as ShortIdFactory;
|
||||||
|
@ -16,20 +15,12 @@ use function Functional\reduce_left;
|
||||||
use function is_array;
|
use function is_array;
|
||||||
use function lcfirst;
|
use function lcfirst;
|
||||||
use function print_r;
|
use function print_r;
|
||||||
|
use function Shlinkio\Shlink\Common\buildDateRange;
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
use function str_repeat;
|
use function str_repeat;
|
||||||
use function str_replace;
|
use function str_replace;
|
||||||
use function ucwords;
|
use function ucwords;
|
||||||
|
|
||||||
const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15;
|
|
||||||
const DEFAULT_SHORT_CODES_LENGTH = 5;
|
|
||||||
const MIN_SHORT_CODES_LENGTH = 4;
|
|
||||||
const DEFAULT_REDIRECT_STATUS_CODE = StatusCodeInterface::STATUS_FOUND;
|
|
||||||
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
|
|
||||||
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
|
|
||||||
const CUSTOM_SLUGS_REGEXP = '/[^\pL\pN._~]/u'; // Any unicode letter or number, plus ".", "_" and "~" chars
|
|
||||||
const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside an html title tag
|
|
||||||
|
|
||||||
function generateRandomShortCode(int $length): string
|
function generateRandomShortCode(int $length): string
|
||||||
{
|
{
|
||||||
static $shortIdFactory;
|
static $shortIdFactory;
|
||||||
|
@ -51,18 +42,10 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en
|
||||||
$startDate = parseDateFromQuery($query, $startDateName);
|
$startDate = parseDateFromQuery($query, $startDateName);
|
||||||
$endDate = parseDateFromQuery($query, $endDateName);
|
$endDate = parseDateFromQuery($query, $endDateName);
|
||||||
|
|
||||||
return match (true) {
|
return buildDateRange($startDate, $endDate);
|
||||||
$startDate === null && $endDate === null => DateRange::emptyInstance(),
|
|
||||||
$startDate !== null && $endDate !== null => DateRange::withStartAndEndDate($startDate, $endDate),
|
|
||||||
$startDate !== null => DateRange::withStartDate($startDate),
|
|
||||||
default => DateRange::withEndDate($endDate),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function parseDateField(string|DateTimeInterface|Chronos|null $date): ?Chronos
|
||||||
* @param string|DateTimeInterface|Chronos|null $date
|
|
||||||
*/
|
|
||||||
function parseDateField($date): ?Chronos
|
|
||||||
{
|
{
|
||||||
if ($date === null || $date instanceof Chronos) {
|
if ($date === null || $date instanceof Chronos) {
|
||||||
return $date;
|
return $date;
|
||||||
|
|
|
@ -14,40 +14,42 @@ use Endroid\QrCode\Writer\SvgWriter;
|
||||||
use Endroid\QrCode\Writer\WriterInterface;
|
use Endroid\QrCode\Writer\WriterInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Shlinkio\Shlink\Core\Options\QrCodeOptions;
|
||||||
|
|
||||||
|
use function Functional\contains;
|
||||||
use function strtolower;
|
use function strtolower;
|
||||||
use function trim;
|
use function trim;
|
||||||
|
|
||||||
final class QrCodeParams
|
final class QrCodeParams
|
||||||
{
|
{
|
||||||
private const DEFAULT_SIZE = 300;
|
|
||||||
private const MIN_SIZE = 50;
|
private const MIN_SIZE = 50;
|
||||||
private const MAX_SIZE = 1000;
|
private const MAX_SIZE = 1000;
|
||||||
|
private const SUPPORTED_FORMATS = ['png', 'svg'];
|
||||||
|
|
||||||
private function __construct(
|
private function __construct(
|
||||||
private int $size,
|
private int $size,
|
||||||
private int $margin,
|
private int $margin,
|
||||||
private WriterInterface $writer,
|
private WriterInterface $writer,
|
||||||
private ErrorCorrectionLevelInterface $errorCorrectionLevel
|
private ErrorCorrectionLevelInterface $errorCorrectionLevel,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function fromRequest(ServerRequestInterface $request): self
|
public static function fromRequest(ServerRequestInterface $request, QrCodeOptions $defaults): self
|
||||||
{
|
{
|
||||||
$query = $request->getQueryParams();
|
$query = $request->getQueryParams();
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
self::resolveSize($request, $query),
|
self::resolveSize($request, $query, $defaults),
|
||||||
self::resolveMargin($query),
|
self::resolveMargin($query, $defaults),
|
||||||
self::resolveWriter($query),
|
self::resolveWriter($query, $defaults),
|
||||||
self::resolveErrorCorrection($query),
|
self::resolveErrorCorrection($query, $defaults),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function resolveSize(Request $request, array $query): int
|
private static function resolveSize(Request $request, array $query, QrCodeOptions $defaults): int
|
||||||
{
|
{
|
||||||
// FIXME Size attribute is deprecated. After v3.0.0, always use the query param instead
|
// FIXME Size attribute is deprecated. After v3.0.0, always use the query param instead
|
||||||
$size = (int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE);
|
$size = (int) $request->getAttribute('size', $query['size'] ?? $defaults->size());
|
||||||
if ($size < self::MIN_SIZE) {
|
if ($size < self::MIN_SIZE) {
|
||||||
return self::MIN_SIZE;
|
return self::MIN_SIZE;
|
||||||
}
|
}
|
||||||
|
@ -55,13 +57,9 @@ final class QrCodeParams
|
||||||
return $size > self::MAX_SIZE ? self::MAX_SIZE : $size;
|
return $size > self::MAX_SIZE ? self::MAX_SIZE : $size;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function resolveMargin(array $query): int
|
private static function resolveMargin(array $query, QrCodeOptions $defaults): int
|
||||||
{
|
{
|
||||||
$margin = $query['margin'] ?? null;
|
$margin = $query['margin'] ?? (string) $defaults->margin();
|
||||||
if ($margin === null) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$intMargin = (int) $margin;
|
$intMargin = (int) $margin;
|
||||||
if ($margin !== (string) $intMargin) {
|
if ($margin !== (string) $intMargin) {
|
||||||
return 0;
|
return 0;
|
||||||
|
@ -70,18 +68,20 @@ final class QrCodeParams
|
||||||
return $intMargin < 0 ? 0 : $intMargin;
|
return $intMargin < 0 ? 0 : $intMargin;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function resolveWriter(array $query): WriterInterface
|
private static function resolveWriter(array $query, QrCodeOptions $defaults): WriterInterface
|
||||||
{
|
{
|
||||||
$format = strtolower(trim($query['format'] ?? 'png'));
|
$qFormat = self::normalizeParam($query['format'] ?? '');
|
||||||
|
$format = contains(self::SUPPORTED_FORMATS, $qFormat) ? $qFormat : self::normalizeParam($defaults->format());
|
||||||
|
|
||||||
return match ($format) {
|
return match ($format) {
|
||||||
'svg' => new SvgWriter(),
|
'svg' => new SvgWriter(),
|
||||||
default => new PngWriter(),
|
default => new PngWriter(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function resolveErrorCorrection(array $query): ErrorCorrectionLevelInterface
|
private static function resolveErrorCorrection(array $query, QrCodeOptions $defaults): ErrorCorrectionLevelInterface
|
||||||
{
|
{
|
||||||
$errorCorrectionLevel = strtolower(trim($query['errorCorrection'] ?? 'l'));
|
$errorCorrectionLevel = self::normalizeParam($query['errorCorrection'] ?? $defaults->errorCorrection());
|
||||||
return match ($errorCorrectionLevel) {
|
return match ($errorCorrectionLevel) {
|
||||||
'h' => new ErrorCorrectionLevelHigh(),
|
'h' => new ErrorCorrectionLevelHigh(),
|
||||||
'q' => new ErrorCorrectionLevelQuartile(),
|
'q' => new ErrorCorrectionLevelQuartile(),
|
||||||
|
@ -90,6 +90,11 @@ final class QrCodeParams
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function normalizeParam(string $param): string
|
||||||
|
{
|
||||||
|
return strtolower(trim($param));
|
||||||
|
}
|
||||||
|
|
||||||
public function size(): int
|
public function size(): int
|
||||||
{
|
{
|
||||||
return $this->size;
|
return $this->size;
|
||||||
|
|
|
@ -14,6 +14,7 @@ use Shlinkio\Shlink\Common\Response\QrCodeResponse;
|
||||||
use Shlinkio\Shlink\Core\Action\Model\QrCodeParams;
|
use Shlinkio\Shlink\Core\Action\Model\QrCodeParams;
|
||||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
|
use Shlinkio\Shlink\Core\Options\QrCodeOptions;
|
||||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||||
|
|
||||||
|
@ -22,7 +23,8 @@ class QrCodeAction implements MiddlewareInterface
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ShortUrlResolverInterface $urlResolver,
|
private ShortUrlResolverInterface $urlResolver,
|
||||||
private ShortUrlStringifierInterface $stringifier,
|
private ShortUrlStringifierInterface $stringifier,
|
||||||
private LoggerInterface $logger
|
private LoggerInterface $logger,
|
||||||
|
private QrCodeOptions $defaultOptions,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -37,7 +39,7 @@ class QrCodeAction implements MiddlewareInterface
|
||||||
return $handler->handle($request);
|
return $handler->handle($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
$params = QrCodeParams::fromRequest($request);
|
$params = QrCodeParams::fromRequest($request, $this->defaultOptions);
|
||||||
$qrCodeBuilder = Builder::create()
|
$qrCodeBuilder = Builder::create()
|
||||||
->data($this->stringifier->stringify($shortUrl))
|
->data($this->stringifier->stringify($shortUrl))
|
||||||
->size($params->size())
|
->size($params->size())
|
||||||
|
|
|
@ -4,31 +4,78 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\Config;
|
namespace Shlinkio\Shlink\Core\Config;
|
||||||
|
|
||||||
|
use League\Uri\Exceptions\SyntaxError;
|
||||||
|
use League\Uri\Uri;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\UriInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||||
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
|
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
|
||||||
|
|
||||||
|
use function Functional\compose;
|
||||||
|
use function str_replace;
|
||||||
|
|
||||||
class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
|
class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
|
||||||
{
|
{
|
||||||
public function __construct(private RedirectResponseHelperInterface $redirectResponseHelper)
|
private const DOMAIN_PLACEHOLDER = '{DOMAIN}';
|
||||||
{
|
private const ORIGINAL_PATH_PLACEHOLDER = '{ORIGINAL_PATH}';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private RedirectResponseHelperInterface $redirectResponseHelper,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resolveRedirectResponse(
|
public function resolveRedirectResponse(
|
||||||
NotFoundType $notFoundType,
|
NotFoundType $notFoundType,
|
||||||
NotFoundRedirectConfigInterface $config
|
NotFoundRedirectConfigInterface $config,
|
||||||
|
UriInterface $currentUri,
|
||||||
): ?ResponseInterface {
|
): ?ResponseInterface {
|
||||||
return match (true) {
|
$urlToRedirectTo = match (true) {
|
||||||
$notFoundType->isBaseUrl() && $config->hasBaseUrlRedirect() =>
|
$notFoundType->isBaseUrl() && $config->hasBaseUrlRedirect() => $config->baseUrlRedirect(),
|
||||||
// @phpstan-ignore-next-line Create custom PHPStan rule
|
$notFoundType->isRegularNotFound() && $config->hasRegular404Redirect() => $config->regular404Redirect(),
|
||||||
$this->redirectResponseHelper->buildRedirectResponse($config->baseUrlRedirect()),
|
|
||||||
$notFoundType->isRegularNotFound() && $config->hasRegular404Redirect() =>
|
|
||||||
// @phpstan-ignore-next-line Create custom PHPStan rule
|
|
||||||
$this->redirectResponseHelper->buildRedirectResponse($config->regular404Redirect()),
|
|
||||||
$notFoundType->isInvalidShortUrl() && $config->hasInvalidShortUrlRedirect() =>
|
$notFoundType->isInvalidShortUrl() && $config->hasInvalidShortUrlRedirect() =>
|
||||||
// @phpstan-ignore-next-line Create custom PHPStan rule
|
$config->invalidShortUrlRedirect(),
|
||||||
$this->redirectResponseHelper->buildRedirectResponse($config->invalidShortUrlRedirect()),
|
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if ($urlToRedirectTo === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectResponseHelper->buildRedirectResponse(
|
||||||
|
$this->resolvePlaceholders($currentUri, $urlToRedirectTo),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePlaceholders(UriInterface $currentUri, string $redirectUrl): string
|
||||||
|
{
|
||||||
|
$domain = $currentUri->getAuthority();
|
||||||
|
$path = $currentUri->getPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$redirectUri = Uri::createFromString($redirectUrl);
|
||||||
|
} catch (SyntaxError $e) {
|
||||||
|
$this->logger->warning('It was not possible to parse "{url}" as a valid URL: {e}', [
|
||||||
|
'e' => $e,
|
||||||
|
'url' => $redirectUrl,
|
||||||
|
]);
|
||||||
|
return $redirectUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
$replacePlaceholderForPattern = static fn (string $pattern, string $replace, callable $modifier) =>
|
||||||
|
static fn (?string $value) =>
|
||||||
|
$value === null ? null : str_replace($modifier($pattern), $modifier($replace), $value);
|
||||||
|
$replacePlaceholders = static fn (callable $modifier) => compose(
|
||||||
|
$replacePlaceholderForPattern(self::DOMAIN_PLACEHOLDER, $domain, $modifier),
|
||||||
|
$replacePlaceholderForPattern(self::ORIGINAL_PATH_PLACEHOLDER, $path, $modifier),
|
||||||
|
);
|
||||||
|
$replacePlaceholdersInPath = $replacePlaceholders('\Functional\id');
|
||||||
|
$replacePlaceholdersInQuery = $replacePlaceholders('\urlencode');
|
||||||
|
|
||||||
|
return $redirectUri
|
||||||
|
->withPath($replacePlaceholdersInPath($redirectUri->getPath()))
|
||||||
|
->withQuery($replacePlaceholdersInQuery($redirectUri->getQuery()))
|
||||||
|
->__toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,12 +5,14 @@ declare(strict_types=1);
|
||||||
namespace Shlinkio\Shlink\Core\Config;
|
namespace Shlinkio\Shlink\Core\Config;
|
||||||
|
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\UriInterface;
|
||||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||||
|
|
||||||
interface NotFoundRedirectResolverInterface
|
interface NotFoundRedirectResolverInterface
|
||||||
{
|
{
|
||||||
public function resolveRedirectResponse(
|
public function resolveRedirectResponse(
|
||||||
NotFoundType $notFoundType,
|
NotFoundType $notFoundType,
|
||||||
NotFoundRedirectConfigInterface $config
|
NotFoundRedirectConfigInterface $config,
|
||||||
|
UriInterface $currentUri,
|
||||||
): ?ResponseInterface;
|
): ?ResponseInterface;
|
||||||
}
|
}
|
||||||
|
|
|
@ -84,7 +84,7 @@ class DomainService implements DomainServiceInterface
|
||||||
public function configureNotFoundRedirects(
|
public function configureNotFoundRedirects(
|
||||||
string $authority,
|
string $authority,
|
||||||
NotFoundRedirects $notFoundRedirects,
|
NotFoundRedirects $notFoundRedirects,
|
||||||
?ApiKey $apiKey = null
|
?ApiKey $apiKey = null,
|
||||||
): Domain {
|
): Domain {
|
||||||
if ($authority === $this->defaultDomain) {
|
if ($authority === $this->defaultDomain) {
|
||||||
throw InvalidDomainException::forDefaultDomainRedirects();
|
throw InvalidDomainException::forDefaultDomainRedirects();
|
||||||
|
|
|
@ -14,7 +14,7 @@ final class DomainItem implements JsonSerializable
|
||||||
private function __construct(
|
private function __construct(
|
||||||
private string $authority,
|
private string $authority,
|
||||||
private NotFoundRedirectConfigInterface $notFoundRedirectConfig,
|
private NotFoundRedirectConfigInterface $notFoundRedirectConfig,
|
||||||
private bool $isDefault
|
private bool $isDefault,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -43,6 +43,7 @@ class ShortUrl extends AbstractEntity
|
||||||
private ?string $title = null;
|
private ?string $title = null;
|
||||||
private bool $titleWasAutoResolved = false;
|
private bool $titleWasAutoResolved = false;
|
||||||
private bool $crawlable = false;
|
private bool $crawlable = false;
|
||||||
|
private bool $forwardQuery = true;
|
||||||
|
|
||||||
private function __construct()
|
private function __construct()
|
||||||
{
|
{
|
||||||
|
@ -80,6 +81,7 @@ class ShortUrl extends AbstractEntity
|
||||||
$instance->title = $meta->getTitle();
|
$instance->title = $meta->getTitle();
|
||||||
$instance->titleWasAutoResolved = $meta->titleWasAutoResolved();
|
$instance->titleWasAutoResolved = $meta->titleWasAutoResolved();
|
||||||
$instance->crawlable = $meta->isCrawlable();
|
$instance->crawlable = $meta->isCrawlable();
|
||||||
|
$instance->forwardQuery = $meta->forwardQuery();
|
||||||
|
|
||||||
return $instance;
|
return $instance;
|
||||||
}
|
}
|
||||||
|
@ -207,6 +209,11 @@ class ShortUrl extends AbstractEntity
|
||||||
return $this->crawlable;
|
return $this->crawlable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function forwardQuery(): bool
|
||||||
|
{
|
||||||
|
return $this->forwardQuery;
|
||||||
|
}
|
||||||
|
|
||||||
public function update(
|
public function update(
|
||||||
ShortUrlEdit $shortUrlEdit,
|
ShortUrlEdit $shortUrlEdit,
|
||||||
?ShortUrlRelationResolverInterface $relationResolver = null,
|
?ShortUrlRelationResolverInterface $relationResolver = null,
|
||||||
|
@ -238,6 +245,9 @@ class ShortUrl extends AbstractEntity
|
||||||
$this->title = $shortUrlEdit->title();
|
$this->title = $shortUrlEdit->title();
|
||||||
$this->titleWasAutoResolved = $shortUrlEdit->titleWasAutoResolved();
|
$this->titleWasAutoResolved = $shortUrlEdit->titleWasAutoResolved();
|
||||||
}
|
}
|
||||||
|
if ($shortUrlEdit->forwardQueryWasProvided()) {
|
||||||
|
$this->forwardQuery = $shortUrlEdit->forwardQuery();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\ErrorHandler;
|
||||||
|
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Message\UriInterface;
|
||||||
use Psr\Http\Server\MiddlewareInterface;
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
use Psr\Http\Server\RequestHandlerInterface;
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolverInterface;
|
use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolverInterface;
|
||||||
|
@ -26,17 +27,25 @@ class NotFoundRedirectHandler implements MiddlewareInterface
|
||||||
{
|
{
|
||||||
/** @var NotFoundType $notFoundType */
|
/** @var NotFoundType $notFoundType */
|
||||||
$notFoundType = $request->getAttribute(NotFoundType::class);
|
$notFoundType = $request->getAttribute(NotFoundType::class);
|
||||||
$authority = $request->getUri()->getAuthority();
|
$currentUri = $request->getUri();
|
||||||
$domainSpecificRedirect = $this->resolveDomainSpecificRedirect($authority, $notFoundType);
|
$domainSpecificRedirect = $this->resolveDomainSpecificRedirect($currentUri, $notFoundType);
|
||||||
|
|
||||||
return $domainSpecificRedirect
|
return $domainSpecificRedirect
|
||||||
?? $this->redirectResolver->resolveRedirectResponse($notFoundType, $this->redirectOptions)
|
// If we did not find domain-specific redirects for current domain, we try to fall back to default redirects
|
||||||
|
?? $this->redirectResolver->resolveRedirectResponse($notFoundType, $this->redirectOptions, $currentUri)
|
||||||
|
// Ultimately, we just call next handler if no domain-specific redirects or default redirects were found
|
||||||
?? $handler->handle($request);
|
?? $handler->handle($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveDomainSpecificRedirect(string $authority, NotFoundType $notFoundType): ?ResponseInterface
|
private function resolveDomainSpecificRedirect(
|
||||||
{
|
UriInterface $currentUri,
|
||||||
$domain = $this->domainService->findByAuthority($authority);
|
NotFoundType $notFoundType,
|
||||||
return $domain === null ? null : $this->redirectResolver->resolveRedirectResponse($notFoundType, $domain);
|
): ?ResponseInterface {
|
||||||
|
$domain = $this->domainService->findByAuthority($currentUri->getAuthority());
|
||||||
|
if ($domain === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectResolver->resolveRedirectResponse($notFoundType, $domain, $currentUri);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,7 @@ class LocateVisit
|
||||||
private EntityManagerInterface $em,
|
private EntityManagerInterface $em,
|
||||||
private LoggerInterface $logger,
|
private LoggerInterface $logger,
|
||||||
private DbUpdaterInterface $dbUpdater,
|
private DbUpdaterInterface $dbUpdater,
|
||||||
private EventDispatcherInterface $eventDispatcher
|
private EventDispatcherInterface $eventDispatcher,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,7 @@ class NotifyVisitToMercure
|
||||||
private HubInterface $hub,
|
private HubInterface $hub,
|
||||||
private MercureUpdatesGeneratorInterface $updatesGenerator,
|
private MercureUpdatesGeneratorInterface $updatesGenerator,
|
||||||
private EntityManagerInterface $em,
|
private EntityManagerInterface $em,
|
||||||
private LoggerInterface $logger
|
private LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Fig\Http\Message\RequestMethodInterface;
|
use Fig\Http\Message\RequestMethodInterface;
|
||||||
use GuzzleHttp\ClientInterface;
|
use GuzzleHttp\ClientInterface;
|
||||||
|
@ -17,10 +16,10 @@ use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
||||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||||
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
|
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
|
||||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||||
|
use Shlinkio\Shlink\Core\Options\WebhookOptions;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
use function Functional\map;
|
use function Functional\map;
|
||||||
use function Functional\partial_left;
|
|
||||||
|
|
||||||
class NotifyVisitToWebHooks
|
class NotifyVisitToWebHooks
|
||||||
{
|
{
|
||||||
|
@ -28,16 +27,15 @@ class NotifyVisitToWebHooks
|
||||||
private ClientInterface $httpClient,
|
private ClientInterface $httpClient,
|
||||||
private EntityManagerInterface $em,
|
private EntityManagerInterface $em,
|
||||||
private LoggerInterface $logger,
|
private LoggerInterface $logger,
|
||||||
/** @var string[] */
|
private WebhookOptions $webhookOptions,
|
||||||
private array $webhooks,
|
|
||||||
private DataTransformerInterface $transformer,
|
private DataTransformerInterface $transformer,
|
||||||
private AppOptions $appOptions
|
private AppOptions $appOptions,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __invoke(VisitLocated $shortUrlLocated): void
|
public function __invoke(VisitLocated $shortUrlLocated): void
|
||||||
{
|
{
|
||||||
if (empty($this->webhooks)) {
|
if (! $this->webhookOptions->hasWebhooks()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,6 +50,10 @@ class NotifyVisitToWebHooks
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($visit->isOrphan() && ! $this->webhookOptions->notifyOrphanVisits()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$requestOptions = $this->buildRequestOptions($visit);
|
$requestOptions = $this->buildRequestOptions($visit);
|
||||||
$requestPromises = $this->performRequests($requestOptions, $visitId);
|
$requestPromises = $this->performRequests($requestOptions, $visitId);
|
||||||
|
|
||||||
|
@ -61,15 +63,16 @@ class NotifyVisitToWebHooks
|
||||||
|
|
||||||
private function buildRequestOptions(Visit $visit): array
|
private function buildRequestOptions(Visit $visit): array
|
||||||
{
|
{
|
||||||
|
$payload = ['visit' => $visit->jsonSerialize()];
|
||||||
|
$shortUrl = $visit->getShortUrl();
|
||||||
|
if ($shortUrl !== null) {
|
||||||
|
$payload['shortUrl'] = $this->transformer->transform($shortUrl);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
RequestOptions::TIMEOUT => 10,
|
RequestOptions::TIMEOUT => 10,
|
||||||
RequestOptions::HEADERS => [
|
RequestOptions::JSON => $payload,
|
||||||
'User-Agent' => (string) $this->appOptions,
|
RequestOptions::HEADERS => ['User-Agent' => $this->appOptions->__toString()],
|
||||||
],
|
|
||||||
RequestOptions::JSON => [
|
|
||||||
'shortUrl' => $this->transformer->transform($visit->getShortUrl()),
|
|
||||||
'visit' => $visit->jsonSerialize(),
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,13 +81,11 @@ class NotifyVisitToWebHooks
|
||||||
*/
|
*/
|
||||||
private function performRequests(array $requestOptions, string $visitId): array
|
private function performRequests(array $requestOptions, string $visitId): array
|
||||||
{
|
{
|
||||||
$logWebhookFailure = Closure::fromCallable([$this, 'logWebhookFailure']);
|
|
||||||
|
|
||||||
return map(
|
return map(
|
||||||
$this->webhooks,
|
$this->webhookOptions->webhooks(),
|
||||||
fn (string $webhook): PromiseInterface => $this->httpClient
|
fn (string $webhook): PromiseInterface => $this->httpClient
|
||||||
->requestAsync(RequestMethodInterface::METHOD_POST, $webhook, $requestOptions)
|
->requestAsync(RequestMethodInterface::METHOD_POST, $webhook, $requestOptions)
|
||||||
->otherwise(partial_left($logWebhookFailure, $webhook, $visitId)),
|
->otherwise(fn (Throwable $e) => $this->logWebhookFailure($webhook, $visitId, $e)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
|
||||||
private EntityManagerInterface $em,
|
private EntityManagerInterface $em,
|
||||||
private ShortUrlRelationResolverInterface $relationResolver,
|
private ShortUrlRelationResolverInterface $relationResolver,
|
||||||
private ShortCodeHelperInterface $shortCodeHelper,
|
private ShortCodeHelperInterface $shortCodeHelper,
|
||||||
private DoctrineBatchHelperInterface $batchHelper
|
private DoctrineBatchHelperInterface $batchHelper,
|
||||||
) {
|
) {
|
||||||
$this->shortUrlRepo = $this->em->getRepository(ShortUrl::class);
|
$this->shortUrlRepo = $this->em->getRepository(ShortUrl::class);
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private DataTransformerInterface $shortUrlTransformer,
|
private DataTransformerInterface $shortUrlTransformer,
|
||||||
private DataTransformerInterface $orphanVisitTransformer
|
private DataTransformerInterface $orphanVisitTransformer,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -32,6 +32,8 @@ final class ShortUrlEdit implements TitleResolutionModelInterface
|
||||||
private ?bool $validateUrl = null;
|
private ?bool $validateUrl = null;
|
||||||
private bool $crawlablePropWasProvided = false;
|
private bool $crawlablePropWasProvided = false;
|
||||||
private bool $crawlable = false;
|
private bool $crawlable = false;
|
||||||
|
private bool $forwardQueryPropWasProvided = false;
|
||||||
|
private bool $forwardQuery = true;
|
||||||
|
|
||||||
private function __construct()
|
private function __construct()
|
||||||
{
|
{
|
||||||
|
@ -64,6 +66,7 @@ final class ShortUrlEdit implements TitleResolutionModelInterface
|
||||||
$this->tagsPropWasProvided = array_key_exists(ShortUrlInputFilter::TAGS, $data);
|
$this->tagsPropWasProvided = array_key_exists(ShortUrlInputFilter::TAGS, $data);
|
||||||
$this->titlePropWasProvided = array_key_exists(ShortUrlInputFilter::TITLE, $data);
|
$this->titlePropWasProvided = array_key_exists(ShortUrlInputFilter::TITLE, $data);
|
||||||
$this->crawlablePropWasProvided = array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data);
|
$this->crawlablePropWasProvided = array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data);
|
||||||
|
$this->forwardQueryPropWasProvided = array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data);
|
||||||
|
|
||||||
$this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL);
|
$this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL);
|
||||||
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
|
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
|
||||||
|
@ -73,6 +76,7 @@ final class ShortUrlEdit implements TitleResolutionModelInterface
|
||||||
$this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS);
|
$this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS);
|
||||||
$this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE);
|
$this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE);
|
||||||
$this->crawlable = $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE);
|
$this->crawlable = $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE);
|
||||||
|
$this->forwardQuery = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function longUrl(): ?string
|
public function longUrl(): ?string
|
||||||
|
@ -176,4 +180,14 @@ final class ShortUrlEdit implements TitleResolutionModelInterface
|
||||||
{
|
{
|
||||||
return $this->crawlablePropWasProvided;
|
return $this->crawlablePropWasProvided;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function forwardQuery(): bool
|
||||||
|
{
|
||||||
|
return $this->forwardQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forwardQueryWasProvided(): bool
|
||||||
|
{
|
||||||
|
return $this->forwardQueryPropWasProvided;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
|
||||||
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
|
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
|
||||||
use function Shlinkio\Shlink\Core\parseDateField;
|
use function Shlinkio\Shlink\Core\parseDateField;
|
||||||
|
|
||||||
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
|
||||||
|
|
||||||
final class ShortUrlMeta implements TitleResolutionModelInterface
|
final class ShortUrlMeta implements TitleResolutionModelInterface
|
||||||
{
|
{
|
||||||
|
@ -32,6 +32,7 @@ final class ShortUrlMeta implements TitleResolutionModelInterface
|
||||||
private ?string $title = null;
|
private ?string $title = null;
|
||||||
private bool $titleWasAutoResolved = false;
|
private bool $titleWasAutoResolved = false;
|
||||||
private bool $crawlable = false;
|
private bool $crawlable = false;
|
||||||
|
private bool $forwardQuery = true;
|
||||||
|
|
||||||
private function __construct()
|
private function __construct()
|
||||||
{
|
{
|
||||||
|
@ -82,6 +83,7 @@ final class ShortUrlMeta implements TitleResolutionModelInterface
|
||||||
$this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS);
|
$this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS);
|
||||||
$this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE);
|
$this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE);
|
||||||
$this->crawlable = $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE);
|
$this->crawlable = $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE);
|
||||||
|
$this->forwardQuery = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getLongUrl(): string
|
public function getLongUrl(): string
|
||||||
|
@ -195,4 +197,9 @@ final class ShortUrlMeta implements TitleResolutionModelInterface
|
||||||
{
|
{
|
||||||
return $this->crawlable;
|
return $this->crawlable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function forwardQuery(): bool
|
||||||
|
{
|
||||||
|
return $this->forwardQuery;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||||
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
|
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
|
||||||
|
|
||||||
|
use function Shlinkio\Shlink\Common\buildDateRange;
|
||||||
use function Shlinkio\Shlink\Core\parseDateField;
|
use function Shlinkio\Shlink\Core\parseDateField;
|
||||||
|
|
||||||
final class ShortUrlsParams
|
final class ShortUrlsParams
|
||||||
|
@ -54,7 +55,7 @@ final class ShortUrlsParams
|
||||||
$this->page = (int) ($inputFilter->getValue(ShortUrlsParamsInputFilter::PAGE) ?? 1);
|
$this->page = (int) ($inputFilter->getValue(ShortUrlsParamsInputFilter::PAGE) ?? 1);
|
||||||
$this->searchTerm = $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM);
|
$this->searchTerm = $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM);
|
||||||
$this->tags = (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS);
|
$this->tags = (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS);
|
||||||
$this->dateRange = new DateRange(
|
$this->dateRange = buildDateRange(
|
||||||
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
|
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
|
||||||
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
|
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
|
||||||
);
|
);
|
||||||
|
|
|
@ -21,9 +21,9 @@ final class VisitsParams
|
||||||
?DateRange $dateRange = null,
|
?DateRange $dateRange = null,
|
||||||
int $page = self::FIRST_PAGE,
|
int $page = self::FIRST_PAGE,
|
||||||
?int $itemsPerPage = null,
|
?int $itemsPerPage = null,
|
||||||
private bool $excludeBots = false
|
private bool $excludeBots = false,
|
||||||
) {
|
) {
|
||||||
$this->dateRange = $dateRange ?? new DateRange();
|
$this->dateRange = $dateRange ?? DateRange::emptyInstance();
|
||||||
$this->page = $this->determinePage($page);
|
$this->page = $this->determinePage($page);
|
||||||
$this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage);
|
$this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage);
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Options;
|
||||||
|
|
||||||
use Laminas\Stdlib\AbstractOptions;
|
use Laminas\Stdlib\AbstractOptions;
|
||||||
|
|
||||||
use const Shlinkio\Shlink\Core\DEFAULT_DELETE_SHORT_URL_THRESHOLD;
|
use const Shlinkio\Shlink\DEFAULT_DELETE_SHORT_URL_THRESHOLD;
|
||||||
|
|
||||||
class DeleteShortUrlsOptions extends AbstractOptions
|
class DeleteShortUrlsOptions extends AbstractOptions
|
||||||
{
|
{
|
||||||
|
|
60
module/Core/src/Options/QrCodeOptions.php
Normal file
60
module/Core/src/Options/QrCodeOptions.php
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\Options;
|
||||||
|
|
||||||
|
use Laminas\Stdlib\AbstractOptions;
|
||||||
|
|
||||||
|
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
|
||||||
|
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
|
||||||
|
use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN;
|
||||||
|
use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE;
|
||||||
|
|
||||||
|
class QrCodeOptions extends AbstractOptions
|
||||||
|
{
|
||||||
|
private int $size = DEFAULT_QR_CODE_SIZE;
|
||||||
|
private int $margin = DEFAULT_QR_CODE_MARGIN;
|
||||||
|
private string $format = DEFAULT_QR_CODE_FORMAT;
|
||||||
|
private string $errorCorrection = DEFAULT_QR_CODE_ERROR_CORRECTION;
|
||||||
|
|
||||||
|
public function size(): int
|
||||||
|
{
|
||||||
|
return $this->size;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setSize(int $size): void
|
||||||
|
{
|
||||||
|
$this->size = $size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function margin(): int
|
||||||
|
{
|
||||||
|
return $this->margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setMargin(int $margin): void
|
||||||
|
{
|
||||||
|
$this->margin = $margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function format(): string
|
||||||
|
{
|
||||||
|
return $this->format;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setFormat(string $format): void
|
||||||
|
{
|
||||||
|
$this->format = $format;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function errorCorrection(): string
|
||||||
|
{
|
||||||
|
return $this->errorCorrection;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setErrorCorrection(string $errorCorrection): void
|
||||||
|
{
|
||||||
|
$this->errorCorrection = $errorCorrection;
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,10 @@ namespace Shlinkio\Shlink\Core\Options;
|
||||||
|
|
||||||
use Laminas\Stdlib\AbstractOptions;
|
use Laminas\Stdlib\AbstractOptions;
|
||||||
|
|
||||||
|
use function array_key_exists;
|
||||||
|
use function explode;
|
||||||
|
use function is_array;
|
||||||
|
|
||||||
class TrackingOptions extends AbstractOptions
|
class TrackingOptions extends AbstractOptions
|
||||||
{
|
{
|
||||||
private bool $anonymizeRemoteAddr = true;
|
private bool $anonymizeRemoteAddr = true;
|
||||||
|
@ -15,6 +19,7 @@ class TrackingOptions extends AbstractOptions
|
||||||
private bool $disableIpTracking = false;
|
private bool $disableIpTracking = false;
|
||||||
private bool $disableReferrerTracking = false;
|
private bool $disableReferrerTracking = false;
|
||||||
private bool $disableUaTracking = false;
|
private bool $disableUaTracking = false;
|
||||||
|
private array $disableTrackingFrom = [];
|
||||||
|
|
||||||
public function anonymizeRemoteAddr(): bool
|
public function anonymizeRemoteAddr(): bool
|
||||||
{
|
{
|
||||||
|
@ -41,6 +46,11 @@ class TrackingOptions extends AbstractOptions
|
||||||
return $this->disableTrackParam;
|
return $this->disableTrackParam;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function queryHasDisableTrackParam(array $query): bool
|
||||||
|
{
|
||||||
|
return $this->disableTrackParam !== null && array_key_exists($this->disableTrackParam, $query);
|
||||||
|
}
|
||||||
|
|
||||||
protected function setDisableTrackParam(?string $disableTrackParam): void
|
protected function setDisableTrackParam(?string $disableTrackParam): void
|
||||||
{
|
{
|
||||||
$this->disableTrackParam = $disableTrackParam;
|
$this->disableTrackParam = $disableTrackParam;
|
||||||
|
@ -85,4 +95,23 @@ class TrackingOptions extends AbstractOptions
|
||||||
{
|
{
|
||||||
$this->disableUaTracking = $disableUaTracking;
|
$this->disableUaTracking = $disableUaTracking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function disableTrackingFrom(): array
|
||||||
|
{
|
||||||
|
return $this->disableTrackingFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasDisableTrackingFrom(): bool
|
||||||
|
{
|
||||||
|
return ! empty($this->disableTrackingFrom);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setDisableTrackingFrom(string|array|null $disableTrackingFrom): void
|
||||||
|
{
|
||||||
|
if (is_array($disableTrackingFrom)) {
|
||||||
|
$this->disableTrackingFrom = $disableTrackingFrom;
|
||||||
|
} else {
|
||||||
|
$this->disableTrackingFrom = $disableTrackingFrom === null ? [] : explode(',', $disableTrackingFrom);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,8 +8,8 @@ use Laminas\Stdlib\AbstractOptions;
|
||||||
|
|
||||||
use function Functional\contains;
|
use function Functional\contains;
|
||||||
|
|
||||||
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_CACHE_LIFETIME;
|
use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||||
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_STATUS_CODE;
|
use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
|
||||||
|
|
||||||
class UrlShortenerOptions extends AbstractOptions
|
class UrlShortenerOptions extends AbstractOptions
|
||||||
{
|
{
|
||||||
|
|
40
module/Core/src/Options/WebhookOptions.php
Normal file
40
module/Core/src/Options/WebhookOptions.php
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\Options;
|
||||||
|
|
||||||
|
use Laminas\Stdlib\AbstractOptions;
|
||||||
|
|
||||||
|
class WebhookOptions extends AbstractOptions
|
||||||
|
{
|
||||||
|
protected $__strictMode__ = false; // phpcs:ignore
|
||||||
|
|
||||||
|
private array $visitsWebhooks = [];
|
||||||
|
private bool $notifyOrphanVisitsToWebhooks = false;
|
||||||
|
|
||||||
|
public function webhooks(): array
|
||||||
|
{
|
||||||
|
return $this->visitsWebhooks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasWebhooks(): bool
|
||||||
|
{
|
||||||
|
return ! empty($this->visitsWebhooks);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setVisitsWebhooks(array $visitsWebhooks): void
|
||||||
|
{
|
||||||
|
$this->visitsWebhooks = $visitsWebhooks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notifyOrphanVisits(): bool
|
||||||
|
{
|
||||||
|
return $this->notifyOrphanVisitsToWebhooks;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setNotifyOrphanVisitsToWebhooks(bool $notifyOrphanVisitsToWebhooks): void
|
||||||
|
{
|
||||||
|
$this->notifyOrphanVisitsToWebhooks = $notifyOrphanVisitsToWebhooks;
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,7 +14,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ShortUrlRepositoryInterface $repository,
|
private ShortUrlRepositoryInterface $repository,
|
||||||
private ShortUrlsParams $params,
|
private ShortUrlsParams $params,
|
||||||
private ?ApiKey $apiKey
|
private ?ApiKey $apiKey,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
|
||||||
private VisitRepositoryInterface $visitRepository,
|
private VisitRepositoryInterface $visitRepository,
|
||||||
private string $tag,
|
private string $tag,
|
||||||
private VisitsParams $params,
|
private VisitsParams $params,
|
||||||
private ?ApiKey $apiKey
|
private ?ApiKey $apiKey,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||||
private VisitRepositoryInterface $visitRepository,
|
private VisitRepositoryInterface $visitRepository,
|
||||||
private ShortUrlIdentifier $identifier,
|
private ShortUrlIdentifier $identifier,
|
||||||
private VisitsParams $params,
|
private VisitsParams $params,
|
||||||
private ?Specification $spec
|
private ?Specification $spec,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -105,13 +105,13 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
|
||||||
$qb->from(ShortUrl::class, 's')
|
$qb->from(ShortUrl::class, 's')
|
||||||
->where('1=1');
|
->where('1=1');
|
||||||
|
|
||||||
if ($dateRange?->getStartDate() !== null) {
|
if ($dateRange?->startDate() !== null) {
|
||||||
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
|
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
|
||||||
$qb->setParameter('startDate', $dateRange->getStartDate(), ChronosDateTimeType::CHRONOS_DATETIME);
|
$qb->setParameter('startDate', $dateRange->startDate(), ChronosDateTimeType::CHRONOS_DATETIME);
|
||||||
}
|
}
|
||||||
if ($dateRange?->getEndDate() !== null) {
|
if ($dateRange?->endDate() !== null) {
|
||||||
$qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate'));
|
$qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate'));
|
||||||
$qb->setParameter('endDate', $dateRange->getEndDate(), ChronosDateTimeType::CHRONOS_DATETIME);
|
$qb->setParameter('endDate', $dateRange->endDate(), ChronosDateTimeType::CHRONOS_DATETIME);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply search term to every searchable field if not empty
|
// Apply search term to every searchable field if not empty
|
||||||
|
|
|
@ -70,15 +70,17 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
|
||||||
$qb = (clone $originalQueryBuilder)->andWhere($qb->expr()->gt('v.id', $lastId));
|
$qb = (clone $originalQueryBuilder)->andWhere($qb->expr()->gt('v.id', $lastId));
|
||||||
$iterator = $qb->getQuery()->toIterable();
|
$iterator = $qb->getQuery()->toIterable();
|
||||||
$resultsFound = false;
|
$resultsFound = false;
|
||||||
|
/** @var Visit|null $lastProcessedVisit */
|
||||||
|
$lastProcessedVisit = null;
|
||||||
|
|
||||||
foreach ($iterator as $key => $visit) {
|
foreach ($iterator as $key => $visit) {
|
||||||
$resultsFound = true;
|
$resultsFound = true;
|
||||||
|
$lastProcessedVisit = $visit;
|
||||||
yield $key => $visit;
|
yield $key => $visit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// As the query is ordered by ID, we can take the last one every time in order to exclude the whole list
|
// As the query is ordered by ID, we can take the last one every time in order to exclude the whole list
|
||||||
/** @var Visit|null $visit */
|
$lastId = $lastProcessedVisit?->getId() ?? $lastId;
|
||||||
$lastId = $visit?->getId() ?? $lastId;
|
|
||||||
} while ($resultsFound);
|
} while ($resultsFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -187,11 +189,11 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
|
||||||
|
|
||||||
private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void
|
private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void
|
||||||
{
|
{
|
||||||
if ($dateRange?->getStartDate() !== null) {
|
if ($dateRange?->startDate() !== null) {
|
||||||
$qb->andWhere($qb->expr()->gte('v.date', '\'' . $dateRange->getStartDate()->toDateTimeString() . '\''));
|
$qb->andWhere($qb->expr()->gte('v.date', '\'' . $dateRange->startDate()->toDateTimeString() . '\''));
|
||||||
}
|
}
|
||||||
if ($dateRange?->getEndDate() !== null) {
|
if ($dateRange?->endDate() !== null) {
|
||||||
$qb->andWhere($qb->expr()->lte('v.date', '\'' . $dateRange->getEndDate()->toDateTimeString() . '\''));
|
$qb->andWhere($qb->expr()->lte('v.date', '\'' . $dateRange->endDate()->toDateTimeString() . '\''));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private EntityManagerInterface $em,
|
private EntityManagerInterface $em,
|
||||||
private DeleteShortUrlsOptions $deleteShortUrlsOptions,
|
private DeleteShortUrlsOptions $deleteShortUrlsOptions,
|
||||||
private ShortUrlResolverInterface $urlResolver
|
private ShortUrlResolverInterface $urlResolver,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ class ShortUrlService implements ShortUrlServiceInterface
|
||||||
private ORM\EntityManagerInterface $em,
|
private ORM\EntityManagerInterface $em,
|
||||||
private ShortUrlResolverInterface $urlResolver,
|
private ShortUrlResolverInterface $urlResolver,
|
||||||
private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
|
private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
|
||||||
private ShortUrlRelationResolverInterface $relationResolver
|
private ShortUrlRelationResolverInterface $relationResolver,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ class UrlShortener implements UrlShortenerInterface
|
||||||
private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
|
private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
|
||||||
private EntityManagerInterface $em,
|
private EntityManagerInterface $em,
|
||||||
private ShortUrlRelationResolverInterface $relationResolver,
|
private ShortUrlRelationResolverInterface $relationResolver,
|
||||||
private ShortCodeHelperInterface $shortCodeHelper
|
private ShortCodeHelperInterface $shortCodeHelper,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,9 +21,10 @@ class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface
|
||||||
public function buildShortUrlRedirect(ShortUrl $shortUrl, array $currentQuery, ?string $extraPath = null): string
|
public function buildShortUrlRedirect(ShortUrl $shortUrl, array $currentQuery, ?string $extraPath = null): string
|
||||||
{
|
{
|
||||||
$uri = Uri::createFromString($shortUrl->getLongUrl());
|
$uri = Uri::createFromString($shortUrl->getLongUrl());
|
||||||
|
$shouldForwardQuery = $shortUrl->forwardQuery();
|
||||||
|
|
||||||
return $uri
|
return $uri
|
||||||
->withQuery($this->resolveQuery($uri, $currentQuery))
|
->withQuery($shouldForwardQuery ? $this->resolveQuery($uri, $currentQuery) : $uri->getQuery())
|
||||||
->withPath($this->resolvePath($uri, $extraPath))
|
->withPath($this->resolvePath($uri, $extraPath))
|
||||||
->__toString();
|
->__toString();
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,7 @@ class ShortUrlDataTransformer implements DataTransformerInterface
|
||||||
'domain' => $shortUrl->getDomain(),
|
'domain' => $shortUrl->getDomain(),
|
||||||
'title' => $shortUrl->title(),
|
'title' => $shortUrl->title(),
|
||||||
'crawlable' => $shortUrl->crawlable(),
|
'crawlable' => $shortUrl->crawlable(),
|
||||||
|
'forwardQuery' => $shortUrl->forwardQuery(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,12 +20,12 @@ class InDateRange extends BaseSpecification
|
||||||
{
|
{
|
||||||
$criteria = [];
|
$criteria = [];
|
||||||
|
|
||||||
if ($this->dateRange?->getStartDate() !== null) {
|
if ($this->dateRange?->startDate() !== null) {
|
||||||
$criteria[] = Spec::gte($this->field, $this->dateRange->getStartDate()->toDateTimeString());
|
$criteria[] = Spec::gte($this->field, $this->dateRange->startDate()->toDateTimeString());
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->dateRange?->getEndDate() !== null) {
|
if ($this->dateRange?->endDate() !== null) {
|
||||||
$criteria[] = Spec::lte($this->field, $this->dateRange->getEndDate()->toDateTimeString());
|
$criteria[] = Spec::lte($this->field, $this->dateRange->endDate()->toDateTimeString());
|
||||||
}
|
}
|
||||||
|
|
||||||
return Spec::andX(...$criteria);
|
return Spec::andX(...$criteria);
|
||||||
|
|
|
@ -15,7 +15,7 @@ use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||||
use function preg_match;
|
use function preg_match;
|
||||||
use function trim;
|
use function trim;
|
||||||
|
|
||||||
use const Shlinkio\Shlink\Core\TITLE_TAG_VALUE;
|
use const Shlinkio\Shlink\TITLE_TAG_VALUE;
|
||||||
|
|
||||||
class UrlValidator implements UrlValidatorInterface, RequestMethodInterface
|
class UrlValidator implements UrlValidatorInterface, RequestMethodInterface
|
||||||
{
|
{
|
||||||
|
@ -32,7 +32,7 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface
|
||||||
*/
|
*/
|
||||||
public function validateUrl(string $url, ?bool $doValidate): void
|
public function validateUrl(string $url, ?bool $doValidate): void
|
||||||
{
|
{
|
||||||
// If the URL validation is not enabled or it was explicitly set to not validate, skip check
|
// If the URL validation is not enabled, or it was explicitly set to not validate, skip check
|
||||||
$doValidate = $doValidate ?? $this->options->isUrlValidationEnabled();
|
$doValidate = $doValidate ?? $this->options->isUrlValidationEnabled();
|
||||||
if (! $doValidate) {
|
if (! $doValidate) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -13,8 +13,8 @@ use Shlinkio\Shlink\Common\Validation;
|
||||||
use Shlinkio\Shlink\Core\Util\CocurSymfonySluggerBridge;
|
use Shlinkio\Shlink\Core\Util\CocurSymfonySluggerBridge;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
use const Shlinkio\Shlink\Core\CUSTOM_SLUGS_REGEXP;
|
use const Shlinkio\Shlink\CUSTOM_SLUGS_REGEXP;
|
||||||
use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH;
|
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
|
||||||
|
|
||||||
class ShortUrlInputFilter extends InputFilter
|
class ShortUrlInputFilter extends InputFilter
|
||||||
{
|
{
|
||||||
|
@ -33,6 +33,7 @@ class ShortUrlInputFilter extends InputFilter
|
||||||
public const TAGS = 'tags';
|
public const TAGS = 'tags';
|
||||||
public const TITLE = 'title';
|
public const TITLE = 'title';
|
||||||
public const CRAWLABLE = 'crawlable';
|
public const CRAWLABLE = 'crawlable';
|
||||||
|
public const FORWARD_QUERY = 'forwardQuery';
|
||||||
|
|
||||||
private function __construct(array $data, bool $requireLongUrl)
|
private function __construct(array $data, bool $requireLongUrl)
|
||||||
{
|
{
|
||||||
|
@ -89,9 +90,10 @@ class ShortUrlInputFilter extends InputFilter
|
||||||
|
|
||||||
$this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false));
|
$this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false));
|
||||||
|
|
||||||
// This cannot be defined as a boolean input because it can actually have 3 values, true, false and null.
|
// These cannot be defined as a boolean inputs, because they can actually have 3 values: true, false and null.
|
||||||
// Defining it as boolean will make null fall back to false, which is not the desired behavior.
|
// Defining them as boolean will make null fall back to false, which is not the desired behavior.
|
||||||
$this->add($this->createInput(self::VALIDATE_URL, false));
|
$this->add($this->createInput(self::VALIDATE_URL, false));
|
||||||
|
$this->add($this->createInput(self::FORWARD_QUERY, false));
|
||||||
|
|
||||||
$domain = $this->createInput(self::DOMAIN, false);
|
$domain = $this->createInput(self::DOMAIN, false);
|
||||||
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator());
|
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator());
|
||||||
|
|
|
@ -12,7 +12,7 @@ class VisitsCountFiltering
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ?DateRange $dateRange = null,
|
private ?DateRange $dateRange = null,
|
||||||
private bool $excludeBots = false,
|
private bool $excludeBots = false,
|
||||||
private ?Specification $spec = null
|
private ?Specification $spec = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ final class VisitsListFiltering extends VisitsCountFiltering
|
||||||
bool $excludeBots = false,
|
bool $excludeBots = false,
|
||||||
?Specification $spec = null,
|
?Specification $spec = null,
|
||||||
private ?int $limit = null,
|
private ?int $limit = null,
|
||||||
private ?int $offset = null
|
private ?int $offset = null,
|
||||||
) {
|
) {
|
||||||
parent::__construct($dateRange, $excludeBots, $spec);
|
parent::__construct($dateRange, $excludeBots, $spec);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,14 +5,21 @@ declare(strict_types=1);
|
||||||
namespace Shlinkio\Shlink\Core\Visit;
|
namespace Shlinkio\Shlink\Core\Visit;
|
||||||
|
|
||||||
use Fig\Http\Message\RequestMethodInterface;
|
use Fig\Http\Message\RequestMethodInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
|
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
|
||||||
|
use PhpIP\IP;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||||
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
||||||
|
|
||||||
use function array_key_exists;
|
use function explode;
|
||||||
|
use function Functional\map;
|
||||||
|
use function Functional\some;
|
||||||
|
use function implode;
|
||||||
|
use function str_contains;
|
||||||
|
|
||||||
class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
|
class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
|
||||||
{
|
{
|
||||||
|
@ -37,24 +44,63 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
|
||||||
$notFoundType = $request->getAttribute(NotFoundType::class);
|
$notFoundType = $request->getAttribute(NotFoundType::class);
|
||||||
$visitor = Visitor::fromRequest($request);
|
$visitor = Visitor::fromRequest($request);
|
||||||
|
|
||||||
if ($notFoundType?->isBaseUrl()) {
|
match (true) { // @phpstan-ignore-line
|
||||||
$this->visitsTracker->trackBaseUrlVisit($visitor);
|
$notFoundType?->isBaseUrl() => $this->visitsTracker->trackBaseUrlVisit($visitor),
|
||||||
} elseif ($notFoundType?->isRegularNotFound()) {
|
$notFoundType?->isRegularNotFound() => $this->visitsTracker->trackRegularNotFoundVisit($visitor),
|
||||||
$this->visitsTracker->trackRegularNotFoundVisit($visitor);
|
$notFoundType?->isInvalidShortUrl() => $this->visitsTracker->trackInvalidShortUrlVisit($visitor),
|
||||||
} elseif ($notFoundType?->isInvalidShortUrl()) {
|
};
|
||||||
$this->visitsTracker->trackInvalidShortUrlVisit($visitor);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function shouldTrackRequest(ServerRequestInterface $request): bool
|
private function shouldTrackRequest(ServerRequestInterface $request): bool
|
||||||
{
|
{
|
||||||
$query = $request->getQueryParams();
|
|
||||||
$disableTrackParam = $this->trackingOptions->getDisableTrackParam();
|
|
||||||
$forwardedMethod = $request->getAttribute(ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE);
|
$forwardedMethod = $request->getAttribute(ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE);
|
||||||
if ($forwardedMethod === self::METHOD_HEAD) {
|
if ($forwardedMethod === self::METHOD_HEAD) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $disableTrackParam === null || ! array_key_exists($disableTrackParam, $query);
|
$remoteAddr = $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR);
|
||||||
|
if ($this->shouldDisableTrackingFromAddress($remoteAddr)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = $request->getQueryParams();
|
||||||
|
return ! $this->trackingOptions->queryHasDisableTrackParam($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldDisableTrackingFromAddress(?string $remoteAddr): bool
|
||||||
|
{
|
||||||
|
if ($remoteAddr === null || ! $this->trackingOptions->hasDisableTrackingFrom()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ip = IP::create($remoteAddr);
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remoteAddrParts = explode('.', $remoteAddr);
|
||||||
|
$disableTrackingFrom = $this->trackingOptions->disableTrackingFrom();
|
||||||
|
|
||||||
|
return some($disableTrackingFrom, function (string $value) use ($ip, $remoteAddrParts): bool {
|
||||||
|
try {
|
||||||
|
return match (true) {
|
||||||
|
str_contains($value, '*') => $ip->matches($this->parseValueWithWildcards($value, $remoteAddrParts)),
|
||||||
|
str_contains($value, '/') => $ip->isIn($value),
|
||||||
|
default => $ip->matches($value),
|
||||||
|
};
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseValueWithWildcards(string $value, array $remoteAddrParts): string
|
||||||
|
{
|
||||||
|
// Replace wildcard parts with the corresponding ones from the remote address
|
||||||
|
return implode('.', map(
|
||||||
|
explode('.', $value),
|
||||||
|
fn (string $part, int $index) => $part === '*' ? $remoteAddrParts[$index] : $part,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@ class VisitsTracker implements VisitsTrackerInterface
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ORM\EntityManagerInterface $em,
|
private ORM\EntityManagerInterface $em,
|
||||||
private EventDispatcherInterface $eventDispatcher,
|
private EventDispatcherInterface $eventDispatcher,
|
||||||
private TrackingOptions $options
|
private TrackingOptions $options,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -133,16 +133,16 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
||||||
self::assertCount(3, $result);
|
self::assertCount(3, $result);
|
||||||
self::assertSame($bar, $result[0]);
|
self::assertSame($bar, $result[0]);
|
||||||
|
|
||||||
$result = $this->repo->findList(null, null, null, [], null, new DateRange(null, Chronos::now()->subDays(2)));
|
$result = $this->repo->findList(null, null, null, [], null, DateRange::withEndDate(Chronos::now()->subDays(2)));
|
||||||
self::assertCount(1, $result);
|
self::assertCount(1, $result);
|
||||||
self::assertEquals(1, $this->repo->countList(null, [], new DateRange(null, Chronos::now()->subDays(2))));
|
self::assertEquals(1, $this->repo->countList(null, [], DateRange::withEndDate(Chronos::now()->subDays(2))));
|
||||||
self::assertSame($foo2, $result[0]);
|
self::assertSame($foo2, $result[0]);
|
||||||
|
|
||||||
self::assertCount(
|
self::assertCount(
|
||||||
2,
|
2,
|
||||||
$this->repo->findList(null, null, null, [], null, new DateRange(Chronos::now()->subDays(2))),
|
$this->repo->findList(null, null, null, [], null, DateRange::withStartDate(Chronos::now()->subDays(2))),
|
||||||
);
|
);
|
||||||
self::assertEquals(2, $this->repo->countList(null, [], new DateRange(Chronos::now()->subDays(2))));
|
self::assertEquals(2, $this->repo->countList(null, [], DateRange::withStartDate(Chronos::now()->subDays(2))));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
|
@ -355,6 +355,8 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
||||||
$this->getEntityManager()->persist($wrongDomainApiKey);
|
$this->getEntityManager()->persist($wrongDomainApiKey);
|
||||||
$rightDomainApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($rightDomain)));
|
$rightDomainApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($rightDomain)));
|
||||||
$this->getEntityManager()->persist($rightDomainApiKey);
|
$this->getEntityManager()->persist($rightDomainApiKey);
|
||||||
|
$adminApiKey = ApiKey::create();
|
||||||
|
$this->getEntityManager()->persist($adminApiKey);
|
||||||
|
|
||||||
$shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
|
$shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
|
||||||
'validSince' => $start,
|
'validSince' => $start,
|
||||||
|
@ -365,6 +367,12 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
||||||
]), $this->relationResolver);
|
]), $this->relationResolver);
|
||||||
$this->getEntityManager()->persist($shortUrl);
|
$this->getEntityManager()->persist($shortUrl);
|
||||||
|
|
||||||
|
$nonDomainShortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
|
||||||
|
'apiKey' => $apiKey,
|
||||||
|
'longUrl' => 'non-domain',
|
||||||
|
]), $this->relationResolver);
|
||||||
|
$this->getEntityManager()->persist($nonDomainShortUrl);
|
||||||
|
|
||||||
$this->getEntityManager()->flush();
|
$this->getEntityManager()->flush();
|
||||||
|
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
|
@ -379,6 +387,12 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
||||||
'longUrl' => 'foo',
|
'longUrl' => 'foo',
|
||||||
'tags' => ['foo', 'bar'],
|
'tags' => ['foo', 'bar'],
|
||||||
])));
|
])));
|
||||||
|
self::assertSame($shortUrl, $this->repo->findOneMatching(ShortUrlMeta::fromRawData([
|
||||||
|
'validSince' => $start,
|
||||||
|
'apiKey' => $adminApiKey,
|
||||||
|
'longUrl' => 'foo',
|
||||||
|
'tags' => ['foo', 'bar'],
|
||||||
|
])));
|
||||||
self::assertNull($this->repo->findOneMatching(ShortUrlMeta::fromRawData([
|
self::assertNull($this->repo->findOneMatching(ShortUrlMeta::fromRawData([
|
||||||
'validSince' => $start,
|
'validSince' => $start,
|
||||||
'apiKey' => $otherApiKey,
|
'apiKey' => $otherApiKey,
|
||||||
|
@ -424,6 +438,27 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
||||||
'tags' => ['foo', 'bar'],
|
'tags' => ['foo', 'bar'],
|
||||||
])),
|
])),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
$nonDomainShortUrl,
|
||||||
|
$this->repo->findOneMatching(ShortUrlMeta::fromRawData([
|
||||||
|
'apiKey' => $apiKey,
|
||||||
|
'longUrl' => 'non-domain',
|
||||||
|
])),
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
$nonDomainShortUrl,
|
||||||
|
$this->repo->findOneMatching(ShortUrlMeta::fromRawData([
|
||||||
|
'apiKey' => $adminApiKey,
|
||||||
|
'longUrl' => 'non-domain',
|
||||||
|
])),
|
||||||
|
);
|
||||||
|
self::assertNull(
|
||||||
|
$this->repo->findOneMatching(ShortUrlMeta::fromRawData([
|
||||||
|
'apiKey' => $otherApiKey,
|
||||||
|
'longUrl' => 'non-domain',
|
||||||
|
])),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
|
|
|
@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver;
|
use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver;
|
||||||
|
use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||||
|
@ -25,6 +26,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
|
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
|
||||||
|
|
||||||
use function Functional\map;
|
use function Functional\map;
|
||||||
|
use function is_string;
|
||||||
use function range;
|
use function range;
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
|
@ -171,6 +173,38 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function findVisitsByShortCodeReturnsProperDataWhenUsingAPiKeys(): void
|
||||||
|
{
|
||||||
|
$adminApiKey = ApiKey::create();
|
||||||
|
$this->getEntityManager()->persist($adminApiKey);
|
||||||
|
|
||||||
|
$restrictedApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()));
|
||||||
|
$this->getEntityManager()->persist($restrictedApiKey);
|
||||||
|
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
|
||||||
|
[$shortCode1] = $this->createShortUrlsAndVisits(true, [], $adminApiKey);
|
||||||
|
[$shortCode2] = $this->createShortUrlsAndVisits('bar.com', [], $restrictedApiKey);
|
||||||
|
|
||||||
|
self::assertNotEmpty($this->repo->findVisitsByShortCode(
|
||||||
|
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode1),
|
||||||
|
new VisitsListFiltering(null, false, $adminApiKey->spec()),
|
||||||
|
));
|
||||||
|
self::assertNotEmpty($this->repo->findVisitsByShortCode(
|
||||||
|
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode2),
|
||||||
|
new VisitsListFiltering(null, false, $adminApiKey->spec()),
|
||||||
|
));
|
||||||
|
self::assertEmpty($this->repo->findVisitsByShortCode(
|
||||||
|
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode1),
|
||||||
|
new VisitsListFiltering(null, false, $restrictedApiKey->spec()),
|
||||||
|
));
|
||||||
|
self::assertNotEmpty($this->repo->findVisitsByShortCode(
|
||||||
|
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode2),
|
||||||
|
new VisitsListFiltering(null, false, $restrictedApiKey->spec()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function findVisitsByTagReturnsProperData(): void
|
public function findVisitsByTagReturnsProperData(): void
|
||||||
{
|
{
|
||||||
|
@ -354,19 +388,26 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createShortUrlsAndVisits(bool $withDomain = true, array $tags = []): array
|
/**
|
||||||
{
|
* @return array{string, string, ShortUrl}
|
||||||
|
*/
|
||||||
|
private function createShortUrlsAndVisits(
|
||||||
|
bool|string $withDomain = true,
|
||||||
|
array $tags = [],
|
||||||
|
?ApiKey $apiKey = null,
|
||||||
|
): array {
|
||||||
$shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
|
$shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
|
||||||
'longUrl' => '',
|
ShortUrlInputFilter::LONG_URL => '',
|
||||||
'tags' => $tags,
|
ShortUrlInputFilter::TAGS => $tags,
|
||||||
|
ShortUrlInputFilter::API_KEY => $apiKey,
|
||||||
]), $this->relationResolver);
|
]), $this->relationResolver);
|
||||||
$domain = 'example.com';
|
$domain = is_string($withDomain) ? $withDomain : 'example.com';
|
||||||
$shortCode = $shortUrl->getShortCode();
|
$shortCode = $shortUrl->getShortCode();
|
||||||
$this->getEntityManager()->persist($shortUrl);
|
$this->getEntityManager()->persist($shortUrl);
|
||||||
|
|
||||||
$this->createVisitsForShortUrl($shortUrl);
|
$this->createVisitsForShortUrl($shortUrl);
|
||||||
|
|
||||||
if ($withDomain) {
|
if ($withDomain !== false) {
|
||||||
$shortUrlWithDomain = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
|
$shortUrlWithDomain = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
|
||||||
'customSlug' => $shortCode,
|
'customSlug' => $shortCode,
|
||||||
'domain' => $domain,
|
'domain' => $domain,
|
||||||
|
|
|
@ -20,6 +20,7 @@ use Shlinkio\Shlink\Core\Action\QrCodeAction;
|
||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
|
use Shlinkio\Shlink\Core\Options\QrCodeOptions;
|
||||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
|
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
|
||||||
|
|
||||||
|
@ -31,6 +32,7 @@ class QrCodeActionTest extends TestCase
|
||||||
|
|
||||||
private QrCodeAction $action;
|
private QrCodeAction $action;
|
||||||
private ObjectProphecy $urlResolver;
|
private ObjectProphecy $urlResolver;
|
||||||
|
private QrCodeOptions $options;
|
||||||
|
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
|
@ -38,11 +40,13 @@ class QrCodeActionTest extends TestCase
|
||||||
$router->generateUri(Argument::cetera())->willReturn('/foo/bar');
|
$router->generateUri(Argument::cetera())->willReturn('/foo/bar');
|
||||||
|
|
||||||
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
|
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
|
||||||
|
$this->options = new QrCodeOptions();
|
||||||
|
|
||||||
$this->action = new QrCodeAction(
|
$this->action = new QrCodeAction(
|
||||||
$this->urlResolver->reveal(),
|
$this->urlResolver->reveal(),
|
||||||
new ShortUrlStringifier(['domain' => 'doma.in']),
|
new ShortUrlStringifier(['domain' => 'doma.in']),
|
||||||
new NullLogger(),
|
new NullLogger(),
|
||||||
|
$this->options,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,9 +89,11 @@ class QrCodeActionTest extends TestCase
|
||||||
* @dataProvider provideQueries
|
* @dataProvider provideQueries
|
||||||
*/
|
*/
|
||||||
public function imageIsReturnedWithExpectedContentTypeBasedOnProvidedFormat(
|
public function imageIsReturnedWithExpectedContentTypeBasedOnProvidedFormat(
|
||||||
|
string $defaultFormat,
|
||||||
array $query,
|
array $query,
|
||||||
string $expectedContentType,
|
string $expectedContentType,
|
||||||
): void {
|
): void {
|
||||||
|
$this->options->setFromArray(['format' => $defaultFormat]);
|
||||||
$code = 'abc123';
|
$code = 'abc123';
|
||||||
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn(
|
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn(
|
||||||
ShortUrl::createEmpty(),
|
ShortUrl::createEmpty(),
|
||||||
|
@ -102,18 +108,26 @@ class QrCodeActionTest extends TestCase
|
||||||
|
|
||||||
public function provideQueries(): iterable
|
public function provideQueries(): iterable
|
||||||
{
|
{
|
||||||
yield 'no format' => [[], 'image/png'];
|
yield 'no format, png default' => ['png', [], 'image/png'];
|
||||||
yield 'png format' => [['format' => 'png'], 'image/png'];
|
yield 'no format, svg default' => ['svg', [], 'image/svg+xml'];
|
||||||
yield 'svg format' => [['format' => 'svg'], 'image/svg+xml'];
|
yield 'png format, png default' => ['png', ['format' => 'png'], 'image/png'];
|
||||||
yield 'unsupported format' => [['format' => 'jpg'], 'image/png'];
|
yield 'png format, svg default' => ['svg', ['format' => 'png'], 'image/png'];
|
||||||
|
yield 'svg format, png default' => ['png', ['format' => 'svg'], 'image/svg+xml'];
|
||||||
|
yield 'svg format, svg default' => ['svg', ['format' => 'svg'], 'image/svg+xml'];
|
||||||
|
yield 'unsupported format, png default' => ['png', ['format' => 'jpg'], 'image/png'];
|
||||||
|
yield 'unsupported format, svg default' => ['svg', ['format' => 'jpg'], 'image/svg+xml'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @test
|
* @test
|
||||||
* @dataProvider provideRequestsWithSize
|
* @dataProvider provideRequestsWithSize
|
||||||
*/
|
*/
|
||||||
public function imageIsReturnedWithExpectedSize(ServerRequestInterface $req, int $expectedSize): void
|
public function imageIsReturnedWithExpectedSize(
|
||||||
{
|
array $defaults,
|
||||||
|
ServerRequestInterface $req,
|
||||||
|
int $expectedSize,
|
||||||
|
): void {
|
||||||
|
$this->options->setFromArray($defaults);
|
||||||
$code = 'abc123';
|
$code = 'abc123';
|
||||||
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn(
|
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn(
|
||||||
ShortUrl::createEmpty(),
|
ShortUrl::createEmpty(),
|
||||||
|
@ -128,25 +142,59 @@ class QrCodeActionTest extends TestCase
|
||||||
|
|
||||||
public function provideRequestsWithSize(): iterable
|
public function provideRequestsWithSize(): iterable
|
||||||
{
|
{
|
||||||
yield 'no size' => [ServerRequestFactory::fromGlobals(), 300];
|
yield 'different margin and size defaults' => [
|
||||||
yield 'size in attr' => [ServerRequestFactory::fromGlobals()->withAttribute('size', '400'), 400];
|
['size' => 660, 'margin' => 40],
|
||||||
yield 'size in query' => [ServerRequestFactory::fromGlobals()->withQueryParams(['size' => '123']), 123];
|
ServerRequestFactory::fromGlobals(),
|
||||||
|
740,
|
||||||
|
];
|
||||||
|
yield 'no size' => [[], ServerRequestFactory::fromGlobals(), 300];
|
||||||
|
yield 'no size, different default' => [['size' => 500], ServerRequestFactory::fromGlobals(), 500];
|
||||||
|
yield 'size in attr' => [[], ServerRequestFactory::fromGlobals()->withAttribute('size', '400'), 400];
|
||||||
|
yield 'size in query' => [[], ServerRequestFactory::fromGlobals()->withQueryParams(['size' => '123']), 123];
|
||||||
|
yield 'size in query, default margin' => [
|
||||||
|
['margin' => 25],
|
||||||
|
ServerRequestFactory::fromGlobals()->withQueryParams(['size' => '123']),
|
||||||
|
173,
|
||||||
|
];
|
||||||
yield 'size in query and attr' => [
|
yield 'size in query and attr' => [
|
||||||
|
[],
|
||||||
ServerRequestFactory::fromGlobals()->withAttribute('size', '350')->withQueryParams(['size' => '123']),
|
ServerRequestFactory::fromGlobals()->withAttribute('size', '350')->withQueryParams(['size' => '123']),
|
||||||
350,
|
350,
|
||||||
];
|
];
|
||||||
yield 'margin' => [ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '35']), 370];
|
yield 'margin' => [[], ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '35']), 370];
|
||||||
|
yield 'margin and different default' => [
|
||||||
|
['size' => 400],
|
||||||
|
ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '35']),
|
||||||
|
470,
|
||||||
|
];
|
||||||
yield 'margin and size' => [
|
yield 'margin and size' => [
|
||||||
|
[],
|
||||||
ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '100', 'size' => '200']),
|
ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '100', 'size' => '200']),
|
||||||
400,
|
400,
|
||||||
];
|
];
|
||||||
yield 'negative margin' => [ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-50']), 300];
|
yield 'negative margin' => [[], ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-50']), 300];
|
||||||
yield 'non-numeric margin' => [ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => 'foo']), 300];
|
yield 'negative margin, default margin' => [
|
||||||
|
['margin' => 10],
|
||||||
|
ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-50']),
|
||||||
|
300,
|
||||||
|
];
|
||||||
|
yield 'non-numeric margin' => [
|
||||||
|
[],
|
||||||
|
ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => 'foo']),
|
||||||
|
300,
|
||||||
|
];
|
||||||
yield 'negative margin and size' => [
|
yield 'negative margin and size' => [
|
||||||
|
[],
|
||||||
|
ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-1', 'size' => '150']),
|
||||||
|
150,
|
||||||
|
];
|
||||||
|
yield 'negative margin and size, default margin' => [
|
||||||
|
['margin' => 5],
|
||||||
ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-1', 'size' => '150']),
|
ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '-1', 'size' => '150']),
|
||||||
150,
|
150,
|
||||||
];
|
];
|
||||||
yield 'non-numeric margin and size' => [
|
yield 'non-numeric margin and size' => [
|
||||||
|
[],
|
||||||
ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => 'foo', 'size' => '538']),
|
ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => 'foo', 'size' => '538']),
|
||||||
538,
|
538,
|
||||||
];
|
];
|
||||||
|
|
|
@ -14,9 +14,10 @@ use Prophecy\Argument;
|
||||||
use Prophecy\PhpUnit\ProphecyTrait;
|
use Prophecy\PhpUnit\ProphecyTrait;
|
||||||
use Prophecy\Prophecy\ObjectProphecy;
|
use Prophecy\Prophecy\ObjectProphecy;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Message\UriInterface;
|
||||||
use Psr\Http\Server\MiddlewareInterface;
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
use Shlinkio\Shlink\Core\Action\RedirectAction;
|
use Shlinkio\Shlink\Core\Action\RedirectAction;
|
||||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
|
|
||||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolver;
|
use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolver;
|
||||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||||
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
||||||
|
@ -28,18 +29,11 @@ class NotFoundRedirectResolverTest extends TestCase
|
||||||
|
|
||||||
private NotFoundRedirectResolver $resolver;
|
private NotFoundRedirectResolver $resolver;
|
||||||
private ObjectProphecy $helper;
|
private ObjectProphecy $helper;
|
||||||
private NotFoundRedirectConfigInterface $config;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->helper = $this->prophesize(RedirectResponseHelperInterface::class);
|
$this->helper = $this->prophesize(RedirectResponseHelperInterface::class);
|
||||||
$this->resolver = new NotFoundRedirectResolver($this->helper->reveal());
|
$this->resolver = new NotFoundRedirectResolver($this->helper->reveal(), new NullLogger());
|
||||||
|
|
||||||
$this->config = new NotFoundRedirectOptions([
|
|
||||||
'invalidShortUrl' => 'invalidShortUrl',
|
|
||||||
'regular404' => 'regular404',
|
|
||||||
'baseUrl' => 'baseUrl',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -47,13 +41,15 @@ class NotFoundRedirectResolverTest extends TestCase
|
||||||
* @dataProvider provideRedirects
|
* @dataProvider provideRedirects
|
||||||
*/
|
*/
|
||||||
public function expectedRedirectionIsReturnedDependingOnTheCase(
|
public function expectedRedirectionIsReturnedDependingOnTheCase(
|
||||||
|
UriInterface $uri,
|
||||||
NotFoundType $notFoundType,
|
NotFoundType $notFoundType,
|
||||||
|
NotFoundRedirectOptions $redirectConfig,
|
||||||
string $expectedRedirectTo,
|
string $expectedRedirectTo,
|
||||||
): void {
|
): void {
|
||||||
$expectedResp = new Response();
|
$expectedResp = new Response();
|
||||||
$buildResp = $this->helper->buildRedirectResponse($expectedRedirectTo)->willReturn($expectedResp);
|
$buildResp = $this->helper->buildRedirectResponse($expectedRedirectTo)->willReturn($expectedResp);
|
||||||
|
|
||||||
$resp = $this->resolver->resolveRedirectResponse($notFoundType, $this->config);
|
$resp = $this->resolver->resolveRedirectResponse($notFoundType, $redirectConfig, $uri);
|
||||||
|
|
||||||
self::assertSame($expectedResp, $resp);
|
self::assertSame($expectedResp, $resp);
|
||||||
$buildResp->shouldHaveBeenCalledOnce();
|
$buildResp->shouldHaveBeenCalledOnce();
|
||||||
|
@ -62,21 +58,61 @@ class NotFoundRedirectResolverTest extends TestCase
|
||||||
public function provideRedirects(): iterable
|
public function provideRedirects(): iterable
|
||||||
{
|
{
|
||||||
yield 'base URL with trailing slash' => [
|
yield 'base URL with trailing slash' => [
|
||||||
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/'))),
|
$uri = new Uri('/'),
|
||||||
|
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)),
|
||||||
|
new NotFoundRedirectOptions(['baseUrl' => 'baseUrl']),
|
||||||
'baseUrl',
|
'baseUrl',
|
||||||
];
|
];
|
||||||
|
yield 'base URL with domain placeholder' => [
|
||||||
|
$uri = new Uri('https://doma.in'),
|
||||||
|
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)),
|
||||||
|
new NotFoundRedirectOptions(['baseUrl' => 'https://redirect-here.com/{DOMAIN}']),
|
||||||
|
'https://redirect-here.com/doma.in',
|
||||||
|
];
|
||||||
|
yield 'base URL with domain placeholder in query' => [
|
||||||
|
$uri = new Uri('https://doma.in'),
|
||||||
|
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)),
|
||||||
|
new NotFoundRedirectOptions(['baseUrl' => 'https://redirect-here.com/?domain={DOMAIN}']),
|
||||||
|
'https://redirect-here.com/?domain=doma.in',
|
||||||
|
];
|
||||||
yield 'base URL without trailing slash' => [
|
yield 'base URL without trailing slash' => [
|
||||||
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri(''))),
|
$uri = new Uri(''),
|
||||||
|
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)),
|
||||||
|
new NotFoundRedirectOptions(['baseUrl' => 'baseUrl']),
|
||||||
'baseUrl',
|
'baseUrl',
|
||||||
];
|
];
|
||||||
yield 'regular 404' => [
|
yield 'regular 404' => [
|
||||||
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo/bar'))),
|
$uri = new Uri('/foo/bar'),
|
||||||
|
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)),
|
||||||
|
new NotFoundRedirectOptions(['regular404' => 'regular404']),
|
||||||
'regular404',
|
'regular404',
|
||||||
];
|
];
|
||||||
|
yield 'regular 404 with path placeholder in query' => [
|
||||||
|
$uri = new Uri('/foo/bar'),
|
||||||
|
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)),
|
||||||
|
new NotFoundRedirectOptions(['regular404' => 'https://redirect-here.com/?path={ORIGINAL_PATH}']),
|
||||||
|
'https://redirect-here.com/?path=%2Ffoo%2Fbar',
|
||||||
|
];
|
||||||
|
yield 'regular 404 with multiple placeholders' => [
|
||||||
|
$uri = new Uri('https://doma.in/foo/bar'),
|
||||||
|
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)),
|
||||||
|
new NotFoundRedirectOptions([
|
||||||
|
'regular404' => 'https://redirect-here.com/{ORIGINAL_PATH}/{DOMAIN}/?d={DOMAIN}&p={ORIGINAL_PATH}',
|
||||||
|
]),
|
||||||
|
'https://redirect-here.com//foo/bar/doma.in/?d=doma.in&p=%2Ffoo%2Fbar', // TODO Fix duplicated slash
|
||||||
|
];
|
||||||
yield 'invalid short URL' => [
|
yield 'invalid short URL' => [
|
||||||
|
new Uri('/foo'),
|
||||||
$this->notFoundType($this->requestForRoute(RedirectAction::class)),
|
$this->notFoundType($this->requestForRoute(RedirectAction::class)),
|
||||||
|
new NotFoundRedirectOptions(['invalidShortUrl' => 'invalidShortUrl']),
|
||||||
'invalidShortUrl',
|
'invalidShortUrl',
|
||||||
];
|
];
|
||||||
|
yield 'invalid short URL with path placeholder' => [
|
||||||
|
new Uri('/foo'),
|
||||||
|
$this->notFoundType($this->requestForRoute(RedirectAction::class)),
|
||||||
|
new NotFoundRedirectOptions(['invalidShortUrl' => 'https://redirect-here.com/{ORIGINAL_PATH}']),
|
||||||
|
'https://redirect-here.com//foo', // TODO Fix duplicated slash
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
|
@ -84,7 +120,7 @@ class NotFoundRedirectResolverTest extends TestCase
|
||||||
{
|
{
|
||||||
$notFoundType = $this->notFoundType($this->requestForRoute('foo'));
|
$notFoundType = $this->notFoundType($this->requestForRoute('foo'));
|
||||||
|
|
||||||
$result = $this->resolver->resolveRedirectResponse($notFoundType, $this->config);
|
$result = $this->resolver->resolveRedirectResponse($notFoundType, new NotFoundRedirectOptions(), new Uri());
|
||||||
|
|
||||||
self::assertNull($result);
|
self::assertNull($result);
|
||||||
$this->helper->buildRedirectResponse(Argument::cetera())->shouldNotHaveBeenCalled();
|
$this->helper->buildRedirectResponse(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||||
|
|
|
@ -16,7 +16,7 @@ use function Functional\map;
|
||||||
use function range;
|
use function range;
|
||||||
use function strlen;
|
use function strlen;
|
||||||
|
|
||||||
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
|
||||||
|
|
||||||
class ShortUrlTest extends TestCase
|
class ShortUrlTest extends TestCase
|
||||||
{
|
{
|
||||||
|
|
|
@ -11,6 +11,7 @@ use Prophecy\Argument;
|
||||||
use Prophecy\PhpUnit\ProphecyTrait;
|
use Prophecy\PhpUnit\ProphecyTrait;
|
||||||
use Prophecy\Prophecy\ObjectProphecy;
|
use Prophecy\Prophecy\ObjectProphecy;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Message\UriInterface;
|
||||||
use Psr\Http\Server\RequestHandlerInterface;
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolverInterface;
|
use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolverInterface;
|
||||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||||
|
@ -72,17 +73,26 @@ class NotFoundRedirectHandlerTest extends TestCase
|
||||||
$domainService->findByAuthority(Argument::cetera())
|
$domainService->findByAuthority(Argument::cetera())
|
||||||
->willReturn(null)
|
->willReturn(null)
|
||||||
->shouldBeCalledOnce();
|
->shouldBeCalledOnce();
|
||||||
$resolver->resolveRedirectResponse(Argument::cetera())
|
$resolver->resolveRedirectResponse(
|
||||||
->willReturn(null)
|
Argument::type(NotFoundType::class),
|
||||||
->shouldBeCalledOnce();
|
Argument::type(NotFoundRedirectOptions::class),
|
||||||
|
Argument::type(UriInterface::class),
|
||||||
|
)->willReturn(null)->shouldBeCalledOnce();
|
||||||
}];
|
}];
|
||||||
yield 'non-redirecting domain' => [function (ObjectProphecy $domainService, ObjectProphecy $resolver): void {
|
yield 'non-redirecting domain' => [function (ObjectProphecy $domainService, ObjectProphecy $resolver): void {
|
||||||
$domainService->findByAuthority(Argument::cetera())
|
$domainService->findByAuthority(Argument::cetera())
|
||||||
->willReturn(Domain::withAuthority(''))
|
->willReturn(Domain::withAuthority(''))
|
||||||
->shouldBeCalledOnce();
|
->shouldBeCalledOnce();
|
||||||
$resolver->resolveRedirectResponse(Argument::cetera())
|
$resolver->resolveRedirectResponse(
|
||||||
->willReturn(null)
|
Argument::type(NotFoundType::class),
|
||||||
->shouldBeCalledTimes(2);
|
Argument::type(NotFoundRedirectOptions::class),
|
||||||
|
Argument::type(UriInterface::class),
|
||||||
|
)->willReturn(null)->shouldBeCalledOnce();
|
||||||
|
$resolver->resolveRedirectResponse(
|
||||||
|
Argument::type(NotFoundType::class),
|
||||||
|
Argument::type(Domain::class),
|
||||||
|
Argument::type(UriInterface::class),
|
||||||
|
)->willReturn(null)->shouldBeCalledOnce();
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -95,6 +105,7 @@ class NotFoundRedirectHandlerTest extends TestCase
|
||||||
$resolveRedirect = $this->resolver->resolveRedirectResponse(
|
$resolveRedirect = $this->resolver->resolveRedirectResponse(
|
||||||
Argument::type(NotFoundType::class),
|
Argument::type(NotFoundType::class),
|
||||||
$this->redirectOptions,
|
$this->redirectOptions,
|
||||||
|
Argument::type(UriInterface::class),
|
||||||
)->willReturn($expectedResp);
|
)->willReturn($expectedResp);
|
||||||
|
|
||||||
$result = $this->middleware->process($this->req, $this->next->reveal());
|
$result = $this->middleware->process($this->req, $this->next->reveal());
|
||||||
|
@ -115,6 +126,7 @@ class NotFoundRedirectHandlerTest extends TestCase
|
||||||
$resolveRedirect = $this->resolver->resolveRedirectResponse(
|
$resolveRedirect = $this->resolver->resolveRedirectResponse(
|
||||||
Argument::type(NotFoundType::class),
|
Argument::type(NotFoundType::class),
|
||||||
$domain,
|
$domain,
|
||||||
|
Argument::type(UriInterface::class),
|
||||||
)->willReturn($expectedResp);
|
)->willReturn($expectedResp);
|
||||||
|
|
||||||
$result = $this->middleware->process($this->req, $this->next->reveal());
|
$result = $this->middleware->process($this->req, $this->next->reveal());
|
||||||
|
|
|
@ -23,6 +23,7 @@ use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
|
||||||
use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToWebHooks;
|
use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToWebHooks;
|
||||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||||
|
use Shlinkio\Shlink\Core\Options\WebhookOptions;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
|
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
|
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
|
||||||
|
|
||||||
|
@ -76,33 +77,56 @@ class NotifyVisitToWebHooksTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function expectedRequestsArePerformedToWebhooks(): void
|
public function orphanVisitDoesNotPerformAnyRequestWhenDisabled(): void
|
||||||
|
{
|
||||||
|
$find = $this->em->find(Visit::class, '1')->willReturn(Visit::forBasePath(Visitor::emptyInstance()));
|
||||||
|
$requestAsync = $this->httpClient->requestAsync(
|
||||||
|
RequestMethodInterface::METHOD_POST,
|
||||||
|
Argument::type('string'),
|
||||||
|
Argument::type('array'),
|
||||||
|
)->willReturn(new FulfilledPromise(''));
|
||||||
|
$logWarning = $this->logger->warning(Argument::cetera());
|
||||||
|
|
||||||
|
$this->createListener(['foo', 'bar'], false)(new VisitLocated('1'));
|
||||||
|
|
||||||
|
$find->shouldHaveBeenCalledOnce();
|
||||||
|
$logWarning->shouldNotHaveBeenCalled();
|
||||||
|
$requestAsync->shouldNotHaveBeenCalled();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
* @dataProvider provideVisits
|
||||||
|
*/
|
||||||
|
public function expectedRequestsArePerformedToWebhooks(Visit $visit, array $expectedResponseKeys): void
|
||||||
{
|
{
|
||||||
$webhooks = ['foo', 'invalid', 'bar', 'baz'];
|
$webhooks = ['foo', 'invalid', 'bar', 'baz'];
|
||||||
$invalidWebhooks = ['invalid', 'baz'];
|
$invalidWebhooks = ['invalid', 'baz'];
|
||||||
|
|
||||||
$find = $this->em->find(Visit::class, '1')->willReturn(
|
$find = $this->em->find(Visit::class, '1')->willReturn($visit);
|
||||||
Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()),
|
|
||||||
);
|
|
||||||
$requestAsync = $this->httpClient->requestAsync(
|
$requestAsync = $this->httpClient->requestAsync(
|
||||||
RequestMethodInterface::METHOD_POST,
|
RequestMethodInterface::METHOD_POST,
|
||||||
Argument::type('string'),
|
Argument::type('string'),
|
||||||
Argument::that(function (array $requestOptions) {
|
Argument::that(function (array $requestOptions) use ($expectedResponseKeys) {
|
||||||
Assert::assertArrayHasKey(RequestOptions::HEADERS, $requestOptions);
|
Assert::assertArrayHasKey(RequestOptions::HEADERS, $requestOptions);
|
||||||
Assert::assertArrayHasKey(RequestOptions::JSON, $requestOptions);
|
Assert::assertArrayHasKey(RequestOptions::JSON, $requestOptions);
|
||||||
Assert::assertArrayHasKey(RequestOptions::TIMEOUT, $requestOptions);
|
Assert::assertArrayHasKey(RequestOptions::TIMEOUT, $requestOptions);
|
||||||
Assert::assertEquals($requestOptions[RequestOptions::TIMEOUT], 10);
|
Assert::assertEquals($requestOptions[RequestOptions::TIMEOUT], 10);
|
||||||
Assert::assertEquals($requestOptions[RequestOptions::HEADERS], ['User-Agent' => 'Shlink:v1.2.3']);
|
Assert::assertEquals($requestOptions[RequestOptions::HEADERS], ['User-Agent' => 'Shlink:v1.2.3']);
|
||||||
Assert::assertArrayHasKey('shortUrl', $requestOptions[RequestOptions::JSON]);
|
|
||||||
Assert::assertArrayHasKey('visit', $requestOptions[RequestOptions::JSON]);
|
$json = $requestOptions[RequestOptions::JSON];
|
||||||
|
Assert::assertCount(count($expectedResponseKeys), $json);
|
||||||
|
foreach ($expectedResponseKeys as $key) {
|
||||||
|
Assert::assertArrayHasKey($key, $json);
|
||||||
|
}
|
||||||
|
|
||||||
return $requestOptions;
|
return $requestOptions;
|
||||||
}),
|
}),
|
||||||
)->will(function (array $args) use ($invalidWebhooks) {
|
)->will(function (array $args) use ($invalidWebhooks) {
|
||||||
[, $webhook] = $args;
|
[, $webhook] = $args;
|
||||||
$e = new Exception('');
|
$shouldReject = contains($invalidWebhooks, $webhook);
|
||||||
|
|
||||||
return contains($invalidWebhooks, $webhook) ? new RejectedPromise($e) : new FulfilledPromise('');
|
return $shouldReject ? new RejectedPromise(new Exception('')) : new FulfilledPromise('');
|
||||||
});
|
});
|
||||||
$logWarning = $this->logger->warning(
|
$logWarning = $this->logger->warning(
|
||||||
'Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}',
|
'Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}',
|
||||||
|
@ -122,13 +146,24 @@ class NotifyVisitToWebHooksTest extends TestCase
|
||||||
$logWarning->shouldHaveBeenCalledTimes(count($invalidWebhooks));
|
$logWarning->shouldHaveBeenCalledTimes(count($invalidWebhooks));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createListener(array $webhooks): NotifyVisitToWebHooks
|
public function provideVisits(): iterable
|
||||||
|
{
|
||||||
|
yield 'regular visit' => [
|
||||||
|
Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()),
|
||||||
|
['shortUrl', 'visit'],
|
||||||
|
];
|
||||||
|
yield 'orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), ['visit'],];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createListener(array $webhooks, bool $notifyOrphanVisits = true): NotifyVisitToWebHooks
|
||||||
{
|
{
|
||||||
return new NotifyVisitToWebHooks(
|
return new NotifyVisitToWebHooks(
|
||||||
$this->httpClient->reveal(),
|
$this->httpClient->reveal(),
|
||||||
$this->em->reveal(),
|
$this->em->reveal(),
|
||||||
$this->logger->reveal(),
|
$this->logger->reveal(),
|
||||||
$webhooks,
|
new WebhookOptions(
|
||||||
|
['visits_webhooks' => $webhooks, 'notify_orphan_visits_to_webhooks' => $notifyOrphanVisits],
|
||||||
|
),
|
||||||
new ShortUrlDataTransformer(new ShortUrlStringifier([])),
|
new ShortUrlDataTransformer(new ShortUrlStringifier([])),
|
||||||
new AppOptions(['name' => 'Shlink', 'version' => '1.2.3']),
|
new AppOptions(['name' => 'Shlink', 'version' => '1.2.3']),
|
||||||
);
|
);
|
||||||
|
|
|
@ -60,6 +60,7 @@ class MercureUpdatesGeneratorTest extends TestCase
|
||||||
'domain' => null,
|
'domain' => null,
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'crawlable' => false,
|
'crawlable' => false,
|
||||||
|
'forwardQuery' => true,
|
||||||
],
|
],
|
||||||
'visit' => [
|
'visit' => [
|
||||||
'referer' => '',
|
'referer' => '',
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue