mirror of
https://github.com/shlinkio/shlink.git
synced 2026-02-28 20:23:12 +08:00
Compare commits
281 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3b7742992 | ||
|
|
4b5fa6ddad | ||
|
|
b6aca82da6 | ||
|
|
8ee3bb4d58 | ||
|
|
46bea241e6 | ||
|
|
5e6d2881bc | ||
|
|
cd19876419 | ||
|
|
f82e103bc5 | ||
|
|
3ff4ac84c4 | ||
|
|
bf0c679a48 | ||
|
|
96c5bc164a | ||
|
|
73aead01b4 | ||
|
|
e19b3cc45d | ||
|
|
a1cab4ca7d | ||
|
|
4b89687e45 | ||
|
|
1c861fecfc | ||
|
|
a12c9f54c4 | ||
|
|
69d72e754f | ||
|
|
db3c5a3031 | ||
|
|
6327ed814a | ||
|
|
9fa32b5b6b | ||
|
|
663ae9f6bb | ||
|
|
70c73bc5d6 | ||
|
|
05d73552cf | ||
|
|
70384237c1 | ||
|
|
36e4a0dd32 | ||
|
|
3ef02d46c0 | ||
|
|
e6ce84aa14 | ||
|
|
348e34d52e | ||
|
|
2e6b3c0561 | ||
|
|
7280b48cdc | ||
|
|
2803f65479 | ||
|
|
3535688c3b | ||
|
|
9cff570c45 | ||
|
|
15c028e151 | ||
|
|
f0dc32b6e5 | ||
|
|
d423d18249 | ||
|
|
8a8e3c3fc8 | ||
|
|
111fc3c37d | ||
|
|
e9a5284dde | ||
|
|
b277f431c2 | ||
|
|
c8b8947b1f | ||
|
|
9a78d1585d | ||
|
|
09414a8834 | ||
|
|
1efa973507 | ||
|
|
e23cd6a856 | ||
|
|
743bb7a6ee | ||
|
|
086efe3c63 | ||
|
|
d751df70fd | ||
|
|
334d95c843 | ||
|
|
5ddac7866b | ||
|
|
a896fbbb90 | ||
|
|
a478699fe8 | ||
|
|
6387e50276 | ||
|
|
28c06de685 | ||
|
|
823573cea7 | ||
|
|
5d0f306bcc | ||
|
|
f30e922074 | ||
|
|
96ff0bffda | ||
|
|
d9b675fc8b | ||
|
|
104b7390da | ||
|
|
7b4456e73f | ||
|
|
86230d9bf3 | ||
|
|
1f8994ca8b | ||
|
|
f7b6f4ba19 | ||
|
|
74ea5969be | ||
|
|
c4718e7523 | ||
|
|
5de706e0fe | ||
|
|
77d06b4b03 | ||
|
|
b4d137375a | ||
|
|
0621ae7735 | ||
|
|
3a6a1f25a7 | ||
|
|
731dc64f44 | ||
|
|
d72b9cf646 | ||
|
|
0f0c4dc549 | ||
|
|
ea0820d881 | ||
|
|
312f20d2f1 | ||
|
|
f8289fa4be | ||
|
|
554209d644 | ||
|
|
4ce44034cb | ||
|
|
221b62ea57 | ||
|
|
0a5c265b12 | ||
|
|
9b55389538 | ||
|
|
60a8d6e986 | ||
|
|
d7523bcb57 | ||
|
|
562110fac4 | ||
|
|
d104265f04 | ||
|
|
4439685403 | ||
|
|
9feb72235a | ||
|
|
c372a498cc | ||
|
|
be35349350 | ||
|
|
771fd74978 | ||
|
|
3ba9ee7bf1 | ||
|
|
a0062a62e8 | ||
|
|
0f2bd77ebc | ||
|
|
744b368cc1 | ||
|
|
a03c4519c9 | ||
|
|
66327881d5 | ||
|
|
b93b14986e | ||
|
|
1ade4e9917 | ||
|
|
65f2ab6720 | ||
|
|
7d38ba12bd | ||
|
|
8128e85b6b | ||
|
|
3d99819be4 | ||
|
|
a2ca1618ea | ||
|
|
b244c56862 | ||
|
|
c931874bac | ||
|
|
1b168ac3d2 | ||
|
|
0fc123b249 | ||
|
|
c622804950 | ||
|
|
e093480a5b | ||
|
|
1498b72966 | ||
|
|
51e1c7cd50 | ||
|
|
40040b627f | ||
|
|
b752f8a357 | ||
|
|
5b93cf42b1 | ||
|
|
fa8145df9f | ||
|
|
5ddb6a7f99 | ||
|
|
8ad34357d3 | ||
|
|
81eb2684bf | ||
|
|
d2c0745efa | ||
|
|
3f2d38a86a | ||
|
|
4df4db05f4 | ||
|
|
6526fda960 | ||
|
|
32fdb257a3 | ||
|
|
9247cd874e | ||
|
|
4ceb42b88d | ||
|
|
3d99fc1708 | ||
|
|
656346bd04 | ||
|
|
6b5217ece2 | ||
|
|
0a2b388f6b | ||
|
|
25b3de84ec | ||
|
|
5c4e348078 | ||
|
|
2ac84ac8c4 | ||
|
|
f0249346b0 | ||
|
|
86651d7992 | ||
|
|
5cd5fb0071 | ||
|
|
e3bf046c30 | ||
|
|
d9af0a5547 | ||
|
|
ede7551856 | ||
|
|
a2030b6c27 | ||
|
|
9a951589dc | ||
|
|
c766cfad89 | ||
|
|
bd25572e08 | ||
|
|
4e00c950cc | ||
|
|
d932f0a204 | ||
|
|
08507272ed | ||
|
|
9c48e6578d | ||
|
|
db6c83eefd | ||
|
|
cc68cb944f | ||
|
|
a0d8d237d7 | ||
|
|
7d6d8e3a68 | ||
|
|
cc42f037c7 | ||
|
|
f4623ed028 | ||
|
|
bec467c703 | ||
|
|
bd09b1571a | ||
|
|
3ed6953d0b | ||
|
|
2fc6fb0a9a | ||
|
|
4b73bd907e | ||
|
|
a18486cc2e | ||
|
|
82f4e22f69 | ||
|
|
3497165ebd | ||
|
|
d5794a3dcb | ||
|
|
bd9ec53e7b | ||
|
|
5d98316c4e | ||
|
|
dcf2526aad | ||
|
|
85dd023c0e | ||
|
|
1fbcb44136 | ||
|
|
ab9042db24 | ||
|
|
b01487ac91 | ||
|
|
5278d7668c | ||
|
|
f7215fc2c5 | ||
|
|
d2e0413a48 | ||
|
|
0e165bc7e0 | ||
|
|
55e7f7ccb0 | ||
|
|
15061d3e0d | ||
|
|
36be44e7b5 | ||
|
|
1b4e62b823 | ||
|
|
12b07bb0ac | ||
|
|
f5666c9451 | ||
|
|
23cffce861 | ||
|
|
a1fb44f2a6 | ||
|
|
4d5dd8c8de | ||
|
|
1c492881e1 | ||
|
|
d310c53cce | ||
|
|
2289eebd91 | ||
|
|
e259bd62ab | ||
|
|
9f512705fa | ||
|
|
383fde488b | ||
|
|
b54350674c | ||
|
|
1e2b88496c | ||
|
|
919b567d46 | ||
|
|
da65c05c4f | ||
|
|
2f8ca6cf11 | ||
|
|
7121ff340a | ||
|
|
37f4d18d34 | ||
|
|
a8b424003c | ||
|
|
de4e677f18 | ||
|
|
bc632fd644 | ||
|
|
d386e1405c | ||
|
|
608742c2e2 | ||
|
|
71e91a541f | ||
|
|
ed18f10b94 | ||
|
|
4330a09793 | ||
|
|
16873201e9 | ||
|
|
2640c7b43c | ||
|
|
7824dddef7 | ||
|
|
7192480751 | ||
|
|
1da66f272c | ||
|
|
0ef1e347e7 | ||
|
|
bfba05c863 | ||
|
|
71f85350da | ||
|
|
8b54098299 | ||
|
|
356e68ca3e | ||
|
|
430c407106 | ||
|
|
31a7212a71 | ||
|
|
36a172308a | ||
|
|
e20df481a4 | ||
|
|
8fa0c95f5a | ||
|
|
4b4a859722 | ||
|
|
9cddedcdba | ||
|
|
01aebd90d5 | ||
|
|
c00105607c | ||
|
|
79ff12a1b0 | ||
|
|
e30c9c86ff | ||
|
|
c61e1e1c0e | ||
|
|
85bc5ce595 | ||
|
|
ef12e90ae7 | ||
|
|
6b0f6e4541 | ||
|
|
cdfd14e63f | ||
|
|
977058d219 | ||
|
|
c58fa586e1 | ||
|
|
1cd6fdeede | ||
|
|
09f25d78b7 | ||
|
|
82091c7951 | ||
|
|
1081211439 | ||
|
|
7e90fd45a7 | ||
|
|
08f4a424e6 | ||
|
|
063ee9c195 | ||
|
|
3f2bd657e1 | ||
|
|
903ef8e249 | ||
|
|
07b12fac3c | ||
|
|
56a2253535 | ||
|
|
752ded2f80 | ||
|
|
248d5e2fe5 | ||
|
|
158e981970 | ||
|
|
96d07c4b4e | ||
|
|
28afb8944f | ||
|
|
0d59ebfe55 | ||
|
|
bc38ecf6de | ||
|
|
755a52b78e | ||
|
|
4c008f1672 | ||
|
|
eb268fb856 | ||
|
|
b0e390ced1 | ||
|
|
741e8f625c | ||
|
|
17eb6dc4ce | ||
|
|
db997fe6f5 | ||
|
|
3b1fc2a27d | ||
|
|
0cbd965010 | ||
|
|
f3c3979eec | ||
|
|
bf26f5baa1 | ||
|
|
164462d536 | ||
|
|
239af85dd4 | ||
|
|
f585cfe02e | ||
|
|
ef54caab85 | ||
|
|
aaaa3010ab | ||
|
|
cfdf866c3f | ||
|
|
2a1a386b9c | ||
|
|
a4de8cee7d | ||
|
|
a9d6c463ed | ||
|
|
b8a725d60c | ||
|
|
927fb51313 | ||
|
|
76aa6502db | ||
|
|
7d908b6545 | ||
|
|
83a29d6ed0 | ||
|
|
55ddc4ae75 | ||
|
|
088e361228 | ||
|
|
80012b8ee8 | ||
|
|
a61235a5d1 | ||
|
|
823242a6c2 | ||
|
|
0670a4dc3c |
@@ -5,7 +5,7 @@ data/log/*
|
||||
data/locks/*
|
||||
data/proxies/*
|
||||
data/migrations_template.txt
|
||||
data/GeoLite2-City.*
|
||||
data/GeoLite2-City*
|
||||
data/database.sqlite
|
||||
data/shlink-tests.db
|
||||
CHANGELOG.md
|
||||
|
||||
75
.github/workflows/ci.yml
vendored
75
.github/workflows/ci.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4']
|
||||
php-version: ['8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
extensions: swoole-4.6.7
|
||||
coverage: none
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer cs
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4']
|
||||
php-version: ['8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
@@ -39,14 +39,13 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
extensions: swoole-4.6.7
|
||||
coverage: none
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer stan
|
||||
|
||||
unit-tests:
|
||||
runs-on: ubuntu-20.04
|
||||
continue-on-error: ${{ matrix.php-version == '8.0' }}
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
@@ -58,13 +57,10 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
extensions: swoole-4.6.7
|
||||
coverage: pcov
|
||||
ini-values: pcov.directory=module
|
||||
- if: ${{ matrix.php-version == '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||
- if: ${{ matrix.php-version != '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer test:unit:ci
|
||||
- uses: actions/upload-artifact@v2
|
||||
if: ${{ matrix.php-version == '7.4' }}
|
||||
@@ -87,13 +83,10 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
extensions: swoole-4.6.7
|
||||
coverage: pcov
|
||||
ini-values: pcov.directory=module
|
||||
- if: ${{ matrix.php-version == '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||
- if: ${{ matrix.php-version != '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer test:db:sqlite:ci
|
||||
- uses: actions/upload-artifact@v2
|
||||
if: ${{ matrix.php-version == '7.4' }}
|
||||
@@ -118,12 +111,9 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
extensions: swoole-4.6.7
|
||||
coverage: none
|
||||
- if: ${{ matrix.php-version == '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||
- if: ${{ matrix.php-version != '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer test:db:mysql
|
||||
|
||||
db-tests-maria:
|
||||
@@ -141,12 +131,9 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
extensions: swoole-4.6.7
|
||||
coverage: none
|
||||
- if: ${{ matrix.php-version == '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||
- if: ${{ matrix.php-version != '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer test:db:maria
|
||||
|
||||
db-tests-postgres:
|
||||
@@ -164,12 +151,9 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
extensions: swoole-4.6.7
|
||||
coverage: none
|
||||
- if: ${{ matrix.php-version == '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||
- if: ${{ matrix.php-version != '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer test:db:postgres
|
||||
|
||||
db-tests-ms:
|
||||
@@ -189,19 +173,15 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9, pdo_sqlsrv-5.9.0beta2
|
||||
extensions: swoole-4.6.7, pdo_sqlsrv-5.9.0
|
||||
coverage: none
|
||||
- if: ${{ matrix.php-version == '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||
- if: ${{ matrix.php-version != '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- 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: composer test:db:ms
|
||||
|
||||
api-tests:
|
||||
runs-on: ubuntu-20.04
|
||||
continue-on-error: ${{ matrix.php-version == '8.0' }}
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
@@ -209,19 +189,16 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Start database server
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
|
||||
- name: Use PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
extensions: swoole-4.6.7
|
||||
coverage: pcov
|
||||
ini-values: pcov.directory=module
|
||||
- if: ${{ matrix.php-version == '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||
- if: ${{ matrix.php-version != '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: bin/test/run-api-tests.sh
|
||||
- uses: actions/upload-artifact@v2
|
||||
if: ${{ matrix.php-version == '7.4' }}
|
||||
@@ -240,6 +217,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
test-group: ['unit', 'db']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
@@ -248,17 +226,14 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
extensions: swoole-4.6.7
|
||||
coverage: pcov
|
||||
ini-values: pcov.directory=module
|
||||
- if: ${{ matrix.php-version == '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||
- if: ${{ matrix.php-version != '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
path: build
|
||||
- run: composer infect:ci
|
||||
- run: composer infect:ci:${{ matrix.test-group }}
|
||||
|
||||
upload-coverage:
|
||||
needs:
|
||||
@@ -268,7 +243,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4']
|
||||
php-version: ['8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
@@ -309,6 +284,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 100
|
||||
- uses: marceloprado/has-changed-path@v1
|
||||
id: changed-dockerfile
|
||||
with:
|
||||
|
||||
44
.github/workflows/publish-release.yml
vendored
44
.github/workflows/publish-release.yml
vendored
@@ -7,18 +7,38 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
swoole: ['yes', 'no']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Use PHP 7.4
|
||||
- name: Use PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '7.4' # Publish release with lowest supported PHP version
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
- name: Generate release assets
|
||||
extensions: swoole-4.6.7
|
||||
- if: ${{ matrix.swoole == 'yes' }}
|
||||
run: ./build.sh ${GITHUB_REF#refs/tags/v}
|
||||
- if: ${{ matrix.swoole == 'no' }}
|
||||
run: ./build.sh ${GITHUB_REF#refs/tags/v} --no-swoole
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: dist-files-${{ matrix.php-version }}-${{ matrix.swoole }}
|
||||
path: build
|
||||
|
||||
publish:
|
||||
needs: ['build']
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
path: build
|
||||
- name: Publish release with assets
|
||||
uses: docker://antonyurchenko/git-release:latest
|
||||
env:
|
||||
@@ -27,4 +47,16 @@ jobs:
|
||||
ALLOW_EMPTY_CHANGELOG: "true"
|
||||
with:
|
||||
args: |
|
||||
build/shlink_*_dist.zip
|
||||
build/*/shlink*_dist.zip
|
||||
|
||||
delete-artifacts:
|
||||
needs: ['publish']
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: [ '7.4', '8.0' ]
|
||||
swoole: [ 'yes', 'no' ]
|
||||
steps:
|
||||
- uses: geekyeggo/delete-artifact@v1
|
||||
with:
|
||||
name: dist-files-${{ matrix.php-version }}-${{ matrix.swoole }}
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,8 +6,8 @@ composer.phar
|
||||
vendor/
|
||||
data/database.sqlite
|
||||
data/shlink-tests.db
|
||||
data/GeoLite2-City.mmdb
|
||||
data/GeoLite2-City.mmdb.*
|
||||
data/GeoLite2-City.*
|
||||
docs/swagger-ui*
|
||||
docs/mercure.html
|
||||
docker-compose.override.yml
|
||||
.phpunit.result.cache
|
||||
|
||||
142
CHANGELOG.md
142
CHANGELOG.md
@@ -4,6 +4,148 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [2.7.0] - 2021-05-23
|
||||
### Added
|
||||
* [#1044](https://github.com/shlinkio/shlink/issues/1044) Added ability to set names on API keys, which helps to identify them when the list grows.
|
||||
* [#819](https://github.com/shlinkio/shlink/issues/819) Visits are now always located in real time, even when not using swoole.
|
||||
|
||||
The only side effect is that a GeoLite2 db file is now installed when the docker image starts or during shlink installation or update.
|
||||
|
||||
Also, when using swoole, the file is now updated **after** tracking a visit, which means it will not apply until the next one.
|
||||
|
||||
* [#1059](https://github.com/shlinkio/shlink/issues/1059) Added ability to optionally display author API key and its name when listing short URLs from the command line.
|
||||
* [#1066](https://github.com/shlinkio/shlink/issues/1066) Added support to import short URLs and their visits from another Shlink instance using its API.
|
||||
* [#898](https://github.com/shlinkio/shlink/issues/898) Improved tracking granularity, allowing to disable visits tracking completely, or just parts of it.
|
||||
|
||||
In order to achieve it, Shlink now supports 4 new tracking-related options, that can be customized via env vars for docker, or via installer:
|
||||
|
||||
* `disable_tracking`: If true, visits will not be tracked at all.
|
||||
* `disable_ip_tracking`: If true, visits will be tracked, but neither the IP address, nor the location will be resolved.
|
||||
* `disable_referrer_tracking`: If true, the referrer will not be tracked.
|
||||
* `disable_ua_tracking`: If true, the user agent will not be tracked.
|
||||
|
||||
* [#955](https://github.com/shlinkio/shlink/issues/955) Added new option to set short URLs as crawlable, making them be listed in the robots.txt as Allowed.
|
||||
* [#900](https://github.com/shlinkio/shlink/issues/900) Shlink now tries to detect if the visit is coming from a potential bot or crawler, and allows to exclude those visits from visits lists if desired.
|
||||
|
||||
### Changed
|
||||
* [#1036](https://github.com/shlinkio/shlink/issues/1036) Updated to `happyr/doctrine-specification` 2.0.
|
||||
* [#1039](https://github.com/shlinkio/shlink/issues/1039) Updated to `endroid/qr-code` 4.0.
|
||||
* [#1008](https://github.com/shlinkio/shlink/issues/1008) Ensured all logs are sent to the filesystem while running API tests, which helps debugging the reason for tests to fail.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#1041](https://github.com/shlinkio/shlink/issues/1041) Ensured the default value for the version while building the docker image is `latest`.
|
||||
* [#1067](https://github.com/shlinkio/shlink/issues/1067) Fixed exception when persisting multiple short URLs in one batch which include the same new tags/domains. This can potentially happen when importing URLs.
|
||||
|
||||
|
||||
## [2.6.2] - 2021-03-12
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#1047](https://github.com/shlinkio/shlink/issues/1047) Fixed error in migrations when doing a fresh installation using PHP8 and MySQL/Mariadb databases.
|
||||
|
||||
|
||||
## [2.6.1] - 2021-02-22
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* [#1026](https://github.com/shlinkio/shlink/issues/1026) Removed non-inclusive terms from source code.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#1024](https://github.com/shlinkio/shlink/issues/1024) Fixed migration that is incorrectly skipped due to the wrong condition being used to check it.
|
||||
* [#1031](https://github.com/shlinkio/shlink/issues/1031) Fixed shortening of twitter URLs with URL validation enabled.
|
||||
* [#1034](https://github.com/shlinkio/shlink/issues/1034) Fixed warning displayed when shlink is stopped while running it with swoole.
|
||||
|
||||
|
||||
## [2.6.0] - 2021-02-13
|
||||
### Added
|
||||
* [#856](https://github.com/shlinkio/shlink/issues/856) Added PHP 8.0 support.
|
||||
* [#941](https://github.com/shlinkio/shlink/issues/941) Added support to provide a title for every short URL.
|
||||
|
||||
The title can also be automatically resolved from the long URL, when no title was explicitly provided, but this option needs to be opted in.
|
||||
|
||||
* [#913](https://github.com/shlinkio/shlink/issues/913) Added support to import short URLs from a standard CSV file.
|
||||
|
||||
The file requires the `Long URL` and `Short code` columns, and it also accepts the optional `title`, `domain` and `tags` columns.
|
||||
|
||||
* [#1000](https://github.com/shlinkio/shlink/issues/1000) Added support to provide a `margin` query param when generating some URL's QR code.
|
||||
* [#675](https://github.com/shlinkio/shlink/issues/675) Added ability to track visits to the base URL, invalid short URLs or any other "not found" URL, as known as orphan visits.
|
||||
|
||||
This behavior is enabled by default, but you can opt out via env vars or config options.
|
||||
|
||||
This new orphan visits can be consumed in these ways:
|
||||
|
||||
* The `https://shlink.io/new-orphan-visit` mercure topic, which gets notified when an orphan visit occurs.
|
||||
* The `GET /visits/orphan` REST endpoint, which behaves like the short URL visits and tags visits endpoints, but returns only orphan visits.
|
||||
|
||||
### Changed
|
||||
* [#977](https://github.com/shlinkio/shlink/issues/977) Migrated from `laminas/laminas-paginator` to `pagerfanta/core` to handle pagination.
|
||||
* [#986](https://github.com/shlinkio/shlink/issues/986) Updated official docker image to use PHP 8.
|
||||
* [#1010](https://github.com/shlinkio/shlink/issues/1010) Increased timeout for database commands to 10 minutes.
|
||||
* [#874](https://github.com/shlinkio/shlink/issues/874) Changed how dist files are generated. Now there will be two for every supported PHP version, with and without support for swoole.
|
||||
|
||||
The dist files will have been built under the same PHP version they are meant to be run under, ensuring resolved dependencies are the proper ones.
|
||||
|
||||
### Deprecated
|
||||
* [#959](https://github.com/shlinkio/shlink/issues/959) Deprecated all command flags using camelCase format (like `--expirationDate`), adding kebab-case replacements for all of them (like `--expiration-date`).
|
||||
|
||||
All the existing camelCase flags will continue working for now, but will be removed in Shlink 3.0.0
|
||||
|
||||
* [#862](https://github.com/shlinkio/shlink/issues/862) Deprecated the endpoint to edit tags for a short URL (`PUT /short-urls/{shortCode}/tags`).
|
||||
|
||||
The short URL edition endpoint (`PATCH /short-urls/{shortCode}`) now supports setting the tags too. Use it instead.
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#988](https://github.com/shlinkio/shlink/issues/988) Fixed serving zero-byte static files in apache and apache-compatible web servers.
|
||||
* [#990](https://github.com/shlinkio/shlink/issues/990) Fixed short URLs not properly composed in REST API endpoints when both custom domain and custom base path are used.
|
||||
* [#1002](https://github.com/shlinkio/shlink/issues/1002) Fixed weird behavior in which GeoLite2 metadata's `buildEpoch` is parsed as string instead of int.
|
||||
* [#851](https://github.com/shlinkio/shlink/issues/851) Fixed error when trying to schedule swoole tasks in ARM architectures (like raspberry).
|
||||
|
||||
|
||||
## [2.5.2] - 2021-01-24
|
||||
### Added
|
||||
* [#965](https://github.com/shlinkio/shlink/issues/965) Added docs section for Architectural Decision Records, including the one for API key roles.
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#979](https://github.com/shlinkio/shlink/issues/979) Added missing `itemsPerPage` query param to swagger docs for short RULs list.
|
||||
* [#980](https://github.com/shlinkio/shlink/issues/980) Fixed value used for `Access-Control-Allow-Origin`, that could not work as expected when including an IP address.
|
||||
* [#947](https://github.com/shlinkio/shlink/issues/947) Fixed incorrect value returned in `Access-Control-Allow-Methods` header, which always contained all methods.
|
||||
|
||||
|
||||
## [2.5.1] - 2021-01-21
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
@@ -33,7 +33,7 @@ Then you will have to follow these steps:
|
||||
|
||||
Once you finish this, you will have the project exposed in ports `8000` through nginx+php-fpm and `8080` through swoole.
|
||||
|
||||
> Note: The `indocker` shell script is a helper used to run commands inside the main docker container.
|
||||
> Note: The `indocker` shell script is a helper tool used to run commands inside the main docker container.
|
||||
|
||||
## Project structure
|
||||
|
||||
@@ -46,27 +46,28 @@ This is a simplified version of the project structure:
|
||||
```
|
||||
shlink
|
||||
├── bin
|
||||
│ ├── cli
|
||||
│ ├── install
|
||||
│ └── update
|
||||
│ ├── cli
|
||||
│ ├── install
|
||||
│ └── update
|
||||
├── config
|
||||
│ ├── autoload
|
||||
│ ├── params
|
||||
│ ├── config.php
|
||||
│ └── container.php
|
||||
│ ├── autoload
|
||||
│ ├── params
|
||||
│ ├── config.php
|
||||
│ └── container.php
|
||||
├── data
|
||||
│ ├── cache
|
||||
│ ├── locks
|
||||
│ ├── log
|
||||
│ ├── migrations
|
||||
│ └── proxies
|
||||
│ ├── cache
|
||||
│ ├── locks
|
||||
│ ├── log
|
||||
│ ├── migrations
|
||||
│ └── proxies
|
||||
├── docs
|
||||
│ ├── async-api
|
||||
│ └── swagger
|
||||
│ ├── adr
|
||||
│ ├── async-api
|
||||
│ └── swagger
|
||||
├── module
|
||||
│ ├── CLI
|
||||
│ ├── Core
|
||||
│ └── Rest
|
||||
│ ├── CLI
|
||||
│ ├── Core
|
||||
│ └── Rest
|
||||
├── public
|
||||
├── composer.json
|
||||
└── README.md
|
||||
@@ -77,7 +78,7 @@ The purposes of every folder are:
|
||||
* `bin`: It contains the CLI tools. The `cli` one is the main entry point to run shlink from the command line, while `install` and `update` are helper tools used to install and update shlink when not using the docker image.
|
||||
* `config`: Contains application-wide configurations, which are later merged with the ones provided by every module.
|
||||
* `data`: Common runtime-generated git-ignored assets, like logs, caches, etc.
|
||||
* `docs`: Any project documentation is stored here, like API spec definitions.
|
||||
* `docs`: Any project documentation is stored here, like API spec definitions or architectural decision records.
|
||||
* `module`: Contains a subfolder for every module in the project. Modules contain the source code, tests and configurations for every context in the project.
|
||||
* `public`: Few assets (like `favicon.ico` or `robots.txt`) and the web entry point are stored here. This web entry point is not used when serving the app with swoole.
|
||||
|
||||
@@ -87,19 +88,21 @@ In order to ensure stability and no regressions are introduced while developing
|
||||
|
||||
* **Unit tests**: These are the simplest to run, and usually test individual pieces of code, replacing any external dependency by mocks.
|
||||
|
||||
The code coverage of unit tests is pretty high, and only entity repositories are excluded because of their nature.
|
||||
The code coverage of unit tests is pretty high, and only components which work closer to the database, like entity repositories, are excluded because of their nature.
|
||||
|
||||
* **Database tests**: These are integration tests that run against a real database, and only cover entity repositories.
|
||||
* **Database tests**: These are integration tests that run against a real database, and only cover components which work closer to the database.
|
||||
|
||||
Its purpose is to verify all the database queries behave as expected and return what's expected.
|
||||
|
||||
The project provides some tooling to run them against any of the supported database engines.
|
||||
|
||||
* **API tests**: These are E2E tests that spin up an instance of the app and test it from the outside, by interacting with the REST API.
|
||||
* **API tests**: These are E2E tests that spin up an instance of the app with swoole, and test it from the outside by interacting with the REST API.
|
||||
|
||||
These are the best tests to catch regressions, and to verify everything interacts as expected.
|
||||
These are the best tests to catch regressions, and to verify everything behaves as expected.
|
||||
|
||||
They use MySQL as the database engine, and include some fixtures that ensure the same data exists at the beginning of the execution.
|
||||
They use Postgres as the database engine, and include some fixtures that ensure the same data exists at the beginning of the execution.
|
||||
|
||||
Since the app instance is run on a process different from the one running the tests, when a test fails it might not be obvious why. To help debugging that, the app will dump all its logs inside `data/log/api-tests`, where you will find the `shlink.log` and `access.log` files.
|
||||
|
||||
* **CLI tests**: *TBD. Once included, its purpose will be the same as API tests, but running through the command line*
|
||||
|
||||
@@ -113,13 +116,14 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed,
|
||||
* Run `./indocker composer test:unit` to run the unit tests.
|
||||
* Run `./indocker composer test:db` to run the database integration tests.
|
||||
|
||||
This command runs the same test suite against all supported database engines. If you just want to run one of them, you can add one of `:sqlite`, `:mysql`, `:maria`, `:postgres`, `:mssql` at the end of the command.
|
||||
This command runs the same test suite against all supported database engines in parallel. If you just want to run one of them, you can add one of `:sqlite`, `:mysql`, `:maria`, `:postgres`, `:mssql` at the end of the command.
|
||||
|
||||
For example, `test:db:postgres`.
|
||||
|
||||
* Run `./indocker composer test:api` to run API E2E tests. For these, the MySQL database engine is used.
|
||||
* Run `./indocker composer test:api` to run API E2E tests. For these, the Postgres database engine is used.
|
||||
* Run `./indocker composer infect:test` ti run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/).
|
||||
* Run `./indocker composer ci` to run all previous commands together. This command is run during the project's continuous integration.
|
||||
* Run `./indocker composer ci:parallel` to do the same as in previous case, but parallelizing non-conflicting tasks as much as possible.
|
||||
|
||||
> Note: Due to some limitations in the tooling used by shlink, the testing databases need to exist beforehand, both for db and api tests (except sqlite).
|
||||
>
|
||||
@@ -129,8 +133,18 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed,
|
||||
|
||||
## Pull request process
|
||||
|
||||
In order to provide pull requests to this project, you should always start by creating a new branch, where you will make all desired changes.
|
||||
**Important!**: Before starting to work on a pull request, make sure you always [open an issue](https://github.com/shlinkio/shlink/issues/new/choose) first.
|
||||
|
||||
This is important because any contribution needs to be discussed first. Maybe there's someone else already working on something similar, or there are other considerations to have in mind.
|
||||
|
||||
Once everything is clear, to provide a pull request to this project, you should always start by creating a new branch, where you will make all desired changes.
|
||||
|
||||
The base branch should always be `develop`, and the target branch for the pull request should also be `develop`.
|
||||
|
||||
Before your branch can be merged, all the checks described in [Running code checks](#running-code-checks) have to be passing. You can verify that manually by running `./indocker composer ci`, or wait for the build to be run automatically after the pull request is created.
|
||||
Before your branch can be merged, all the checks described in [Running code checks](#running-code-checks) have to be passing. You can verify that manually by running `./indocker composer ci:parallel`, or wait for the build to be run automatically after the pull request is created.
|
||||
|
||||
## Architectural Decision Records
|
||||
|
||||
The project includes logs for some architectural decisions, using the [adr](https://adr.github.io/) proposal.
|
||||
|
||||
If you are curious or want to understand why something has been built in some specific way, [take a look at them](docs/adr).
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -1,8 +1,10 @@
|
||||
FROM php:7.4.11-alpine3.12 as base
|
||||
FROM php:8.0.6-alpine3.13 as base
|
||||
|
||||
ARG SHLINK_VERSION=2.4.0
|
||||
ARG SHLINK_VERSION=latest
|
||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||
ENV SWOOLE_VERSION 4.5.9
|
||||
ENV SWOOLE_VERSION 4.6.7
|
||||
ENV PDO_SQLSRV_VERSION 5.9.0
|
||||
ENV MS_ODBC_SQL_VERSION 17.5.2.1
|
||||
ENV LC_ALL "C"
|
||||
|
||||
WORKDIR /etc/shlink
|
||||
@@ -29,13 +31,13 @@ RUN \
|
||||
|
||||
# Install sqlsrv driver
|
||||
RUN if [ $(uname -m) == "x86_64" ]; then \
|
||||
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
|
||||
pecl install pdo_sqlsrv && \
|
||||
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \
|
||||
docker-php-ext-enable pdo_sqlsrv && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk ; \
|
||||
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk ; \
|
||||
fi
|
||||
|
||||
# Install swoole
|
||||
@@ -68,6 +70,8 @@ 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 docker/docker-entrypoint.sh docker-entrypoint.sh
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016-2020 Alejandro Celaya
|
||||
Copyright (c) 2016-2021 Alejandro Celaya
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -33,7 +33,8 @@ The idea is that you can just generate a container using the image and provide t
|
||||
|
||||
First, make sure the host where you are going to run shlink fulfills these requirements:
|
||||
|
||||
* PHP 7.4 with JSON, curl, PDO, intl and gd extensions enabled (PHP 8.0 support is coming).
|
||||
* PHP 7.4 or 8.0
|
||||
* The next PHP extensions: json, curl, pdo, intl, gd and gmp.
|
||||
* apcu extension is recommended if you don't plan to use swoole.
|
||||
* xml extension is required if you want to generate QR codes in svg format.
|
||||
* MySQL, MariaDB, PostgreSQL, Microsoft SQL Server or SQLite.
|
||||
@@ -47,7 +48,7 @@ In order to run Shlink, you will need a built version of the project. There are
|
||||
|
||||
The easiest way to install shlink is by using one of the pre-bundled distributable packages.
|
||||
|
||||
Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink_x.x.x_dist.zip` file you will find there.
|
||||
Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink*_dist.zip` file that suits your needs. You will find one for every supported PHP version and with/without swoole integration.
|
||||
|
||||
Finally, decompress the file in the location of your choice.
|
||||
|
||||
@@ -57,9 +58,9 @@ In order to run Shlink, you will need a built version of the project. There are
|
||||
|
||||
* Clone the project with git (`git clone https://github.com/shlinkio/shlink.git`), or download it by clicking the **Clone or download** green button.
|
||||
* Download the [Composer](https://getcomposer.org/download/) PHP package manager inside the project folder.
|
||||
* Run `./build.sh 1.0.0`, replacing the version with the version number you are going to build (the version number is only used for the generated dist file).
|
||||
* Run `./build.sh 1.0.0`, replacing the version with the version number you are going to build (the version number is used as part of the generated dist file name, and to set the value returned when running `shlink -V` from the command line).
|
||||
|
||||
After that, you will have a `shlink_x.x.x_dist.zip` dist file inside the `build` directory, that you need to decompress in the location fo your choice.
|
||||
After that, you will have a dist file inside the `build` directory, that you need to decompress in the location of your choice.
|
||||
|
||||
> This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by a [GitHub workflow](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Publish+release%22), attaching the generated dist file to it.
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#!/usr/bin/env sh
|
||||
export APP_ENV=test
|
||||
export DB_DRIVER=mysql
|
||||
export DB_DRIVER=postgres
|
||||
export TEST_ENV=api
|
||||
|
||||
rm -rf data/log/api-tests
|
||||
|
||||
# Try to stop server just in case it hanged in last execution
|
||||
vendor/bin/laminas mezzio:swoole:stop
|
||||
|
||||
|
||||
36
build.sh
36
build.sh
@@ -1,35 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
if [[ "$#" -ne 1 ]]; then
|
||||
if [ "$#" -lt 1 ] || [ "$#" -gt 2 ] || ([ "$#" == 2 ] && [ "$2" != "--no-swoole" ]); then
|
||||
echo "Usage:" >&2
|
||||
echo " $0 {version}" >&2
|
||||
echo " $0 {version} [--no-swoole]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version=$1
|
||||
builtcontent="./build/shlink_${version}_dist"
|
||||
noSwoole=$2
|
||||
phpVersion=$(php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;')
|
||||
[[ $noSwoole ]] && swooleSuffix="" || swooleSuffix="_swoole"
|
||||
distId="shlink${version}_php${phpVersion}${swooleSuffix}_dist"
|
||||
builtContent="./build/${distId}"
|
||||
projectdir=$(pwd)
|
||||
[[ -f ./composer.phar ]] && composerBin='./composer.phar' || composerBin='composer'
|
||||
|
||||
# Copy project content to temp dir
|
||||
echo 'Copying project files...'
|
||||
rm -rf "${builtcontent}"
|
||||
mkdir -p "${builtcontent}"
|
||||
rsync -av * "${builtcontent}" \
|
||||
rm -rf "${builtContent}"
|
||||
mkdir -p "${builtContent}"
|
||||
rsync -av * "${builtContent}" \
|
||||
--exclude=*docker* \
|
||||
--exclude=Dockerfile \
|
||||
--include=.htaccess \
|
||||
--exclude-from=./.dockerignore
|
||||
cd "${builtcontent}"
|
||||
cd "${builtContent}"
|
||||
|
||||
# Install dependencies
|
||||
echo "Installing dependencies with $composerBin..."
|
||||
composerFlags="--optimize-autoloader --no-progress --no-interaction"
|
||||
${composerBin} self-update
|
||||
${composerBin} install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction
|
||||
${composerBin} install --no-dev --prefer-dist $composerFlags
|
||||
|
||||
# Copy mezzio helper script to vendor (deprecated - Remove with Shlink 3.0.0)
|
||||
cp "${projectdir}/bin/helper/mezzio-swoole" "./vendor/bin"
|
||||
if [[ $noSwoole ]]; then
|
||||
# If generating a dist not for swoole, uninstall mezzio-swoole
|
||||
${composerBin} remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev $composerFlags
|
||||
else
|
||||
# Copy mezzio helper script to vendor (deprecated - Remove with Shlink 3.0.0)
|
||||
cp "${projectdir}/bin/helper/mezzio-swoole" "./vendor/bin"
|
||||
fi
|
||||
|
||||
# Delete development files
|
||||
echo 'Deleting dev files...'
|
||||
@@ -41,9 +51,9 @@ sed -i "s/%SHLINK_VERSION%/${version}/g" config/autoload/app_options.global.php
|
||||
# Compressing file
|
||||
echo 'Compressing files...'
|
||||
cd "${projectdir}"/build
|
||||
rm -f ./shlink_${version}_dist.zip
|
||||
zip -ry ./shlink_${version}_dist.zip ./shlink_${version}_dist
|
||||
rm -f ./${distId}.zip
|
||||
zip -ry ./${distId}.zip ./${distId}
|
||||
cd "${projectdir}"
|
||||
rm -rf "${builtcontent}"
|
||||
rm -rf "${builtContent}"
|
||||
|
||||
echo 'Done!'
|
||||
|
||||
@@ -12,51 +12,51 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^7.4",
|
||||
"php": "^7.4 || ^8.0",
|
||||
"ext-json": "*",
|
||||
"ext-pdo": "*",
|
||||
"akrabat/ip-address-middleware": "^2.0",
|
||||
"cakephp/chronos": "^2.0",
|
||||
"cocur/slugify": "^4.0",
|
||||
"doctrine/cache": "^1.9",
|
||||
"doctrine/migrations": "^3.0.2",
|
||||
"doctrine/orm": "^2.8",
|
||||
"endroid/qr-code": "^3.6",
|
||||
"doctrine/migrations": "^3.1.1",
|
||||
"doctrine/orm": "^2.8.4",
|
||||
"endroid/qr-code": "^4.0",
|
||||
"geoip2/geoip2": "^2.9",
|
||||
"guzzlehttp/guzzle": "^7.0",
|
||||
"happyr/doctrine-specification": "2.0.x-dev#cb116d3 as 2.0",
|
||||
"happyr/doctrine-specification": "^2.0",
|
||||
"jaybizzle/crawler-detect": "^1.2",
|
||||
"laminas/laminas-config": "^3.3",
|
||||
"laminas/laminas-config-aggregator": "^1.1",
|
||||
"laminas/laminas-diactoros": "^2.1.3",
|
||||
"laminas/laminas-inputfilter": "^2.10",
|
||||
"laminas/laminas-paginator": "^2.8",
|
||||
"laminas/laminas-servicemanager": "^3.6",
|
||||
"laminas/laminas-stdlib": "^3.2",
|
||||
"lcobucci/jwt": "^4.0",
|
||||
"league/uri": "^6.2",
|
||||
"lstrojny/functional-php": "^1.15",
|
||||
"mezzio/mezzio": "^3.2",
|
||||
"lstrojny/functional-php": "^1.17",
|
||||
"mezzio/mezzio": "^3.3",
|
||||
"mezzio/mezzio-fastroute": "^3.1",
|
||||
"mezzio/mezzio-helpers": "^5.3",
|
||||
"mezzio/mezzio-problem-details": "^1.1",
|
||||
"mezzio/mezzio-swoole": "^3.1",
|
||||
"mezzio/mezzio-problem-details": "^1.3",
|
||||
"mezzio/mezzio-swoole": "^3.3",
|
||||
"monolog/monolog": "^2.0",
|
||||
"nikolaposa/monolog-factory": "^3.1",
|
||||
"ocramius/proxy-manager": "^2.11",
|
||||
"pagerfanta/core": "^2.5",
|
||||
"php-middleware/request-id": "^4.1",
|
||||
"predis/predis": "^1.1",
|
||||
"pugx/shortid-php": "^0.7",
|
||||
"ramsey/uuid": "^3.9",
|
||||
"shlinkio/shlink-common": "^3.4",
|
||||
"shlinkio/shlink-common": "^3.7",
|
||||
"shlinkio/shlink-config": "^1.0",
|
||||
"shlinkio/shlink-event-dispatcher": "^2.0",
|
||||
"shlinkio/shlink-importer": "^2.1",
|
||||
"shlinkio/shlink-installer": "^5.3",
|
||||
"shlinkio/shlink-event-dispatcher": "^2.1",
|
||||
"shlinkio/shlink-importer": "^2.3",
|
||||
"shlinkio/shlink-installer": "^6.0",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.5",
|
||||
"symfony/console": "^5.1",
|
||||
"symfony/filesystem": "^5.1",
|
||||
"symfony/lock": "^5.1",
|
||||
"symfony/mercure": "^0.4.1",
|
||||
"symfony/mercure": "^0.5.1",
|
||||
"symfony/process": "^5.1",
|
||||
"symfony/string": "^5.1"
|
||||
},
|
||||
@@ -64,14 +64,14 @@
|
||||
"devster/ubench": "^2.1",
|
||||
"dms/phpunit-arraysubset-asserts": "^0.2.1",
|
||||
"eaglewu/swoole-ide-helper": "dev-master",
|
||||
"infection/infection": "^0.20.2",
|
||||
"infection/infection": "^0.21.0",
|
||||
"phpspec/prophecy-phpunit": "^2.0",
|
||||
"phpstan/phpstan": "^0.12.64",
|
||||
"phpunit/php-code-coverage": "^9.2",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"shlinkio/php-coding-standard": "~2.1.1",
|
||||
"shlinkio/shlink-test-utils": "^2.0",
|
||||
"shlinkio/shlink-test-utils": "^2.1",
|
||||
"symfony/var-dumper": "^5.2",
|
||||
"veewee/composer-run-parallel": "^0.1.0"
|
||||
},
|
||||
@@ -125,6 +125,7 @@
|
||||
],
|
||||
"test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
|
||||
"test:unit:ci": "@test:unit --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml",
|
||||
"test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html",
|
||||
"test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
|
||||
"test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
|
||||
"test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml",
|
||||
@@ -133,7 +134,6 @@
|
||||
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
|
||||
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
|
||||
"test:api": "bin/test/run-api-tests.sh",
|
||||
"test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html",
|
||||
"infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --skip-initial-tests",
|
||||
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80",
|
||||
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json",
|
||||
|
||||
@@ -7,7 +7,6 @@ return [
|
||||
'app_options' => [
|
||||
'name' => 'Shlink',
|
||||
'version' => '%SHLINK_VERSION%',
|
||||
'disable_track_param' => null,
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Common;
|
||||
|
||||
use Happyr\DoctrineSpecification\EntitySpecificationRepository;
|
||||
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
|
||||
|
||||
return [
|
||||
|
||||
|
||||
@@ -2,14 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
|
||||
// When running tests, any mysql-specific option can interfere with other drivers
|
||||
$driverOptions = env('APP_ENV') === 'test' ? [] : [
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
|
||||
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
|
||||
];
|
||||
|
||||
return [
|
||||
|
||||
'entity_manager' => [
|
||||
@@ -18,7 +10,6 @@ return [
|
||||
'password' => 'root',
|
||||
'driver' => 'pdo_mysql',
|
||||
'host' => 'shlink_db',
|
||||
'driverOptions' => $driverOptions,
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI;
|
||||
|
||||
use Shlinkio\Shlink\Installer\Config\Option;
|
||||
use Shlinkio\Shlink\Installer\Util\InstallationCommand;
|
||||
|
||||
return [
|
||||
|
||||
@@ -24,7 +27,6 @@ return [
|
||||
Option\Redirect\BaseUrlRedirectConfigOption::class,
|
||||
Option\Redirect\InvalidShortUrlRedirectConfigOption::class,
|
||||
Option\Redirect\Regular404RedirectConfigOption::class,
|
||||
Option\DisableTrackParamConfigOption::class,
|
||||
Option\Visit\CheckVisitsThresholdConfigOption::class,
|
||||
Option\Visit\VisitsThresholdConfigOption::class,
|
||||
Option\BasePathConfigOption::class,
|
||||
@@ -37,17 +39,27 @@ return [
|
||||
Option\Mercure\MercureInternalUrlConfigOption::class,
|
||||
Option\Mercure\MercureJwtSecretConfigOption::class,
|
||||
Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class,
|
||||
Option\UrlShortener\IpAnonymizationConfigOption::class,
|
||||
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
|
||||
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
|
||||
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
|
||||
Option\Tracking\IpAnonymizationConfigOption::class,
|
||||
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
|
||||
Option\Tracking\DisableTrackParamConfigOption::class,
|
||||
Option\Tracking\DisableTrackingConfigOption::class,
|
||||
Option\Tracking\DisableIpTrackingConfigOption::class,
|
||||
Option\Tracking\DisableReferrerTrackingConfigOption::class,
|
||||
Option\Tracking\DisableUaTrackingConfigOption::class,
|
||||
],
|
||||
|
||||
'installation_commands' => [
|
||||
'db_create_schema' => [
|
||||
'command' => 'bin/cli db:create',
|
||||
InstallationCommand::DB_CREATE_SCHEMA => [
|
||||
'command' => 'bin/cli ' . Command\Db\CreateDatabaseCommand::NAME,
|
||||
],
|
||||
'db_migrate' => [
|
||||
'command' => 'bin/cli db:migrate',
|
||||
InstallationCommand::DB_MIGRATE => [
|
||||
'command' => 'bin/cli ' . Command\Db\MigrateDatabaseCommand::NAME,
|
||||
],
|
||||
InstallationCommand::GEOLITE_DOWNLOAD_DB => [
|
||||
'command' => 'bin/cli ' . Command\Visit\DownloadGeoLiteDbCommand::NAME,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
use Laminas\ServiceManager\Proxy\LazyServiceFactory;
|
||||
use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
|
||||
use Symfony\Component\Mercure\Publisher;
|
||||
use Symfony\Component\Mercure\PublisherInterface;
|
||||
use Symfony\Component\Mercure\Hub;
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
|
||||
return [
|
||||
|
||||
@@ -21,14 +21,14 @@ return [
|
||||
LcobucciJwtProvider::class => [
|
||||
LazyServiceFactory::class,
|
||||
],
|
||||
Publisher::class => [
|
||||
Hub::class => [
|
||||
LazyServiceFactory::class,
|
||||
],
|
||||
],
|
||||
'lazy_services' => [
|
||||
'class_map' => [
|
||||
LcobucciJwtProvider::class => LcobucciJwtProvider::class,
|
||||
Publisher::class => PublisherInterface::class,
|
||||
Hub::class => HubInterface::class,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -5,17 +5,18 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink;
|
||||
|
||||
use Laminas\Stratigility\Middleware\ErrorHandler;
|
||||
use Mezzio\Helper;
|
||||
use Mezzio\ProblemDetails;
|
||||
use Mezzio\Router;
|
||||
use PhpMiddleware\RequestId\RequestIdMiddleware;
|
||||
use RKA\Middleware\IpAddress;
|
||||
use Shlinkio\Shlink\Common\Middleware\ContentLengthMiddleware;
|
||||
|
||||
return [
|
||||
|
||||
'middleware_pipeline' => [
|
||||
'error-handler' => [
|
||||
'middleware' => [
|
||||
Helper\ContentLengthMiddleware::class,
|
||||
ContentLengthMiddleware::class,
|
||||
ErrorHandler::class,
|
||||
],
|
||||
],
|
||||
@@ -64,6 +65,10 @@ return [
|
||||
],
|
||||
'not-found' => [
|
||||
'middleware' => [
|
||||
// This middleware is in front of tracking actions explicitly. Putting here for orphan visits tracking
|
||||
IpAddress::class,
|
||||
Core\ErrorHandler\NotFoundTypeResolverMiddleware::class,
|
||||
Core\ErrorHandler\NotFoundTrackerMiddleware::class,
|
||||
Core\ErrorHandler\NotFoundRedirectHandler::class,
|
||||
Core\ErrorHandler\NotFoundTemplateHandler::class,
|
||||
],
|
||||
|
||||
31
config/autoload/tracking.global.php
Normal file
31
config/autoload/tracking.global.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
'tracking' => [
|
||||
// Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations
|
||||
// This applies only if IP address tracking is enabled
|
||||
'anonymize_remote_addr' => true,
|
||||
|
||||
// Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence
|
||||
'track_orphan_visits' => true,
|
||||
|
||||
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence
|
||||
'disable_track_param' => null,
|
||||
|
||||
// If true, visits will not be tracked at all
|
||||
'disable_tracking' => false,
|
||||
|
||||
// If true, visits will be tracked, but neither the IP address, nor the location will be resolved
|
||||
'disable_ip_tracking' => false,
|
||||
|
||||
// If true, the referrer will not be tracked
|
||||
'disable_referrer_tracking' => false,
|
||||
|
||||
// If true, the user agent will not be tracked
|
||||
'disable_ua_tracking' => false,
|
||||
],
|
||||
|
||||
];
|
||||
@@ -13,12 +13,12 @@ return [
|
||||
'schema' => 'https',
|
||||
'hostname' => '',
|
||||
],
|
||||
'validate_url' => false,
|
||||
'anonymize_remote_addr' => true,
|
||||
'validate_url' => false, // Deprecated
|
||||
'visits_webhooks' => [],
|
||||
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
|
||||
'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE,
|
||||
'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME,
|
||||
'auto_resolve_titles' => false,
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -8,14 +8,16 @@ use Laminas\ConfigAggregator;
|
||||
use Laminas\Diactoros;
|
||||
use Mezzio;
|
||||
use Mezzio\ProblemDetails;
|
||||
use Mezzio\Swoole\ConfigProvider as SwooleConfigProvider;
|
||||
|
||||
use function class_exists;
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
|
||||
return (new ConfigAggregator\ConfigAggregator([
|
||||
Mezzio\ConfigProvider::class,
|
||||
Mezzio\Router\ConfigProvider::class,
|
||||
Mezzio\Router\FastRouteRouter\ConfigProvider::class,
|
||||
Mezzio\Swoole\ConfigProvider::class,
|
||||
class_exists(SwooleConfigProvider::class) ? SwooleConfigProvider::class : new ConfigAggregator\ArrayProvider([]),
|
||||
ProblemDetails\ConfigProvider::class,
|
||||
Diactoros\ConfigProvider::class,
|
||||
Common\ConfigProvider::class,
|
||||
|
||||
@@ -9,7 +9,8 @@ use Laminas\ConfigAggregator\ConfigAggregator;
|
||||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use Laminas\Stdlib\Glob;
|
||||
use PDO;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Logger;
|
||||
use PHPUnit\Runner\Version;
|
||||
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
||||
use SebastianBergmann\CodeCoverage\Driver\Selector;
|
||||
@@ -53,10 +54,6 @@ $buildDbConnection = function (): array {
|
||||
'password' => 'root',
|
||||
'dbname' => 'shlink_test',
|
||||
'charset' => 'utf8',
|
||||
'driverOptions' => [
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
|
||||
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
|
||||
],
|
||||
],
|
||||
'postgres' => [
|
||||
'driver' => 'pdo_pgsql',
|
||||
@@ -80,6 +77,18 @@ $buildDbConnection = function (): array {
|
||||
return $driverConfigMap[$driver] ?? [];
|
||||
};
|
||||
|
||||
$buildTestLoggerConfig = fn (string $handlerName, string $filename) => [
|
||||
'handlers' => [
|
||||
$handlerName => [
|
||||
'name' => StreamHandler::class,
|
||||
'params' => [
|
||||
'level' => Logger::DEBUG,
|
||||
'stream' => sprintf('data/log/api-tests/%s', $filename),
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
return [
|
||||
|
||||
'debug' => true,
|
||||
@@ -163,4 +172,9 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'logger' => [
|
||||
'Shlink' => $buildTestLoggerConfig('shlink_handler', 'shlink.log'),
|
||||
'Access' => $buildTestLoggerConfig('access_handler', 'access.log'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
FROM php:7.4.11-fpm-alpine3.12
|
||||
FROM php:8.0.6-fpm-alpine3.13
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.18
|
||||
ENV APCU_BC_VERSION 1.0.5
|
||||
ENV APCU_VERSION 5.1.19
|
||||
ENV PDO_SQLSRV_VERSION 5.9.0
|
||||
ENV MS_ODBC_SQL_VERSION 17.5.2.1
|
||||
|
||||
RUN apk update
|
||||
|
||||
@@ -35,36 +36,22 @@ RUN docker-php-ext-install gmp
|
||||
|
||||
# Install APCu extension
|
||||
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
|
||||
RUN mkdir -p /usr/src/php/ext/apcu\
|
||||
&& tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1
|
||||
# configure and install
|
||||
RUN docker-php-ext-configure apcu\
|
||||
&& docker-php-ext-install apcu
|
||||
# cleanup
|
||||
RUN rm /tmp/apcu.tar.gz
|
||||
|
||||
# Install APCu-BC extension
|
||||
ADD https://pecl.php.net/get/apcu_bc-$APCU_BC_VERSION.tgz /tmp/apcu_bc.tar.gz
|
||||
RUN mkdir -p /usr/src/php/ext/apcu-bc\
|
||||
&& tar xf /tmp/apcu_bc.tar.gz -C /usr/src/php/ext/apcu-bc --strip-components=1
|
||||
# configure and install
|
||||
RUN docker-php-ext-configure apcu-bc\
|
||||
&& docker-php-ext-install apcu-bc
|
||||
# cleanup
|
||||
RUN rm /tmp/apcu_bc.tar.gz
|
||||
|
||||
# Load APCU.ini before APC.ini
|
||||
RUN rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini
|
||||
RUN echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
|
||||
RUN mkdir -p /usr/src/php/ext/apcu \
|
||||
&& tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \
|
||||
&& docker-php-ext-configure apcu \
|
||||
&& docker-php-ext-install apcu \
|
||||
&& rm /tmp/apcu.tar.gz \
|
||||
&& rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \
|
||||
&& echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
|
||||
|
||||
# Install pcov and sqlsrv driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install pdo_sqlsrv pcov && \
|
||||
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
|
||||
docker-php-ext-enable pdo_sqlsrv pcov && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk
|
||||
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk
|
||||
|
||||
# Install composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
FROM php:7.4.11-alpine3.12
|
||||
FROM php:8.0.6-alpine3.13
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.18
|
||||
ENV APCU_BC_VERSION 1.0.5
|
||||
ENV INOTIFY_VERSION 2.0.0
|
||||
ENV SWOOLE_VERSION 4.5.9
|
||||
ENV APCU_VERSION 5.1.19
|
||||
ENV PDO_SQLSRV_VERSION 5.9.0
|
||||
ENV INOTIFY_VERSION 3.0.0
|
||||
ENV SWOOLE_VERSION 4.6.7
|
||||
ENV MS_ODBC_SQL_VERSION 17.5.2.1
|
||||
|
||||
RUN apk update
|
||||
|
||||
@@ -37,46 +38,30 @@ RUN docker-php-ext-install gmp
|
||||
|
||||
# Install APCu extension
|
||||
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
|
||||
RUN mkdir -p /usr/src/php/ext/apcu\
|
||||
&& tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1
|
||||
# configure and install
|
||||
RUN docker-php-ext-configure apcu\
|
||||
&& docker-php-ext-install apcu
|
||||
# cleanup
|
||||
RUN rm /tmp/apcu.tar.gz
|
||||
|
||||
# Install APCu-BC extension
|
||||
ADD https://pecl.php.net/get/apcu_bc-$APCU_BC_VERSION.tgz /tmp/apcu_bc.tar.gz
|
||||
RUN mkdir -p /usr/src/php/ext/apcu-bc\
|
||||
&& tar xf /tmp/apcu_bc.tar.gz -C /usr/src/php/ext/apcu-bc --strip-components=1
|
||||
# configure and install
|
||||
RUN docker-php-ext-configure apcu-bc\
|
||||
&& docker-php-ext-install apcu-bc
|
||||
# cleanup
|
||||
RUN rm /tmp/apcu_bc.tar.gz
|
||||
|
||||
# Load APCU.ini before APC.ini
|
||||
RUN rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini
|
||||
RUN echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
|
||||
RUN mkdir -p /usr/src/php/ext/apcu \
|
||||
&& tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \
|
||||
&& docker-php-ext-configure apcu \
|
||||
&& docker-php-ext-install apcu \
|
||||
&& rm /tmp/apcu.tar.gz \
|
||||
&& rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \
|
||||
&& echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
|
||||
|
||||
# Install inotify extension
|
||||
ADD https://pecl.php.net/get/inotify-$INOTIFY_VERSION.tgz /tmp/inotify.tar.gz
|
||||
RUN mkdir -p /usr/src/php/ext/inotify\
|
||||
&& tar xf /tmp/inotify.tar.gz -C /usr/src/php/ext/inotify --strip-components=1
|
||||
# configure and install
|
||||
RUN docker-php-ext-configure inotify\
|
||||
&& docker-php-ext-install inotify
|
||||
# cleanup
|
||||
RUN rm /tmp/inotify.tar.gz
|
||||
RUN mkdir -p /usr/src/php/ext/inotify \
|
||||
&& tar xf /tmp/inotify.tar.gz -C /usr/src/php/ext/inotify --strip-components=1 \
|
||||
&& docker-php-ext-configure inotify \
|
||||
&& docker-php-ext-install inotify \
|
||||
&& rm /tmp/inotify.tar.gz
|
||||
|
||||
# Install swoole, pcov and mssql driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv pcov && \
|
||||
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
|
||||
docker-php-ext-enable swoole pdo_sqlsrv pcov && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk
|
||||
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk
|
||||
|
||||
# Install composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\DBALException;
|
||||
use Doctrine\DBAL\Exception;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Schema\SchemaException;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
@@ -18,7 +18,7 @@ class Version20160819142757 extends AbstractMigration
|
||||
private const SQLITE = 'sqlite';
|
||||
|
||||
/**
|
||||
* @throws DBALException
|
||||
* @throws Exception
|
||||
* @throws SchemaException
|
||||
*/
|
||||
public function up(Schema $schema): void
|
||||
@@ -35,7 +35,7 @@ class Version20160819142757 extends AbstractMigration
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DBALException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\DBALException;
|
||||
use Doctrine\DBAL\Exception;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
use PDO;
|
||||
@@ -16,15 +16,13 @@ use Shlinkio\Shlink\Common\Util\IpAddress;
|
||||
*/
|
||||
final class Version20180913205455 extends AbstractMigration
|
||||
{
|
||||
/**
|
||||
*/
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Nothing to create
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DBALException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function postUp(Schema $schema): void
|
||||
{
|
||||
@@ -64,8 +62,6 @@ final class Version20180913205455 extends AbstractMigration
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Nothing to rollback
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\DBALException;
|
||||
use Doctrine\DBAL\Exception;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Schema\SchemaException;
|
||||
use Doctrine\DBAL\Schema\Table;
|
||||
@@ -42,7 +42,7 @@ final class Version20181020060559 extends AbstractMigration
|
||||
|
||||
/**
|
||||
* @throws SchemaException
|
||||
* @throws DBALException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function postUp(Schema $schema): void
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\DBALException;
|
||||
use Doctrine\DBAL\Exception;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
@@ -16,7 +16,7 @@ final class Version20200105165647 extends AbstractMigration
|
||||
private const COLUMNS = ['lat' => 'latitude', 'lon' => 'longitude'];
|
||||
|
||||
/**
|
||||
* @throws DBALException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function preUp(Schema $schema): void
|
||||
{
|
||||
@@ -43,7 +43,7 @@ final class Version20200105165647 extends AbstractMigration
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DBALException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
@@ -57,7 +57,7 @@ final class Version20200105165647 extends AbstractMigration
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DBALException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function postUp(Schema $schema): void
|
||||
{
|
||||
@@ -83,7 +83,7 @@ final class Version20200105165647 extends AbstractMigration
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DBALException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\DBALException;
|
||||
use Doctrine\DBAL\Exception;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
@@ -16,7 +16,7 @@ final class Version20200106215144 extends AbstractMigration
|
||||
private const COLUMNS = ['latitude', 'longitude'];
|
||||
|
||||
/**
|
||||
* @throws DBALException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
@@ -32,7 +32,7 @@ final class Version20200106215144 extends AbstractMigration
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DBALException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
|
||||
36
data/migrations/Version20210202181026.php
Normal file
36
data/migrations/Version20210202181026.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20210202181026 extends AbstractMigration
|
||||
{
|
||||
private const TITLE = 'title';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$shortUrls = $schema->getTable('short_urls');
|
||||
$this->skipIf($shortUrls->hasColumn(self::TITLE));
|
||||
|
||||
$shortUrls->addColumn(self::TITLE, Types::STRING, [
|
||||
'notnull' => false,
|
||||
'length' => 512,
|
||||
]);
|
||||
$shortUrls->addColumn('title_was_auto_resolved', Types::BOOLEAN, [
|
||||
'default' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$shortUrls = $schema->getTable('short_urls');
|
||||
$this->skipIf(! $shortUrls->hasColumn(self::TITLE));
|
||||
$shortUrls->dropColumn(self::TITLE);
|
||||
$shortUrls->dropColumn('title_was_auto_resolved');
|
||||
}
|
||||
}
|
||||
43
data/migrations/Version20210207100807.php
Normal file
43
data/migrations/Version20210207100807.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
|
||||
final class Version20210207100807 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$visits = $schema->getTable('visits');
|
||||
$this->skipIf($visits->hasColumn('visited_url'));
|
||||
|
||||
$shortUrlId = $visits->getColumn('short_url_id');
|
||||
$shortUrlId->setNotnull(false);
|
||||
|
||||
$visits->addColumn('visited_url', Types::STRING, [
|
||||
'length' => Visitor::VISITED_URL_MAX_LENGTH,
|
||||
'notnull' => false,
|
||||
]);
|
||||
$visits->addColumn('type', Types::STRING, [
|
||||
'length' => 255,
|
||||
'default' => Visit::TYPE_VALID_SHORT_URL,
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$visits = $schema->getTable('visits');
|
||||
$this->skipIf(! $visits->hasColumn('visited_url'));
|
||||
|
||||
$shortUrlId = $visits->getColumn('short_url_id');
|
||||
$shortUrlId->setNotnull(true);
|
||||
$visits->dropColumn('visited_url');
|
||||
$visits->dropColumn('type');
|
||||
}
|
||||
}
|
||||
37
data/migrations/Version20210306165711.php
Normal file
37
data/migrations/Version20210306165711.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20210306165711 extends AbstractMigration
|
||||
{
|
||||
private const TABLE = 'api_keys';
|
||||
private const COLUMN = 'name';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$apiKeys = $schema->getTable(self::TABLE);
|
||||
$this->skipIf($apiKeys->hasColumn(self::COLUMN));
|
||||
|
||||
$apiKeys->addColumn(
|
||||
self::COLUMN,
|
||||
Types::STRING,
|
||||
[
|
||||
'notnull' => false,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$apiKeys = $schema->getTable(self::TABLE);
|
||||
$this->skipIf(! $apiKeys->hasColumn(self::COLUMN));
|
||||
|
||||
$apiKeys->dropColumn(self::COLUMN);
|
||||
}
|
||||
}
|
||||
26
data/migrations/Version20210522051601.php
Normal file
26
data/migrations/Version20210522051601.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 Version20210522051601 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$shortUrls = $schema->getTable('short_urls');
|
||||
$this->skipIf($shortUrls->hasColumn('crawlable'));
|
||||
$shortUrls->addColumn('crawlable', Types::BOOLEAN, ['default' => false]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$shortUrls = $schema->getTable('short_urls');
|
||||
$this->skipIf(! $shortUrls->hasColumn('crawlable'));
|
||||
$shortUrls->dropColumn('crawlable');
|
||||
}
|
||||
}
|
||||
28
data/migrations/Version20210522124633.php
Normal file
28
data/migrations/Version20210522124633.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20210522124633 extends AbstractMigration
|
||||
{
|
||||
private const POTENTIAL_BOT_COLUMN = 'potential_bot';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$visits = $schema->getTable('visits');
|
||||
$this->skipIf($visits->hasColumn(self::POTENTIAL_BOT_COLUMN));
|
||||
$visits->addColumn(self::POTENTIAL_BOT_COLUMN, Types::BOOLEAN, ['default' => false]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$visits = $schema->getTable('visits');
|
||||
$this->skipIf(! $visits->hasColumn(self::POTENTIAL_BOT_COLUMN));
|
||||
$visits->dropColumn(self::POTENTIAL_BOT_COLUMN);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ version: '3'
|
||||
services:
|
||||
shlink_nginx:
|
||||
container_name: shlink_nginx
|
||||
image: nginx:1.17.10-alpine
|
||||
image: nginx:1.19.6-alpine
|
||||
ports:
|
||||
- "8000:80"
|
||||
volumes:
|
||||
@@ -34,7 +34,7 @@ services:
|
||||
|
||||
shlink_swoole_proxy:
|
||||
container_name: shlink_swoole_proxy
|
||||
image: nginx:1.17.10-alpine
|
||||
image: nginx:1.19.6-alpine
|
||||
ports:
|
||||
- "8002:80"
|
||||
volumes:
|
||||
@@ -120,7 +120,7 @@ services:
|
||||
|
||||
shlink_mercure_proxy:
|
||||
container_name: shlink_mercure_proxy
|
||||
image: nginx:1.17.10-alpine
|
||||
image: nginx:1.19.6-alpine
|
||||
ports:
|
||||
- "8001:80"
|
||||
volumes:
|
||||
|
||||
@@ -42,12 +42,6 @@ $helper = new class {
|
||||
];
|
||||
}
|
||||
|
||||
$driverOptions = ! $isMysql ? [] : [
|
||||
// 1002 -> PDO::MYSQL_ATTR_INIT_COMMAND
|
||||
1002 => 'SET NAMES utf8',
|
||||
// 1000 -> PDO::MYSQL_ATTR_USE_BUFFERED_QUERY
|
||||
1000 => true,
|
||||
];
|
||||
return [
|
||||
'driver' => self::DB_DRIVERS_MAP[$driver],
|
||||
'dbname' => env('DB_NAME', 'shlink'),
|
||||
@@ -55,7 +49,6 @@ $helper = new class {
|
||||
'password' => env('DB_PASSWORD'),
|
||||
'host' => env('DB_HOST', $driver === 'postgres' ? env('DB_UNIX_SOCKET') : null),
|
||||
'port' => env('DB_PORT', self::DB_PORTS_MAP[$driver]),
|
||||
'driverOptions' => $driverOptions,
|
||||
'unix_socket' => $isMysql ? env('DB_UNIX_SOCKET') : null,
|
||||
];
|
||||
}
|
||||
@@ -101,10 +94,6 @@ $helper = new class {
|
||||
|
||||
return [
|
||||
|
||||
'app_options' => [
|
||||
'disable_track_param' => env('DISABLE_TRACK_PARAM'),
|
||||
],
|
||||
|
||||
'delete_short_urls' => [
|
||||
'check_visits_threshold' => true,
|
||||
'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', DEFAULT_DELETE_SHORT_URL_THRESHOLD),
|
||||
@@ -120,11 +109,21 @@ return [
|
||||
'hostname' => env('SHORT_DOMAIN_HOST', ''),
|
||||
],
|
||||
'validate_url' => (bool) env('VALIDATE_URLS', false),
|
||||
'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true),
|
||||
'visits_webhooks' => $helper->getVisitsWebhooks(),
|
||||
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
|
||||
'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
|
||||
'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
|
||||
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', 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(),
|
||||
@@ -168,7 +167,7 @@ return [
|
||||
],
|
||||
|
||||
'geolite2' => [
|
||||
'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'),
|
||||
'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'), // Deprecated. Remove the default value
|
||||
],
|
||||
|
||||
'mercure' => $helper->getMercureConfig(),
|
||||
|
||||
@@ -15,6 +15,12 @@ php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies -n -q
|
||||
echo "Clearing entities cache..."
|
||||
php vendor/doctrine/orm/bin/doctrine.php orm:clear-cache:metadata -n -q
|
||||
|
||||
# Try to download GeoLite2 db file only if the license key env var was defined
|
||||
if [ ! -z "${GEOLITE_LICENSE_KEY}" ]; then
|
||||
echo "Downloading GeoLite2 db file..."
|
||||
php bin/cli visit:download-db -n -q
|
||||
fi
|
||||
|
||||
# When restarting the container, swoole might think it is already in execution
|
||||
# This forces the app to be started every second until the exit code is 0
|
||||
until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Support restrictions and permissions in API keys
|
||||
|
||||
* Status: Accepted
|
||||
* Date: 2021-01-17
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
Historically, every API key generated for Shlink granted you access to all existing resources.
|
||||
|
||||
The intention is to be able to apply some form of restriction to API keys, so that only a subset of "resources" can be accessed with it, naming:
|
||||
|
||||
* Allowing interactions only with short URLs and related resources, that have been created with the same API key.
|
||||
* Allowing interactions only with short URLs and related resources, that have been attached to a specific domain.
|
||||
|
||||
The intention is to implement a system that allows adding to API keys as many of these restrictions as wanted.
|
||||
|
||||
Supporting more restrictions in the future is also desirable.
|
||||
|
||||
## Considered option
|
||||
|
||||
* Using an ACL/RBAC library, and checking roles in a middleware.
|
||||
* Using a service that, provided an API key, tells if certain resource is reachable while it also allows building queries dynamically.
|
||||
* Using some library implementing the specification pattern, to dynamically build queries transparently for outer layers.
|
||||
|
||||
## Decision outcome
|
||||
|
||||
The main difficulty on implementing this is that the entity conditioning the behavior (the API key) comes in the request in some form, but it can potentially affect database queries performed in the persistence layer.
|
||||
|
||||
Because of this, it has to traverse all the application layers from top to bottom, in most of the cases.
|
||||
|
||||
This motivated selecting the third option, as we can propagate the API key and delay its handling to the last step, without changing the behavior of the rest of the layers that much (except in some individual use cases).
|
||||
|
||||
The domain term used to refer these "restrictions" is finally **roles**.
|
||||
|
||||
It can be combined in the future with an ACL/RBAC library, if we want to restrict access to certain resources, but it didn't fulfil the initial requirements.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### An ACL/RBAC library
|
||||
|
||||
* Good, because there are many good libraries out there.
|
||||
* Bad, because when you need to filter resources lists this kind of libraries doesn't really work.
|
||||
|
||||
### A service with the logic
|
||||
|
||||
* Bad, because it would need to be used in many layers of the application, mixing unrelated concerns.
|
||||
|
||||
### A library implementing the specification pattern
|
||||
|
||||
* Good, because allows centralizing the generation of dynamic specs by the entity itself, that are later translated automatically into database queries.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Track visits to 'base_url', 'invalid_short_url' and 'regular_404'
|
||||
|
||||
* Status: Accepted
|
||||
* Date: 2021-02-07
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
Shlink has the mechanism to return either custom errors or custom redirects when visiting the instance's base URL, an invalid short URL, or any other kind of URL that would result in a "Not found" error.
|
||||
|
||||
However, it does not track visits to any of those, just to valid short URLs.
|
||||
|
||||
The intention is to change that, and allow users to track the cases mentioned above.
|
||||
|
||||
## Considered option
|
||||
|
||||
* Create a new table to track visits o this kind.
|
||||
* Reuse the existing `visits` table, by making `short_url_id` nullable and adding a couple of other fields.
|
||||
|
||||
## Decision outcome
|
||||
|
||||
The decision is to use the existing table, as making the short URL nullable can be handled seamlessly by using named constructors, and it has a lot of benefits on regards of reusing existing components.
|
||||
|
||||
Also, the domain name this kind of visits will receive is "Orphan Visits", as they are detached from any existing short URL.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### New table
|
||||
|
||||
* Good because we don't touch existing models and tables, reducing the risk to introduce a backwards compatibility break.
|
||||
* Bad because we will have to repeat data modeling and logic, or refactor some components to support both contexts. This in turn increases the options to introduce a BC break.
|
||||
|
||||
### Reuse existing table
|
||||
|
||||
* Good because all the mechanisms in place to handle visits will work out of the box, including locating visits and such.
|
||||
* Bad because we will have more optional properties, which means more double checks in many places.
|
||||
6
docs/adr/README.md
Normal file
6
docs/adr/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Architectural Decision Records
|
||||
|
||||
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-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)
|
||||
@@ -58,6 +58,23 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"http://shlink.io/new-orphan-visit": {
|
||||
"subscribe": {
|
||||
"summary": "Receive information about any new orphan visit.",
|
||||
"operationId": "newOrphanVisit",
|
||||
"message": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"visit": {
|
||||
"$ref": "#/components/schemas/OrphanVisit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -99,6 +116,15 @@
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"description": "The domain in which the short URL was created. Null if it belongs to default domain."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
@@ -116,7 +142,9 @@
|
||||
"validUntil": null,
|
||||
"maxVisits": 100
|
||||
},
|
||||
"domain": "example.com"
|
||||
"domain": "example.com",
|
||||
"title": "The title",
|
||||
"crawlable": false
|
||||
}
|
||||
},
|
||||
"ShortUrlMeta": {
|
||||
@@ -162,6 +190,10 @@
|
||||
},
|
||||
"visitLocation": {
|
||||
"$ref": "#/components/schemas/VisitLocation"
|
||||
},
|
||||
"potentialBot": {
|
||||
"type": "boolean",
|
||||
"description": "Tells if Shlink thinks this visit comes potentially from a bot or crawler"
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
@@ -176,7 +208,49 @@
|
||||
"longitude": -122.0946,
|
||||
"regionName": "California",
|
||||
"timezone": "America/Los_Angeles"
|
||||
},
|
||||
"potentialBot": false
|
||||
}
|
||||
},
|
||||
"OrphanVisit": {
|
||||
"allOf": [
|
||||
{"$ref": "#/components/schemas/Visit"},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"visitedUrl": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "The originally visited URL that triggered the tracking of this visit"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_short_url",
|
||||
"base_url",
|
||||
"regular_404"
|
||||
],
|
||||
"description": "Tells the type of orphan visit"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"example": {
|
||||
"referer": "https://t.co",
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
|
||||
"visitLocation": {
|
||||
"cityName": "Cupertino",
|
||||
"countryCode": "US",
|
||||
"countryName": "United States",
|
||||
"latitude": 37.3042,
|
||||
"longitude": -122.0946,
|
||||
"regionName": "California",
|
||||
"timezone": "America/Los_Angeles"
|
||||
},
|
||||
"potentialBot": false,
|
||||
"visitedUrl": "https://doma.in",
|
||||
"type": "base_url"
|
||||
}
|
||||
},
|
||||
"VisitLocation": {
|
||||
|
||||
23
docs/swagger/definitions/OrphanVisit.json
Normal file
23
docs/swagger/definitions/OrphanVisit.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["visitedUrl", "type"],
|
||||
"allOf": [{
|
||||
"$ref": "./Visit.json"
|
||||
}],
|
||||
"properties": {
|
||||
"visitedUrl": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "The originally visited URL that triggered the tracking of this visit"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_short_url",
|
||||
"base_url",
|
||||
"regular_404"
|
||||
],
|
||||
"description": "Tells the type of orphan visit"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,17 @@
|
||||
},
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "The domain in which the short URL was created. Null if it belongs to default domain."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["referer", "date", "userAgent", "visitLocation"],
|
||||
"properties": {
|
||||
"referer": {
|
||||
"type": "string",
|
||||
@@ -16,6 +17,10 @@
|
||||
},
|
||||
"visitLocation": {
|
||||
"$ref": "./VisitLocation.json"
|
||||
},
|
||||
"potentialBot": {
|
||||
"type": "boolean",
|
||||
"description": "Tells if Shlink thinks this visit comes potentially from a bot or crawler"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["visitsCount"],
|
||||
"required": ["visitsCount", "orphanVisitsCount"],
|
||||
"properties": {
|
||||
"visitsCount": {
|
||||
"type": "number",
|
||||
"description": "The total amount of visits received."
|
||||
"description": "The total amount of visits received on any short URL."
|
||||
},
|
||||
"orphanVisitsCount": {
|
||||
"type": "number",
|
||||
"description": "The total amount of visits that could not be matched to a short URL (visits to the base URL, an invalid short URL or any other kind of 404)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,15 @@
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "itemsPerPage",
|
||||
"in": "query",
|
||||
"description": "The amount of items to return on every page. Defaults to 10",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "searchTerm",
|
||||
"in": "query",
|
||||
@@ -55,7 +64,9 @@
|
||||
"dateCreated-ASC",
|
||||
"dateCreated-DESC",
|
||||
"visits-ASC",
|
||||
"visits-DESC"
|
||||
"visits-DESC",
|
||||
"title-ASC",
|
||||
"title-DESC"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -128,7 +139,9 @@
|
||||
"validUntil": null,
|
||||
"maxVisits": 100
|
||||
},
|
||||
"domain": null
|
||||
"domain": null,
|
||||
"title": "Welcome to Steam",
|
||||
"crawlable": false
|
||||
},
|
||||
{
|
||||
"shortCode": "12Kb3",
|
||||
@@ -144,7 +157,9 @@
|
||||
"validUntil": null,
|
||||
"maxVisits": null
|
||||
},
|
||||
"domain": null
|
||||
"domain": null,
|
||||
"title": null,
|
||||
"crawlable": false
|
||||
},
|
||||
{
|
||||
"shortCode": "123bA",
|
||||
@@ -158,7 +173,9 @@
|
||||
"validUntil": null,
|
||||
"maxVisits": null
|
||||
},
|
||||
"domain": "example.com"
|
||||
"domain": "example.com",
|
||||
"title": null,
|
||||
"crawlable": false
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
@@ -255,6 +272,14 @@
|
||||
"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",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,7 +312,9 @@
|
||||
"validUntil": null,
|
||||
"maxVisits": 500
|
||||
},
|
||||
"domain": null
|
||||
"domain": null,
|
||||
"title": null,
|
||||
"crawlable": false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -73,7 +73,9 @@
|
||||
"validUntil": null,
|
||||
"maxVisits": 100
|
||||
},
|
||||
"domain": null
|
||||
"domain": null,
|
||||
"title": null,
|
||||
"crawlable": false
|
||||
},
|
||||
"text/plain": "https://doma.in/abc123"
|
||||
}
|
||||
|
||||
@@ -53,7 +53,9 @@
|
||||
"validUntil": null,
|
||||
"maxVisits": 100
|
||||
},
|
||||
"domain": null
|
||||
"domain": null,
|
||||
"title": null,
|
||||
"crawlable": false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -118,19 +120,38 @@
|
||||
},
|
||||
"validSince": {
|
||||
"description": "The date (in ISO-8601 format) from which this short code will be valid",
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"validUntil": {
|
||||
"description": "The date (in ISO-8601 format) until which this short code will be valid",
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"maxVisits": {
|
||||
"description": "The maximum number of allowed visits for this short code",
|
||||
"type": "number"
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,8 +164,35 @@
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "The short code has been properly updated."
|
||||
"200": {
|
||||
"description": "The short URL has been properly updated.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/ShortUrl.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"application/json": {
|
||||
"shortCode": "12Kb3",
|
||||
"shortUrl": "https://doma.in/12Kb3",
|
||||
"longUrl": "https://shlink.io",
|
||||
"dateCreated": "2016-05-01T20:34:16+02:00",
|
||||
"visitsCount": 1029,
|
||||
"tags": [
|
||||
"shlink"
|
||||
],
|
||||
"meta": {
|
||||
"validSince": "2017-01-21T00:00:00+02:00",
|
||||
"validUntil": null,
|
||||
"maxVisits": 100
|
||||
},
|
||||
"domain": null,
|
||||
"title": "Shlink - The URL shortener",
|
||||
"crawlable": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Provided meta arguments are invalid.",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"put": {
|
||||
"deprecated": true,
|
||||
"operationId": "editShortUrlTags",
|
||||
"tags": [
|
||||
"Short URLs"
|
||||
],
|
||||
"summary": "Edit tags on short URL",
|
||||
"description": "Edit the tags on URL identified by provided short code.",
|
||||
"description": "Edit the tags on URL identified by provided short code.<br />This endpoint is deprecated. Use the [Edit short URL](#/Short%20URLs/editShortUrl) endpoint to edit tags.",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
|
||||
@@ -57,6 +57,16 @@
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "excludeBots",
|
||||
"in": "query",
|
||||
"description": "Tells if visits from potential bots should be excluded from the result set",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["true"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
@@ -98,7 +108,8 @@
|
||||
"referer": "https://twitter.com",
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
|
||||
"visitLocation": null
|
||||
"visitLocation": null,
|
||||
"potentialBot": false
|
||||
},
|
||||
{
|
||||
"referer": "https://t.co",
|
||||
@@ -112,13 +123,15 @@
|
||||
"longitude": -122.0946,
|
||||
"regionName": "California",
|
||||
"timezone": "America/Los_Angeles"
|
||||
}
|
||||
},
|
||||
"potentialBot": false
|
||||
},
|
||||
{
|
||||
"referer": null,
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "some_web_crawler/1.4",
|
||||
"visitLocation": null
|
||||
"visitLocation": null,
|
||||
"potentialBot": true
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
|
||||
@@ -54,6 +54,16 @@
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "excludeBots",
|
||||
"in": "query",
|
||||
"description": "Tells if visits from potential bots should be excluded from the result set",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["true"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
@@ -95,7 +105,8 @@
|
||||
"referer": "https://twitter.com",
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
|
||||
"visitLocation": null
|
||||
"visitLocation": null,
|
||||
"potentialBot": false
|
||||
},
|
||||
{
|
||||
"referer": "https://t.co",
|
||||
@@ -109,13 +120,15 @@
|
||||
"longitude": -122.0946,
|
||||
"regionName": "California",
|
||||
"timezone": "America/Los_Angeles"
|
||||
}
|
||||
},
|
||||
"potentialBot": false
|
||||
},
|
||||
{
|
||||
"referer": null,
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "some_web_crawler/1.4",
|
||||
"visitLocation": null
|
||||
"visitLocation": null,
|
||||
"potentialBot": true
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"examples": {
|
||||
"application/json": {
|
||||
"visits": {
|
||||
"visitsCount": 1569874
|
||||
"visitsCount": 1569874,
|
||||
"orphanVisitsCount": 71345
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
154
docs/swagger/paths/v2_visits_orphan.json
Normal file
154
docs/swagger/paths/v2_visits_orphan.json
Normal file
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"get": {
|
||||
"operationId": "getOrphanVisits",
|
||||
"tags": [
|
||||
"Visits"
|
||||
],
|
||||
"summary": "List orphan visits",
|
||||
"description": "Get the list of visits to invalid short URLs, the base URL or any other 404.",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
},
|
||||
{
|
||||
"name": "startDate",
|
||||
"in": "query",
|
||||
"description": "The date (in ISO-8601 format) from which we want to get visits.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "endDate",
|
||||
"in": "query",
|
||||
"description": "The date (in ISO-8601 format) until which we want to get visits.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"description": "The page to display. Defaults to 1",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "itemsPerPage",
|
||||
"in": "query",
|
||||
"description": "The amount of items to return on every page. Defaults to all the items",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "excludeBots",
|
||||
"in": "query",
|
||||
"description": "Tells if visits from potential bots should be excluded from the result set",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["true"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of visits.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"visits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "../definitions/OrphanVisit.json"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"$ref": "../definitions/Pagination.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"application/json": {
|
||||
"visits": {
|
||||
"data": [
|
||||
{
|
||||
"referer": "https://twitter.com",
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
|
||||
"visitLocation": null,
|
||||
"potentialBot": false,
|
||||
"visitedUrl": "https://doma.in",
|
||||
"type": "base_url"
|
||||
},
|
||||
{
|
||||
"referer": "https://t.co",
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
|
||||
"visitLocation": {
|
||||
"cityName": "Cupertino",
|
||||
"countryCode": "US",
|
||||
"countryName": "United States",
|
||||
"latitude": 37.3042,
|
||||
"longitude": -122.0946,
|
||||
"regionName": "California",
|
||||
"timezone": "America/Los_Angeles"
|
||||
},
|
||||
"potentialBot": false,
|
||||
"visitedUrl": "https://doma.in/foo",
|
||||
"type": "invalid_short_url"
|
||||
},
|
||||
{
|
||||
"referer": null,
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "some_web_crawler/1.4",
|
||||
"visitLocation": null,
|
||||
"potentialBot": true,
|
||||
"visitedUrl": "https://doma.in/foo/bar/baz",
|
||||
"type": "regular_404"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"currentPage": 5,
|
||||
"pagesCount": 12,
|
||||
"itemsPerPage": 10,
|
||||
"itemsInCurrentPage": 10,
|
||||
"totalItems": 115
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Unexpected error.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,17 @@
|
||||
"svg"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "margin",
|
||||
"in": "query",
|
||||
"description": "The margin around the QR code image.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"default": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
||||
@@ -95,6 +95,9 @@
|
||||
"/rest/v{version}/tags/{tag}/visits": {
|
||||
"$ref": "paths/v2_tags_{tag}_visits.json"
|
||||
},
|
||||
"/rest/v{version}/visits/orphan": {
|
||||
"$ref": "paths/v2_visits_orphan.json"
|
||||
},
|
||||
|
||||
"/rest/v{version}/domains": {
|
||||
"$ref": "paths/v2_domains.json"
|
||||
|
||||
@@ -15,6 +15,7 @@ return [
|
||||
Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class,
|
||||
|
||||
Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class,
|
||||
Command\Visit\DownloadGeoLiteDbCommand::NAME => Command\Visit\DownloadGeoLiteDbCommand::class,
|
||||
|
||||
Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class,
|
||||
Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,
|
||||
|
||||
@@ -11,6 +11,8 @@ use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainService;
|
||||
use Shlinkio\Shlink\Core\Service;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
|
||||
use Shlinkio\Shlink\Core\Tag\TagService;
|
||||
use Shlinkio\Shlink\Core\Visit;
|
||||
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
|
||||
@@ -32,6 +34,8 @@ return [
|
||||
PhpExecutableFinder::class => InvokableFactory::class,
|
||||
|
||||
Util\GeolocationDbUpdater::class => ConfigAbstractFactory::class,
|
||||
Util\ProcessRunner::class => ConfigAbstractFactory::class,
|
||||
|
||||
ApiKey\RoleResolver::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\ShortUrl\GenerateShortUrlCommand::class => ConfigAbstractFactory::class,
|
||||
@@ -40,6 +44,7 @@ return [
|
||||
Command\ShortUrl\GetVisitsCommand::class => ConfigAbstractFactory::class,
|
||||
Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\Visit\DownloadGeoLiteDbCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
|
||||
@@ -60,23 +65,27 @@ return [
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
Util\GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY],
|
||||
Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class],
|
||||
ApiKey\RoleResolver::class => [DomainService::class],
|
||||
|
||||
Command\ShortUrl\GenerateShortUrlCommand::class => [
|
||||
Service\UrlShortener::class,
|
||||
'config.url_shortener.domain',
|
||||
ShortUrlStringifier::class,
|
||||
'config.url_shortener.default_short_codes_length',
|
||||
],
|
||||
Command\ShortUrl\ResolveUrlCommand::class => [Service\ShortUrl\ShortUrlResolver::class],
|
||||
Command\ShortUrl\ListShortUrlsCommand::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'],
|
||||
Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class],
|
||||
Command\ShortUrl\ListShortUrlsCommand::class => [
|
||||
Service\ShortUrlService::class,
|
||||
ShortUrlDataTransformer::class,
|
||||
],
|
||||
Command\ShortUrl\GetVisitsCommand::class => [Visit\VisitsStatsHelper::class],
|
||||
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
|
||||
|
||||
Command\Visit\DownloadGeoLiteDbCommand::class => [Util\GeolocationDbUpdater::class],
|
||||
Command\Visit\LocateVisitsCommand::class => [
|
||||
Visit\VisitLocator::class,
|
||||
IpLocationResolverInterface::class,
|
||||
LockFactory::class,
|
||||
Util\GeolocationDbUpdater::class,
|
||||
],
|
||||
|
||||
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
|
||||
@@ -92,14 +101,14 @@ return [
|
||||
|
||||
Command\Db\CreateDatabaseCommand::class => [
|
||||
LockFactory::class,
|
||||
SymfonyCli\Helper\ProcessHelper::class,
|
||||
Util\ProcessRunner::class,
|
||||
PhpExecutableFinder::class,
|
||||
Connection::class,
|
||||
NoDbNameConnectionFactory::SERVICE_NAME,
|
||||
],
|
||||
Command\Db\MigrateDatabaseCommand::class => [
|
||||
LockFactory::class,
|
||||
SymfonyCli\Helper\ProcessHelper::class,
|
||||
Util\ProcessRunner::class,
|
||||
PhpExecutableFinder::class,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -6,11 +6,11 @@ namespace Shlinkio\Shlink\CLI\Command\Api;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
|
||||
use Shlinkio\Shlink\CLI\Command\BaseCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -19,7 +19,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use function Shlinkio\Shlink\Core\arrayToString;
|
||||
use function sprintf;
|
||||
|
||||
class GenerateKeyCommand extends Command
|
||||
class GenerateKeyCommand extends BaseCommand
|
||||
{
|
||||
public const NAME = 'api-key:generate';
|
||||
|
||||
@@ -42,9 +42,13 @@ class GenerateKeyCommand extends Command
|
||||
|
||||
<info>%command.full_name%</info>
|
||||
|
||||
You can optionally set its expiration date with <comment>--expirationDate</comment> or <comment>-e</comment>:
|
||||
You can optionally set its name for tracking purposes with <comment>--name</comment> or <comment>-m</comment>:
|
||||
|
||||
<info>%command.full_name% --expirationDate 2020-01-01</info>
|
||||
<info>%command.full_name% --name Alice</info>
|
||||
|
||||
You can optionally set its expiration date with <comment>--expiration-date</comment> or <comment>-e</comment>:
|
||||
|
||||
<info>%command.full_name% --expiration-date 2020-01-01</info>
|
||||
|
||||
You can also set roles to the API key:
|
||||
|
||||
@@ -57,7 +61,13 @@ class GenerateKeyCommand extends Command
|
||||
->setName(self::NAME)
|
||||
->setDescription('Generates a new valid API key.')
|
||||
->addOption(
|
||||
'expirationDate',
|
||||
'name',
|
||||
'm',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The name by which this API key will be known.',
|
||||
)
|
||||
->addOptionWithDeprecatedFallback(
|
||||
'expiration-date',
|
||||
'e',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The date in which the API key should expire. Use any valid PHP format.',
|
||||
@@ -79,9 +89,10 @@ class GenerateKeyCommand extends Command
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$expirationDate = $input->getOption('expirationDate');
|
||||
$expirationDate = $this->getOptionWithDeprecatedFallback($input, 'expiration-date');
|
||||
$apiKey = $this->apiKeyService->create(
|
||||
isset($expirationDate) ? Chronos::parse($expirationDate) : null,
|
||||
$input->getOption('name'),
|
||||
...$this->roleResolver->determineRoles($input),
|
||||
);
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Api;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command\BaseCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -19,7 +19,7 @@ use function Functional\map;
|
||||
use function implode;
|
||||
use function sprintf;
|
||||
|
||||
class ListKeysCommand extends Command
|
||||
class ListKeysCommand extends BaseCommand
|
||||
{
|
||||
private const ERROR_STRING_PATTERN = '<fg=red>%s</>';
|
||||
private const SUCCESS_STRING_PATTERN = '<info>%s</info>';
|
||||
@@ -40,8 +40,8 @@ class ListKeysCommand extends Command
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Lists all the available API keys.')
|
||||
->addOption(
|
||||
'enabledOnly',
|
||||
->addOptionWithDeprecatedFallback(
|
||||
'enabled-only',
|
||||
'e',
|
||||
InputOption::VALUE_NONE,
|
||||
'Tells if only enabled API keys should be returned.',
|
||||
@@ -50,14 +50,14 @@ class ListKeysCommand extends Command
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$enabledOnly = $input->getOption('enabledOnly');
|
||||
$enabledOnly = $this->getOptionWithDeprecatedFallback($input, 'enabled-only');
|
||||
|
||||
$rows = map($this->apiKeyService->listKeys($enabledOnly), function (ApiKey $apiKey) use ($enabledOnly) {
|
||||
$expiration = $apiKey->getExpirationDate();
|
||||
$messagePattern = $this->determineMessagePattern($apiKey);
|
||||
|
||||
// Set columns for this row
|
||||
$rowData = [sprintf($messagePattern, $apiKey)];
|
||||
$rowData = [sprintf($messagePattern, $apiKey), sprintf($messagePattern, $apiKey->name() ?? '-')];
|
||||
if (! $enabledOnly) {
|
||||
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
|
||||
}
|
||||
@@ -74,10 +74,12 @@ class ListKeysCommand extends Command
|
||||
|
||||
ShlinkTable::fromOutput($output)->render(array_filter([
|
||||
'Key',
|
||||
'Name',
|
||||
! $enabledOnly ? 'Is enabled' : null,
|
||||
'Expiration date',
|
||||
'Roles',
|
||||
]), $rows);
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
|
||||
51
module/CLI/src/Command/BaseCommand.php
Normal file
51
module/CLI/src/Command/BaseCommand.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
use function method_exists;
|
||||
use function Shlinkio\Shlink\Core\kebabCaseToCamelCase;
|
||||
use function sprintf;
|
||||
use function str_contains;
|
||||
|
||||
abstract class BaseCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @param mixed|null $default
|
||||
*/
|
||||
protected function addOptionWithDeprecatedFallback(
|
||||
string $name,
|
||||
?string $shortcut = null,
|
||||
?int $mode = null,
|
||||
string $description = '',
|
||||
$default = null
|
||||
): self {
|
||||
$this->addOption($name, $shortcut, $mode, $description, $default);
|
||||
|
||||
if (str_contains($name, '-')) {
|
||||
$camelCaseName = kebabCaseToCamelCase($name);
|
||||
$this->addOption($camelCaseName, null, $mode, sprintf('[DEPRECATED] Same as "%s".', $name), $default);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|string|string[]|null
|
||||
*/
|
||||
protected function getOptionWithDeprecatedFallback(InputInterface $input, string $name)
|
||||
{
|
||||
$rawInput = method_exists($input, '__toString') ? $input->__toString() : '';
|
||||
$camelCaseName = kebabCaseToCamelCase($name);
|
||||
|
||||
if (str_contains($rawInput, $camelCaseName)) {
|
||||
return $input->getOption($camelCaseName);
|
||||
}
|
||||
|
||||
return $input->getOption($name);
|
||||
}
|
||||
}
|
||||
@@ -6,31 +6,34 @@ namespace Shlinkio\Shlink\CLI\Command\Db;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
|
||||
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
|
||||
use Symfony\Component\Console\Helper\ProcessHelper;
|
||||
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
abstract class AbstractDatabaseCommand extends AbstractLockedCommand
|
||||
{
|
||||
private ProcessHelper $processHelper;
|
||||
private ProcessRunnerInterface $processRunner;
|
||||
private string $phpBinary;
|
||||
|
||||
public function __construct(LockFactory $locker, ProcessHelper $processHelper, PhpExecutableFinder $phpFinder)
|
||||
{
|
||||
public function __construct(
|
||||
LockFactory $locker,
|
||||
ProcessRunnerInterface $processRunner,
|
||||
PhpExecutableFinder $phpFinder
|
||||
) {
|
||||
parent::__construct($locker);
|
||||
$this->processHelper = $processHelper;
|
||||
$this->processRunner = $processRunner;
|
||||
$this->phpBinary = $phpFinder->find(false) ?: 'php';
|
||||
}
|
||||
|
||||
protected function runPhpCommand(OutputInterface $output, array $command): void
|
||||
{
|
||||
$command = [$this->phpBinary, ...$command, '--no-interaction'];
|
||||
$this->processHelper->mustRun($output, $command);
|
||||
$this->processRunner->run($output, $command);
|
||||
}
|
||||
|
||||
protected function getLockConfig(): LockedCommandConfig
|
||||
{
|
||||
return new LockedCommandConfig($this->getName(), true);
|
||||
return LockedCommandConfig::blocking($this->getName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Db;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Symfony\Component\Console\Helper\ProcessHelper;
|
||||
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
@@ -26,12 +26,12 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
|
||||
|
||||
public function __construct(
|
||||
LockFactory $locker,
|
||||
ProcessHelper $processHelper,
|
||||
ProcessRunnerInterface $processRunner,
|
||||
PhpExecutableFinder $phpFinder,
|
||||
Connection $conn,
|
||||
Connection $noDbNameConn
|
||||
) {
|
||||
parent::__construct($locker, $processHelper, $phpFinder);
|
||||
parent::__construct($locker, $processRunner, $phpFinder);
|
||||
$this->regularConn = $conn;
|
||||
$this->noDbNameConn = $noDbNameConn;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command\BaseCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
@@ -23,21 +24,24 @@ use function Functional\flatten;
|
||||
use function Functional\unique;
|
||||
use function method_exists;
|
||||
use function sprintf;
|
||||
use function strpos;
|
||||
use function str_contains;
|
||||
|
||||
class GenerateShortUrlCommand extends Command
|
||||
class GenerateShortUrlCommand extends BaseCommand
|
||||
{
|
||||
public const NAME = 'short-url:generate';
|
||||
|
||||
private UrlShortenerInterface $urlShortener;
|
||||
private array $domainConfig;
|
||||
private ShortUrlStringifierInterface $stringifier;
|
||||
private int $defaultShortCodeLength;
|
||||
|
||||
public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig, int $defaultShortCodeLength)
|
||||
{
|
||||
public function __construct(
|
||||
UrlShortenerInterface $urlShortener,
|
||||
ShortUrlStringifierInterface $stringifier,
|
||||
int $defaultShortCodeLength
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->urlShortener = $urlShortener;
|
||||
$this->domainConfig = $domainConfig;
|
||||
$this->stringifier = $stringifier;
|
||||
$this->defaultShortCodeLength = $defaultShortCodeLength;
|
||||
}
|
||||
|
||||
@@ -53,34 +57,34 @@ class GenerateShortUrlCommand extends Command
|
||||
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
|
||||
'Tags to apply to the new short URL',
|
||||
)
|
||||
->addOption(
|
||||
'validSince',
|
||||
->addOptionWithDeprecatedFallback(
|
||||
'valid-since',
|
||||
's',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The date from which this short URL will be valid. '
|
||||
. 'If someone tries to access it before this date, it will not be found.',
|
||||
)
|
||||
->addOption(
|
||||
'validUntil',
|
||||
->addOptionWithDeprecatedFallback(
|
||||
'valid-until',
|
||||
'u',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The date until which this short URL will be valid. '
|
||||
. 'If someone tries to access it after this date, it will not be found.',
|
||||
)
|
||||
->addOption(
|
||||
'customSlug',
|
||||
->addOptionWithDeprecatedFallback(
|
||||
'custom-slug',
|
||||
'c',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'If provided, this slug will be used instead of generating a short code',
|
||||
)
|
||||
->addOption(
|
||||
'maxVisits',
|
||||
->addOptionWithDeprecatedFallback(
|
||||
'max-visits',
|
||||
'm',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'This will limit the number of visits for this short URL.',
|
||||
)
|
||||
->addOption(
|
||||
'findIfExists',
|
||||
->addOptionWithDeprecatedFallback(
|
||||
'find-if-exists',
|
||||
'f',
|
||||
InputOption::VALUE_NONE,
|
||||
'This will force existing matching URL to be returned if found, instead of creating a new one.',
|
||||
@@ -91,11 +95,11 @@ class GenerateShortUrlCommand extends Command
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The domain to which this short URL will be attached.',
|
||||
)
|
||||
->addOption(
|
||||
'shortCodeLength',
|
||||
->addOptionWithDeprecatedFallback(
|
||||
'short-code-length',
|
||||
'l',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The length for generated short code (it will be ignored if --customSlug was provided).',
|
||||
'The length for generated short code (it will be ignored if --custom-slug was provided).',
|
||||
)
|
||||
->addOption(
|
||||
'validate-url',
|
||||
@@ -136,26 +140,34 @@ class GenerateShortUrlCommand extends Command
|
||||
|
||||
$explodeWithComma = curry('explode')(',');
|
||||
$tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
|
||||
$customSlug = $input->getOption('customSlug');
|
||||
$maxVisits = $input->getOption('maxVisits');
|
||||
$shortCodeLength = $input->getOption('shortCodeLength') ?? $this->defaultShortCodeLength;
|
||||
$customSlug = $this->getOptionWithDeprecatedFallback($input, 'custom-slug');
|
||||
$maxVisits = $this->getOptionWithDeprecatedFallback($input, 'max-visits');
|
||||
$shortCodeLength = $this->getOptionWithDeprecatedFallback(
|
||||
$input,
|
||||
'short-code-length',
|
||||
) ?? $this->defaultShortCodeLength;
|
||||
$doValidateUrl = $this->doValidateUrl($input);
|
||||
|
||||
try {
|
||||
$shortUrl = $this->urlShortener->shorten($longUrl, $tags, ShortUrlMeta::fromRawData([
|
||||
ShortUrlMetaInputFilter::VALID_SINCE => $input->getOption('validSince'),
|
||||
ShortUrlMetaInputFilter::VALID_UNTIL => $input->getOption('validUntil'),
|
||||
ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug,
|
||||
ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
|
||||
ShortUrlMetaInputFilter::FIND_IF_EXISTS => $input->getOption('findIfExists'),
|
||||
ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'),
|
||||
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
|
||||
ShortUrlMetaInputFilter::VALIDATE_URL => $doValidateUrl,
|
||||
$shortUrl = $this->urlShortener->shorten(ShortUrlMeta::fromRawData([
|
||||
ShortUrlInputFilter::LONG_URL => $longUrl,
|
||||
ShortUrlInputFilter::VALID_SINCE => $this->getOptionWithDeprecatedFallback($input, 'valid-since'),
|
||||
ShortUrlInputFilter::VALID_UNTIL => $this->getOptionWithDeprecatedFallback($input, 'valid-until'),
|
||||
ShortUrlInputFilter::CUSTOM_SLUG => $customSlug,
|
||||
ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
|
||||
ShortUrlInputFilter::FIND_IF_EXISTS => $this->getOptionWithDeprecatedFallback(
|
||||
$input,
|
||||
'find-if-exists',
|
||||
),
|
||||
ShortUrlInputFilter::DOMAIN => $input->getOption('domain'),
|
||||
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
|
||||
ShortUrlInputFilter::VALIDATE_URL => $doValidateUrl,
|
||||
ShortUrlInputFilter::TAGS => $tags,
|
||||
]));
|
||||
|
||||
$io->writeln([
|
||||
sprintf('Processed long URL: <info>%s</info>', $longUrl),
|
||||
sprintf('Generated short URL: <info>%s</info>', $shortUrl->toString($this->domainConfig)),
|
||||
sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($shortUrl)),
|
||||
]);
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
} catch (InvalidUrlException | NonUniqueSlugException $e) {
|
||||
@@ -168,10 +180,10 @@ class GenerateShortUrlCommand extends Command
|
||||
{
|
||||
$rawInput = method_exists($input, '__toString') ? $input->__toString() : '';
|
||||
|
||||
if (strpos($rawInput, '--no-validate-url') !== false) {
|
||||
if (str_contains($rawInput, '--no-validate-url')) {
|
||||
return false;
|
||||
}
|
||||
if (strpos($rawInput, '--validate-url') !== false) {
|
||||
if (str_contains($rawInput, '--validate-url')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
@@ -21,16 +21,17 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function Functional\map;
|
||||
use function Functional\select_keys;
|
||||
use function sprintf;
|
||||
|
||||
class GetVisitsCommand extends AbstractWithDateRangeCommand
|
||||
{
|
||||
public const NAME = 'short-url:visits';
|
||||
|
||||
private VisitsTrackerInterface $visitsTracker;
|
||||
private VisitsStatsHelperInterface $visitsHelper;
|
||||
|
||||
public function __construct(VisitsTrackerInterface $visitsTracker)
|
||||
public function __construct(VisitsStatsHelperInterface $visitsHelper)
|
||||
{
|
||||
$this->visitsTracker = $visitsTracker;
|
||||
$this->visitsHelper = $visitsHelper;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
@@ -39,18 +40,18 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Returns the detailed visits information for provided short code')
|
||||
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get')
|
||||
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code');
|
||||
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get.')
|
||||
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code.');
|
||||
}
|
||||
|
||||
protected function getStartDateDesc(): string
|
||||
protected function getStartDateDesc(string $optionName): string
|
||||
{
|
||||
return 'Allows to filter visits, returning only those older than start date';
|
||||
return sprintf('Allows to filter visits, returning only those older than "%s".', $optionName);
|
||||
}
|
||||
|
||||
protected function getEndDateDesc(): string
|
||||
protected function getEndDateDesc(string $optionName): string
|
||||
{
|
||||
return 'Allows to filter visits, returning only those newer than end date';
|
||||
return sprintf('Allows to filter visits, returning only those newer than "%s".', $optionName);
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
@@ -70,12 +71,15 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$identifier = ShortUrlIdentifier::fromCli($input);
|
||||
$startDate = $this->getDateOption($input, $output, 'startDate');
|
||||
$endDate = $this->getDateOption($input, $output, 'endDate');
|
||||
$startDate = $this->getStartDateOption($input, $output);
|
||||
$endDate = $this->getEndDateOption($input, $output);
|
||||
|
||||
$paginator = $this->visitsTracker->info($identifier, new VisitsParams(new DateRange($startDate, $endDate)));
|
||||
$paginator = $this->visitsHelper->visitsForShortUrl(
|
||||
$identifier,
|
||||
new VisitsParams(new DateRange($startDate, $endDate)),
|
||||
);
|
||||
|
||||
$rows = map($paginator->getCurrentItems(), function (Visit $visit) {
|
||||
$rows = map($paginator->getCurrentPageResults(), function (Visit $visit) {
|
||||
$rowData = $visit->jsonSerialize();
|
||||
$rowData['country'] = ($visit->getVisitLocation() ?? new UnknownVisitLocation())->getCountryName();
|
||||
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
|
||||
|
||||
@@ -4,51 +4,43 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Laminas\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
|
||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function array_flip;
|
||||
use function array_intersect_key;
|
||||
use function array_values;
|
||||
use function count;
|
||||
use function array_keys;
|
||||
use function array_pad;
|
||||
use function explode;
|
||||
use function Functional\map;
|
||||
use function implode;
|
||||
use function sprintf;
|
||||
|
||||
class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
{
|
||||
use PaginatorUtilsTrait;
|
||||
use PagerfantaUtilsTrait;
|
||||
|
||||
public const NAME = 'short-url:list';
|
||||
private const COLUMNS_WHITELIST = [
|
||||
'shortCode',
|
||||
'shortUrl',
|
||||
'longUrl',
|
||||
'dateCreated',
|
||||
'visitsCount',
|
||||
'tags',
|
||||
];
|
||||
|
||||
private ShortUrlServiceInterface $shortUrlService;
|
||||
private ShortUrlDataTransformer $transformer;
|
||||
private DataTransformerInterface $transformer;
|
||||
|
||||
public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig)
|
||||
public function __construct(ShortUrlServiceInterface $shortUrlService, DataTransformerInterface $transformer)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->shortUrlService = $shortUrlService;
|
||||
$this->transformer = new ShortUrlDataTransformer($domainConfig);
|
||||
$this->transformer = $transformer;
|
||||
}
|
||||
|
||||
protected function doConfigure(): void
|
||||
@@ -60,28 +52,46 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
'page',
|
||||
'p',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The first page to list (10 items per page unless "--all" is provided)',
|
||||
'The first page to list (10 items per page unless "--all" is provided).',
|
||||
'1',
|
||||
)
|
||||
->addOption(
|
||||
'searchTerm',
|
||||
->addOptionWithDeprecatedFallback(
|
||||
'search-term',
|
||||
'st',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'A query used to filter results by searching for it on the longUrl and shortCode fields',
|
||||
'A query used to filter results by searching for it on the longUrl and shortCode fields.',
|
||||
)
|
||||
->addOption(
|
||||
'tags',
|
||||
't',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'A comma-separated list of tags to filter results',
|
||||
'A comma-separated list of tags to filter results.',
|
||||
)
|
||||
->addOption(
|
||||
'orderBy',
|
||||
->addOptionWithDeprecatedFallback(
|
||||
'order-by',
|
||||
'o',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The field from which we want to order by. Pass ASC or DESC separated by a comma',
|
||||
'The field from which you want to order by. '
|
||||
. 'Define ordering dir by passing ASC or DESC after "," or "-".',
|
||||
)
|
||||
->addOptionWithDeprecatedFallback(
|
||||
'show-tags',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Whether to display the tags or not.',
|
||||
)
|
||||
->addOption(
|
||||
'show-api-key',
|
||||
'k',
|
||||
InputOption::VALUE_NONE,
|
||||
'Whether to display the API key from which the URL was generated or not.',
|
||||
)
|
||||
->addOption(
|
||||
'show-api-key-name',
|
||||
'm',
|
||||
InputOption::VALUE_NONE,
|
||||
'Whether to display the API key name from which the URL was generated or not.',
|
||||
)
|
||||
->addOption('showTags', null, InputOption::VALUE_NONE, 'Whether to display the tags or not')
|
||||
->addOption(
|
||||
'all',
|
||||
'a',
|
||||
@@ -91,14 +101,14 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
);
|
||||
}
|
||||
|
||||
protected function getStartDateDesc(): string
|
||||
protected function getStartDateDesc(string $optionName): string
|
||||
{
|
||||
return 'Allows to filter short URLs, returning only those created after "startDate"';
|
||||
return sprintf('Allows to filter short URLs, returning only those created after "%s".', $optionName);
|
||||
}
|
||||
|
||||
protected function getEndDateDesc(): string
|
||||
protected function getEndDateDesc(string $optionName): string
|
||||
{
|
||||
return 'Allows to filter short URLs, returning only those created before "endDate"';
|
||||
return sprintf('Allows to filter short URLs, returning only those created before "%s".', $optionName);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
@@ -106,14 +116,14 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$page = (int) $input->getOption('page');
|
||||
$searchTerm = $input->getOption('searchTerm');
|
||||
$searchTerm = $this->getOptionWithDeprecatedFallback($input, 'search-term');
|
||||
$tags = $input->getOption('tags');
|
||||
$tags = ! empty($tags) ? explode(',', $tags) : [];
|
||||
$showTags = (bool) $input->getOption('showTags');
|
||||
$all = (bool) $input->getOption('all');
|
||||
$startDate = $this->getDateOption($input, $output, 'startDate');
|
||||
$endDate = $this->getDateOption($input, $output, 'endDate');
|
||||
$all = $input->getOption('all');
|
||||
$startDate = $this->getStartDateOption($input, $output);
|
||||
$endDate = $this->getEndDateOption($input, $output);
|
||||
$orderBy = $this->processOrderBy($input);
|
||||
$columnsMap = $this->resolveColumnsMap($input);
|
||||
|
||||
$data = [
|
||||
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
|
||||
@@ -129,10 +139,10 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
|
||||
do {
|
||||
$data[ShortUrlsParamsInputFilter::PAGE] = $page;
|
||||
$result = $this->renderPage($output, $showTags, ShortUrlsParams::fromRawData($data), $all);
|
||||
$result = $this->renderPage($output, $columnsMap, ShortUrlsParams::fromRawData($data), $all);
|
||||
$page++;
|
||||
|
||||
$continue = ! $this->isLastPage($result) && $io->confirm(
|
||||
$continue = $result->hasNextPage() && $io->confirm(
|
||||
sprintf('Continue with page <options=bold>%s</>?', $page),
|
||||
false,
|
||||
);
|
||||
@@ -144,46 +154,65 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
private function renderPage(OutputInterface $output, bool $showTags, ShortUrlsParams $params, bool $all): Paginator
|
||||
{
|
||||
$result = $this->shortUrlService->listShortUrls($params);
|
||||
private function renderPage(
|
||||
OutputInterface $output,
|
||||
array $columnsMap,
|
||||
ShortUrlsParams $params,
|
||||
bool $all
|
||||
): Paginator {
|
||||
$shortUrls = $this->shortUrlService->listShortUrls($params);
|
||||
|
||||
$headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
|
||||
if ($showTags) {
|
||||
$headers[] = 'Tags';
|
||||
}
|
||||
$rows = map($shortUrls, function (ShortUrl $shortUrl) use ($columnsMap) {
|
||||
$rawShortUrl = $this->transformer->transform($shortUrl);
|
||||
return map($columnsMap, fn (callable $call) => $call($rawShortUrl, $shortUrl));
|
||||
});
|
||||
|
||||
$rows = [];
|
||||
foreach ($result as $row) {
|
||||
$shortUrl = $this->transformer->transform($row);
|
||||
if ($showTags) {
|
||||
$shortUrl['tags'] = implode(', ', $shortUrl['tags']);
|
||||
} else {
|
||||
unset($shortUrl['tags']);
|
||||
}
|
||||
ShlinkTable::fromOutput($output)->render(
|
||||
array_keys($columnsMap),
|
||||
$rows,
|
||||
$all ? null : $this->formatCurrentPageMessage($shortUrls, 'Page %s of %s'),
|
||||
);
|
||||
|
||||
$rows[] = array_values(array_intersect_key($shortUrl, array_flip(self::COLUMNS_WHITELIST)));
|
||||
}
|
||||
|
||||
ShlinkTable::fromOutput($output)->render($headers, $rows, $all ? null : $this->formatCurrentPageMessage(
|
||||
$result,
|
||||
'Page %s of %s',
|
||||
));
|
||||
|
||||
return $result;
|
||||
return $shortUrls;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|string|null
|
||||
*/
|
||||
private function processOrderBy(InputInterface $input)
|
||||
private function processOrderBy(InputInterface $input): ?string
|
||||
{
|
||||
$orderBy = $input->getOption('orderBy');
|
||||
$orderBy = $this->getOptionWithDeprecatedFallback($input, 'order-by');
|
||||
if (empty($orderBy)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$orderBy = explode(',', $orderBy);
|
||||
return count($orderBy) === 1 ? $orderBy[0] : [$orderBy[0] => $orderBy[1]];
|
||||
[$field, $dir] = array_pad(explode(',', $orderBy), 2, null);
|
||||
return $dir === null ? $field : sprintf('%s-%s', $field, $dir);
|
||||
}
|
||||
|
||||
private function resolveColumnsMap(InputInterface $input): array
|
||||
{
|
||||
$pickProp = static fn (string $prop): callable => static fn (array $shortUrl) => $shortUrl[$prop];
|
||||
$columnsMap = [
|
||||
'Short Code' => $pickProp('shortCode'),
|
||||
'Title' => $pickProp('title'),
|
||||
'Short URL' => $pickProp('shortUrl'),
|
||||
'Long URL' => $pickProp('longUrl'),
|
||||
'Date created' => $pickProp('dateCreated'),
|
||||
'Visits count' => $pickProp('visitsCount'),
|
||||
];
|
||||
if ($this->getOptionWithDeprecatedFallback($input, 'show-tags')) {
|
||||
$columnsMap['Tags'] = static fn (array $shortUrl): string => implode(', ', $shortUrl['tags']);
|
||||
}
|
||||
if ($input->getOption('show-api-key')) {
|
||||
$columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string =>
|
||||
(string) $shortUrl->authorApiKey();
|
||||
}
|
||||
if ($input->getOption('show-api-key-name')) {
|
||||
$columnsMap['API Key Name'] = static function (array $_, ShortUrl $shortUrl): ?string {
|
||||
$apiKey = $shortUrl->authorApiKey();
|
||||
|
||||
return $apiKey !== null ? $apiKey->name() : null;
|
||||
};
|
||||
}
|
||||
|
||||
return $columnsMap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\CLI\Command\Util;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Shlinkio\Shlink\CLI\Command\BaseCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -13,19 +13,42 @@ use Throwable;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
abstract class AbstractWithDateRangeCommand extends Command
|
||||
abstract class AbstractWithDateRangeCommand extends BaseCommand
|
||||
{
|
||||
private const START_DATE = 'start-date';
|
||||
private const END_DATE = 'end-date';
|
||||
|
||||
final protected function configure(): void
|
||||
{
|
||||
$this->doConfigure();
|
||||
$this
|
||||
->addOption('startDate', 's', InputOption::VALUE_REQUIRED, $this->getStartDateDesc())
|
||||
->addOption('endDate', 'e', InputOption::VALUE_REQUIRED, $this->getEndDateDesc());
|
||||
->addOptionWithDeprecatedFallback(
|
||||
self::START_DATE,
|
||||
's',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
$this->getStartDateDesc(self::START_DATE),
|
||||
)
|
||||
->addOptionWithDeprecatedFallback(
|
||||
self::END_DATE,
|
||||
'e',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
$this->getEndDateDesc(self::END_DATE),
|
||||
);
|
||||
}
|
||||
|
||||
protected function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos
|
||||
protected function getStartDateOption(InputInterface $input, OutputInterface $output): ?Chronos
|
||||
{
|
||||
$value = $input->getOption($key);
|
||||
return $this->getDateOption($input, $output, self::START_DATE);
|
||||
}
|
||||
|
||||
protected function getEndDateOption(InputInterface $input, OutputInterface $output): ?Chronos
|
||||
{
|
||||
return $this->getDateOption($input, $output, self::END_DATE);
|
||||
}
|
||||
|
||||
private function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos
|
||||
{
|
||||
$value = $this->getOptionWithDeprecatedFallback($input, $key);
|
||||
if (empty($value)) {
|
||||
return null;
|
||||
}
|
||||
@@ -49,6 +72,7 @@ abstract class AbstractWithDateRangeCommand extends Command
|
||||
|
||||
abstract protected function doConfigure(): void;
|
||||
|
||||
abstract protected function getStartDateDesc(): string;
|
||||
abstract protected function getEndDateDesc(): string;
|
||||
abstract protected function getStartDateDesc(string $optionName): string;
|
||||
|
||||
abstract protected function getEndDateDesc(string $optionName): string;
|
||||
}
|
||||
|
||||
@@ -6,19 +6,29 @@ namespace Shlinkio\Shlink\CLI\Command\Util;
|
||||
|
||||
final class LockedCommandConfig
|
||||
{
|
||||
private const DEFAULT_TTL = 90.0; // 1.5 minutes
|
||||
public const DEFAULT_TTL = 600.0; // 10 minutes
|
||||
|
||||
private string $lockName;
|
||||
private bool $isBlocking;
|
||||
private float $ttl;
|
||||
|
||||
public function __construct(string $lockName, bool $isBlocking = false, float $ttl = self::DEFAULT_TTL)
|
||||
private function __construct(string $lockName, bool $isBlocking, float $ttl = self::DEFAULT_TTL)
|
||||
{
|
||||
$this->lockName = $lockName;
|
||||
$this->isBlocking = $isBlocking;
|
||||
$this->ttl = $ttl;
|
||||
}
|
||||
|
||||
public static function blocking(string $lockName): self
|
||||
{
|
||||
return new self($lockName, true);
|
||||
}
|
||||
|
||||
public static function nonBlocking(string $lockName): self
|
||||
{
|
||||
return new self($lockName, false);
|
||||
}
|
||||
|
||||
public function lockName(): string
|
||||
{
|
||||
return $this->lockName;
|
||||
|
||||
80
module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php
Normal file
80
module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class DownloadGeoLiteDbCommand extends Command
|
||||
{
|
||||
public const NAME = 'visit:download-db';
|
||||
|
||||
private GeolocationDbUpdaterInterface $dbUpdater;
|
||||
private ?ProgressBar $progressBar = null;
|
||||
|
||||
public function __construct(GeolocationDbUpdaterInterface $dbUpdater)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->dbUpdater = $dbUpdater;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription(
|
||||
'Checks if the GeoLite2 db file is too old or it does not exist, and tries to download an up-to-date '
|
||||
. 'copy if so.',
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
try {
|
||||
$this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) use ($io): void {
|
||||
$io->text(sprintf('<fg=blue>%s GeoLite2 db file...</>', $olderDbExists ? 'Updating' : 'Downloading'));
|
||||
$this->progressBar = new ProgressBar($io);
|
||||
}, function (int $total, int $downloaded): void {
|
||||
$this->progressBar->setMaxSteps($total);
|
||||
$this->progressBar->setProgress($downloaded);
|
||||
});
|
||||
|
||||
if ($this->progressBar === null) {
|
||||
$io->info('GeoLite2 db file is up to date.');
|
||||
} else {
|
||||
$this->progressBar->finish();
|
||||
$io->success('GeoLite2 db file properly downloaded.');
|
||||
}
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
} catch (GeolocationDbUpdateFailedException $e) {
|
||||
$olderDbExists = $e->olderDbExists();
|
||||
|
||||
if ($olderDbExists) {
|
||||
$io->warning(
|
||||
'GeoLite2 db file update failed. Visits will continue to be located with the old version.',
|
||||
);
|
||||
} else {
|
||||
$io->error('GeoLite2 db file download failed. It will not be possible to locate visits.');
|
||||
}
|
||||
|
||||
if ($io->isVerbose()) {
|
||||
$this->getApplication()->renderThrowable($e, $io);
|
||||
}
|
||||
|
||||
return $olderDbExists ? ExitCodes::EXIT_WARNING : ExitCodes::EXIT_FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
|
||||
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\Common\Util\IpAddress;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
@@ -19,7 +17,6 @@ use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
use Symfony\Component\Console\Exception\RuntimeException;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -35,28 +32,26 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||
|
||||
private VisitLocatorInterface $visitLocator;
|
||||
private IpLocationResolverInterface $ipLocationResolver;
|
||||
private GeolocationDbUpdaterInterface $dbUpdater;
|
||||
|
||||
private SymfonyStyle $io;
|
||||
private ?ProgressBar $progressBar = null;
|
||||
|
||||
public function __construct(
|
||||
VisitLocatorInterface $visitLocator,
|
||||
IpLocationResolverInterface $ipLocationResolver,
|
||||
LockFactory $locker,
|
||||
GeolocationDbUpdaterInterface $dbUpdater
|
||||
LockFactory $locker
|
||||
) {
|
||||
parent::__construct($locker);
|
||||
$this->visitLocator = $visitLocator;
|
||||
$this->ipLocationResolver = $ipLocationResolver;
|
||||
$this->dbUpdater = $dbUpdater;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Resolves visits origin locations.')
|
||||
->setDescription(
|
||||
'Resolves visits origin locations. It implicitly downloads/updates the GeoLite2 db file if needed.',
|
||||
)
|
||||
->addOption(
|
||||
'retry',
|
||||
'r',
|
||||
@@ -90,12 +85,12 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||
);
|
||||
}
|
||||
|
||||
if ($all && $retry && ! $this->warnAndVerifyContinue()) {
|
||||
if ($all && $retry && ! $this->warnAndVerifyContinue($input)) {
|
||||
throw new RuntimeException('Execution aborted');
|
||||
}
|
||||
}
|
||||
|
||||
private function warnAndVerifyContinue(): bool
|
||||
private function warnAndVerifyContinue(InputInterface $input): bool
|
||||
{
|
||||
$this->io->warning([
|
||||
'You are about to process the location of all existing visits your short URLs received.',
|
||||
@@ -113,7 +108,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||
$all = $retry && $input->getOption('all');
|
||||
|
||||
try {
|
||||
$this->checkDbUpdate();
|
||||
$this->checkDbUpdate($input);
|
||||
|
||||
if ($all) {
|
||||
$this->visitLocator->locateAllVisits($this);
|
||||
@@ -128,7 +123,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$this->io->error($e->getMessage());
|
||||
if ($e instanceof Throwable && $this->io->isVerbose()) {
|
||||
if ($this->io->isVerbose()) {
|
||||
$this->getApplication()->renderThrowable($e, $this->io);
|
||||
}
|
||||
|
||||
@@ -176,38 +171,18 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||
$this->io->writeln($message);
|
||||
}
|
||||
|
||||
private function checkDbUpdate(): void
|
||||
private function checkDbUpdate(InputInterface $input): void
|
||||
{
|
||||
try {
|
||||
$this->dbUpdater->checkDbUpdate(function (bool $olderDbExists): void {
|
||||
$this->io->writeln(
|
||||
sprintf('<fg=blue>%s GeoLite2 database...</>', $olderDbExists ? 'Updating' : 'Downloading'),
|
||||
);
|
||||
$this->progressBar = new ProgressBar($this->io);
|
||||
}, function (int $total, int $downloaded): void {
|
||||
$this->progressBar->setMaxSteps($total);
|
||||
$this->progressBar->setProgress($downloaded);
|
||||
});
|
||||
$downloadDbCommand = $this->getApplication()->find(DownloadGeoLiteDbCommand::NAME);
|
||||
$exitCode = $downloadDbCommand->run($input, $this->io);
|
||||
|
||||
if ($this->progressBar !== null) {
|
||||
$this->progressBar->finish();
|
||||
$this->io->newLine();
|
||||
}
|
||||
} catch (GeolocationDbUpdateFailedException $e) {
|
||||
if (! $e->olderDbExists()) {
|
||||
$this->io->error('GeoLite2 database download failed. It is not possible to locate visits.');
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$this->io->newLine();
|
||||
$this->io->writeln(
|
||||
'<fg=yellow;options=bold>[Warning] GeoLite2 database update failed. Proceeding with old version.</>',
|
||||
);
|
||||
if ($exitCode === ExitCodes::EXIT_FAILURE) {
|
||||
throw new RuntimeException('It is not possible to locate visits without a GeoLite2 db file.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function getLockConfig(): LockedCommandConfig
|
||||
{
|
||||
return new LockedCommandConfig($this->getName());
|
||||
return LockedCommandConfig::nonBlocking($this->getName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,51 @@ namespace Shlinkio\Shlink\CLI\Exception;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class GeolocationDbUpdateFailedException extends RuntimeException implements ExceptionInterface
|
||||
{
|
||||
private bool $olderDbExists;
|
||||
|
||||
public static function create(bool $olderDbExists, ?Throwable $prev = null): self
|
||||
private function __construct(string $message, int $code = 0, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
public static function withOlderDb(?Throwable $prev = null): self
|
||||
{
|
||||
$e = new self(
|
||||
'An error occurred while updating geolocation database, and an older version could not be found',
|
||||
'An error occurred while updating geolocation database, but an older DB is already present.',
|
||||
0,
|
||||
$prev,
|
||||
);
|
||||
$e->olderDbExists = $olderDbExists;
|
||||
$e->olderDbExists = true;
|
||||
|
||||
return $e;
|
||||
}
|
||||
|
||||
public static function withoutOlderDb(?Throwable $prev = null): self
|
||||
{
|
||||
$e = new self(
|
||||
'An error occurred while updating geolocation database, and an older version could not be found.',
|
||||
0,
|
||||
$prev,
|
||||
);
|
||||
$e->olderDbExists = false;
|
||||
|
||||
return $e;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $buildEpoch
|
||||
*/
|
||||
public static function withInvalidEpochInOldDb($buildEpoch): self
|
||||
{
|
||||
$e = new self(sprintf(
|
||||
'Build epoch with value "%s" from existing geolocation database, could not be parsed to integer.',
|
||||
$buildEpoch,
|
||||
));
|
||||
$e->olderDbExists = true;
|
||||
|
||||
return $e;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ namespace Shlinkio\Shlink\CLI\Util;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use GeoIp2\Database\Reader;
|
||||
use MaxMind\Db\Reader\Metadata;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
|
||||
use function is_int;
|
||||
|
||||
class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||
{
|
||||
private const LOCK_NAME = 'geolocation-db-update';
|
||||
@@ -29,13 +32,13 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||
/**
|
||||
* @throws GeolocationDbUpdateFailedException
|
||||
*/
|
||||
public function checkDbUpdate(?callable $mustBeUpdated = null, ?callable $handleProgress = null): void
|
||||
public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): void
|
||||
{
|
||||
$lock = $this->locker->createLock(self::LOCK_NAME);
|
||||
$lock->acquire(true); // Block until lock is released
|
||||
|
||||
try {
|
||||
$this->downloadIfNeeded($mustBeUpdated, $handleProgress);
|
||||
$this->downloadIfNeeded($beforeDownload, $handleProgress);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
@@ -44,39 +47,71 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||
/**
|
||||
* @throws GeolocationDbUpdateFailedException
|
||||
*/
|
||||
private function downloadIfNeeded(?callable $mustBeUpdated, ?callable $handleProgress): void
|
||||
private function downloadIfNeeded(?callable $beforeDownload, ?callable $handleProgress): void
|
||||
{
|
||||
if (! $this->dbUpdater->databaseFileExists()) {
|
||||
$this->downloadNewDb(false, $mustBeUpdated, $handleProgress);
|
||||
$this->downloadNewDb(false, $beforeDownload, $handleProgress);
|
||||
return;
|
||||
}
|
||||
|
||||
$meta = $this->geoLiteDbReader->metadata();
|
||||
if ($this->buildIsTooOld($meta->buildEpoch)) {
|
||||
$this->downloadNewDb(true, $mustBeUpdated, $handleProgress);
|
||||
if ($this->buildIsTooOld($meta)) {
|
||||
$this->downloadNewDb(true, $beforeDownload, $handleProgress);
|
||||
}
|
||||
}
|
||||
|
||||
private function buildIsTooOld(Metadata $meta): bool
|
||||
{
|
||||
$buildTimestamp = $this->resolveBuildTimestamp($meta);
|
||||
$buildDate = Chronos::createFromTimestamp($buildTimestamp);
|
||||
$now = Chronos::now();
|
||||
|
||||
return $now->gt($buildDate->addDays(35));
|
||||
}
|
||||
|
||||
private function resolveBuildTimestamp(Metadata $meta): int
|
||||
{
|
||||
// In theory the buildEpoch should be an int, but it has been reported to come as a string.
|
||||
// See https://github.com/shlinkio/shlink/issues/1002 for context
|
||||
|
||||
/** @var int|string $buildEpoch */
|
||||
$buildEpoch = $meta->buildEpoch;
|
||||
if (is_int($buildEpoch)) {
|
||||
return $buildEpoch;
|
||||
}
|
||||
|
||||
$intBuildEpoch = (int) $buildEpoch;
|
||||
if ($buildEpoch === (string) $intBuildEpoch) {
|
||||
return $intBuildEpoch;
|
||||
}
|
||||
|
||||
throw GeolocationDbUpdateFailedException::withInvalidEpochInOldDb($buildEpoch);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws GeolocationDbUpdateFailedException
|
||||
*/
|
||||
private function downloadNewDb(bool $olderDbExists, ?callable $mustBeUpdated, ?callable $handleProgress): void
|
||||
private function downloadNewDb(bool $olderDbExists, ?callable $beforeDownload, ?callable $handleProgress): void
|
||||
{
|
||||
if ($mustBeUpdated !== null) {
|
||||
$mustBeUpdated($olderDbExists);
|
||||
if ($beforeDownload !== null) {
|
||||
$beforeDownload($olderDbExists);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->dbUpdater->downloadFreshCopy($handleProgress);
|
||||
$this->dbUpdater->downloadFreshCopy($this->wrapHandleProgressCallback($handleProgress, $olderDbExists));
|
||||
} catch (RuntimeException $e) {
|
||||
throw GeolocationDbUpdateFailedException::create($olderDbExists, $e);
|
||||
throw $olderDbExists
|
||||
? GeolocationDbUpdateFailedException::withOlderDb($e)
|
||||
: GeolocationDbUpdateFailedException::withoutOlderDb($e);
|
||||
}
|
||||
}
|
||||
|
||||
private function buildIsTooOld(int $buildTimestamp): bool
|
||||
private function wrapHandleProgressCallback(?callable $handleProgress, bool $olderDbExists): ?callable
|
||||
{
|
||||
$buildDate = Chronos::createFromTimestamp($buildTimestamp);
|
||||
$now = Chronos::now();
|
||||
return $now->gt($buildDate->addDays(35));
|
||||
if ($handleProgress === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fn (int $total, int $downloaded) => $handleProgress($total, $downloaded, $olderDbExists);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ interface GeolocationDbUpdaterInterface
|
||||
/**
|
||||
* @throws GeolocationDbUpdateFailedException
|
||||
*/
|
||||
public function checkDbUpdate(?callable $mustBeUpdated = null, ?callable $handleProgress = null): void;
|
||||
public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): void;
|
||||
}
|
||||
|
||||
60
module/CLI/src/Util/ProcessRunner.php
Normal file
60
module/CLI/src/Util/ProcessRunner.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Util;
|
||||
|
||||
use Closure;
|
||||
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
|
||||
use Symfony\Component\Console\Helper\DebugFormatterHelper;
|
||||
use Symfony\Component\Console\Helper\ProcessHelper;
|
||||
use Symfony\Component\Console\Output\ConsoleOutputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
use function spl_object_hash;
|
||||
use function sprintf;
|
||||
use function str_replace;
|
||||
|
||||
class ProcessRunner implements ProcessRunnerInterface
|
||||
{
|
||||
private ProcessHelper $helper;
|
||||
private Closure $createProcess;
|
||||
|
||||
public function __construct(ProcessHelper $helper, ?callable $createProcess = null)
|
||||
{
|
||||
$this->helper = $helper;
|
||||
$this->createProcess = $createProcess !== null
|
||||
? Closure::fromCallable($createProcess)
|
||||
: static fn (array $cmd) => new Process($cmd, null, null, null, LockedCommandConfig::DEFAULT_TTL);
|
||||
}
|
||||
|
||||
public function run(OutputInterface $output, array $cmd): void
|
||||
{
|
||||
if ($output instanceof ConsoleOutputInterface) {
|
||||
$output = $output->getErrorOutput();
|
||||
}
|
||||
|
||||
/** @var DebugFormatterHelper $formatter */
|
||||
$formatter = $this->helper->getHelperSet()->get('debug_formatter');
|
||||
/** @var Process $process */
|
||||
$process = ($this->createProcess)($cmd);
|
||||
|
||||
if ($output->isVeryVerbose()) {
|
||||
$output->write(
|
||||
$formatter->start(spl_object_hash($process), str_replace('<', '\\<', $process->getCommandLine())),
|
||||
);
|
||||
}
|
||||
|
||||
$callback = $output->isDebug() ? $this->helper->wrapCallback($output, $process) : null;
|
||||
$process->mustRun($callback);
|
||||
|
||||
if ($output->isVeryVerbose()) {
|
||||
$message = $process->isSuccessful() ? 'Command ran successfully' : sprintf(
|
||||
'%s Command did not run successfully',
|
||||
$process->getExitCode(),
|
||||
);
|
||||
$output->write($formatter->stop(spl_object_hash($process), $message, $process->isSuccessful()));
|
||||
}
|
||||
}
|
||||
}
|
||||
12
module/CLI/src/Util/ProcessRunnerInterface.php
Normal file
12
module/CLI/src/Util/ProcessRunnerInterface.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Util;
|
||||
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
interface ProcessRunnerInterface
|
||||
{
|
||||
public function run(OutputInterface $output, array $cmd): void;
|
||||
}
|
||||
44
module/CLI/test/CliTestUtilsTrait.php
Normal file
44
module/CLI/test/CliTestUtilsTrait.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI;
|
||||
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
trait CliTestUtilsTrait
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
/**
|
||||
* @return ObjectProphecy|Command
|
||||
*/
|
||||
private function createCommandMock(string $name): ObjectProphecy
|
||||
{
|
||||
$command = $this->prophesize(Command::class);
|
||||
$command->getName()->willReturn($name);
|
||||
$command->getDefinition()->willReturn($name);
|
||||
$command->isEnabled()->willReturn(true);
|
||||
$command->getAliases()->willReturn([]);
|
||||
$command->setApplication(Argument::type(Application::class))->willReturn(function (): void {
|
||||
});
|
||||
|
||||
return $command;
|
||||
}
|
||||
|
||||
private function testerForCommand(Command $mainCommand, Command ...$extraCommands): CommandTester
|
||||
{
|
||||
$app = new Application();
|
||||
$app->add($mainCommand);
|
||||
foreach ($extraCommands as $command) {
|
||||
$app->add($command);
|
||||
}
|
||||
|
||||
return new CommandTester($mainCommand);
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,16 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Api;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand;
|
||||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class DisableKeyCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $apiKeyService;
|
||||
@@ -23,10 +22,7 @@ class DisableKeyCommandTest extends TestCase
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
|
||||
$command = new DisableKeyCommand($this->apiKeyService->reveal());
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
$this->commandTester = new CommandTester($command);
|
||||
$this->commandTester = $this->testerForCommand(new DisableKeyCommand($this->apiKeyService->reveal()));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
||||
@@ -7,55 +7,64 @@ namespace ShlinkioTest\Shlink\CLI\Command\Api;
|
||||
use Cake\Chronos\Chronos;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class GenerateKeyCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $apiKeyService;
|
||||
private ObjectProphecy $roleResolver;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
|
||||
$this->roleResolver = $this->prophesize(RoleResolverInterface::class);
|
||||
$this->roleResolver->determineRoles(Argument::type(InputInterface::class))->willReturn([]);
|
||||
$roleResolver = $this->prophesize(RoleResolverInterface::class);
|
||||
$roleResolver->determineRoles(Argument::type(InputInterface::class))->willReturn([]);
|
||||
|
||||
$command = new GenerateKeyCommand($this->apiKeyService->reveal(), $this->roleResolver->reveal());
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
$this->commandTester = new CommandTester($command);
|
||||
$command = new GenerateKeyCommand($this->apiKeyService->reveal(), $roleResolver->reveal());
|
||||
$this->commandTester = $this->testerForCommand($command);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function noExpirationDateIsDefinedIfNotProvided(): void
|
||||
{
|
||||
$create = $this->apiKeyService->create(null)->willReturn(new ApiKey());
|
||||
$this->apiKeyService->create(null, null)->shouldBeCalledOnce()->willReturn(ApiKey::create());
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('Generated API key: ', $output);
|
||||
$create->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function expirationDateIsDefinedIfProvided(): void
|
||||
{
|
||||
$this->apiKeyService->create(Argument::type(Chronos::class))->shouldBeCalledOnce()
|
||||
->willReturn(new ApiKey());
|
||||
$this->apiKeyService->create(Argument::type(Chronos::class), null)->shouldBeCalledOnce()->willReturn(
|
||||
ApiKey::create(),
|
||||
);
|
||||
|
||||
$this->commandTester->execute([
|
||||
'--expirationDate' => '2016-01-01',
|
||||
'--expiration-date' => '2016-01-01',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function nameIsDefinedIfProvided(): void
|
||||
{
|
||||
$this->apiKeyService->create(null, Argument::type('string'))->shouldBeCalledOnce()->willReturn(
|
||||
ApiKey::create(),
|
||||
);
|
||||
|
||||
$this->commandTester->execute([
|
||||
'--name' => 'Alice',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,19 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Api;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class ListKeysCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $apiKeyService;
|
||||
@@ -25,10 +25,7 @@ class ListKeysCommandTest extends TestCase
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
|
||||
$command = new ListKeysCommand($this->apiKeyService->reveal());
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
$this->commandTester = new CommandTester($command);
|
||||
$this->commandTester = $this->testerForCommand(new ListKeysCommand($this->apiKeyService->reveal()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,7 +36,7 @@ class ListKeysCommandTest extends TestCase
|
||||
{
|
||||
$listKeys = $this->apiKeyService->listKeys($enabledOnly)->willReturn($keys);
|
||||
|
||||
$this->commandTester->execute(['--enabledOnly' => $enabledOnly]);
|
||||
$this->commandTester->execute(['--enabled-only' => $enabledOnly]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals($expected, $output);
|
||||
@@ -49,65 +46,85 @@ class ListKeysCommandTest extends TestCase
|
||||
public function provideKeysAndOutputs(): iterable
|
||||
{
|
||||
yield 'all keys' => [
|
||||
[ApiKey::withKey('foo'), ApiKey::withKey('bar'), ApiKey::withKey('baz')],
|
||||
[$apiKey1 = ApiKey::create(), $apiKey2 = ApiKey::create(), $apiKey3 = ApiKey::create()],
|
||||
false,
|
||||
<<<OUTPUT
|
||||
+-----+------------+-----------------+-------+
|
||||
| Key | Is enabled | Expiration date | Roles |
|
||||
+-----+------------+-----------------+-------+
|
||||
| foo | +++ | - | Admin |
|
||||
| bar | +++ | - | Admin |
|
||||
| baz | +++ | - | Admin |
|
||||
+-----+------------+-----------------+-------+
|
||||
+--------------------------------------+------+------------+-----------------+-------+
|
||||
| Key | Name | Is enabled | Expiration date | Roles |
|
||||
+--------------------------------------+------+------------+-----------------+-------+
|
||||
| {$apiKey1} | - | +++ | - | Admin |
|
||||
| {$apiKey2} | - | +++ | - | Admin |
|
||||
| {$apiKey3} | - | +++ | - | Admin |
|
||||
+--------------------------------------+------+------------+-----------------+-------+
|
||||
|
||||
OUTPUT,
|
||||
];
|
||||
yield 'enabled keys' => [
|
||||
[ApiKey::withKey('foo')->disable(), ApiKey::withKey('bar')],
|
||||
[$apiKey1 = ApiKey::create()->disable(), $apiKey2 = ApiKey::create()],
|
||||
true,
|
||||
<<<OUTPUT
|
||||
+-----+-----------------+-------+
|
||||
| Key | Expiration date | Roles |
|
||||
+-----+-----------------+-------+
|
||||
| foo | - | Admin |
|
||||
| bar | - | Admin |
|
||||
+-----+-----------------+-------+
|
||||
+--------------------------------------+------+-----------------+-------+
|
||||
| Key | Name | Expiration date | Roles |
|
||||
+--------------------------------------+------+-----------------+-------+
|
||||
| {$apiKey1} | - | - | Admin |
|
||||
| {$apiKey2} | - | - | Admin |
|
||||
+--------------------------------------+------+-----------------+-------+
|
||||
|
||||
OUTPUT,
|
||||
];
|
||||
yield 'with roles' => [
|
||||
[
|
||||
ApiKey::withKey('foo'),
|
||||
$this->apiKeyWithRoles('bar', [RoleDefinition::forAuthoredShortUrls()]),
|
||||
$this->apiKeyWithRoles('baz', [RoleDefinition::forDomain((new Domain('example.com'))->setId('1'))]),
|
||||
ApiKey::withKey('foo2'),
|
||||
$this->apiKeyWithRoles('baz2', [
|
||||
$apiKey1 = ApiKey::create(),
|
||||
$apiKey2 = $this->apiKeyWithRoles([RoleDefinition::forAuthoredShortUrls()]),
|
||||
$apiKey3 = $this->apiKeyWithRoles([RoleDefinition::forDomain((new Domain('example.com'))->setId('1'))]),
|
||||
$apiKey4 = ApiKey::create(),
|
||||
$apiKey5 = $this->apiKeyWithRoles([
|
||||
RoleDefinition::forAuthoredShortUrls(),
|
||||
RoleDefinition::forDomain((new Domain('example.com'))->setId('1')),
|
||||
]),
|
||||
ApiKey::withKey('foo3'),
|
||||
$apiKey6 = ApiKey::create(),
|
||||
],
|
||||
true,
|
||||
<<<OUTPUT
|
||||
+------+-----------------+--------------------------+
|
||||
| Key | Expiration date | Roles |
|
||||
+------+-----------------+--------------------------+
|
||||
| foo | - | Admin |
|
||||
| bar | - | Author only |
|
||||
| baz | - | Domain only: example.com |
|
||||
| foo2 | - | Admin |
|
||||
| baz2 | - | Author only |
|
||||
| | | Domain only: example.com |
|
||||
| foo3 | - | Admin |
|
||||
+------+-----------------+--------------------------+
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| Key | Name | Expiration date | Roles |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
| {$apiKey1} | - | - | Admin |
|
||||
| {$apiKey2} | - | - | Author only |
|
||||
| {$apiKey3} | - | - | Domain only: example.com |
|
||||
| {$apiKey4} | - | - | Admin |
|
||||
| {$apiKey5} | - | - | Author only |
|
||||
| | | | Domain only: example.com |
|
||||
| {$apiKey6} | - | - | Admin |
|
||||
+--------------------------------------+------+-----------------+--------------------------+
|
||||
|
||||
OUTPUT,
|
||||
];
|
||||
yield 'with names' => [
|
||||
[
|
||||
$apiKey1 = ApiKey::fromMeta(ApiKeyMeta::withName('Alice')),
|
||||
$apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withName('Alice and Bob')),
|
||||
$apiKey3 = ApiKey::fromMeta(ApiKeyMeta::withName('')),
|
||||
$apiKey4 = ApiKey::create(),
|
||||
],
|
||||
true,
|
||||
<<<OUTPUT
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
| Key | Name | Expiration date | Roles |
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
| {$apiKey1} | Alice | - | Admin |
|
||||
| {$apiKey2} | Alice and Bob | - | Admin |
|
||||
| {$apiKey3} | | - | Admin |
|
||||
| {$apiKey4} | - | - | Admin |
|
||||
+--------------------------------------+---------------+-----------------+-------+
|
||||
|
||||
OUTPUT,
|
||||
];
|
||||
}
|
||||
|
||||
private function apiKeyWithRoles(string $key, array $roles): ApiKey
|
||||
private function apiKeyWithRoles(array $roles): ApiKey
|
||||
{
|
||||
$apiKey = ApiKey::withKey($key);
|
||||
$apiKey = ApiKey::create();
|
||||
foreach ($roles as $role) {
|
||||
$apiKey->registerRole($role);
|
||||
}
|
||||
|
||||
@@ -9,21 +9,19 @@ use Doctrine\DBAL\Platforms\AbstractPlatform;
|
||||
use Doctrine\DBAL\Schema\AbstractSchemaManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Helper\ProcessHelper;
|
||||
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Lock\LockInterface;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class CreateDatabaseCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $processHelper;
|
||||
@@ -43,7 +41,7 @@ class CreateDatabaseCommandTest extends TestCase
|
||||
$phpExecutableFinder = $this->prophesize(PhpExecutableFinder::class);
|
||||
$phpExecutableFinder->find(false)->willReturn('/usr/local/bin/php');
|
||||
|
||||
$this->processHelper = $this->prophesize(ProcessHelper::class);
|
||||
$this->processHelper = $this->prophesize(ProcessRunnerInterface::class);
|
||||
$this->schemaManager = $this->prophesize(AbstractSchemaManager::class);
|
||||
$this->databasePlatform = $this->prophesize(AbstractPlatform::class);
|
||||
|
||||
@@ -60,10 +58,8 @@ class CreateDatabaseCommandTest extends TestCase
|
||||
$this->regularConn->reveal(),
|
||||
$noDbNameConn->reveal(),
|
||||
);
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
|
||||
$this->commandTester = new CommandTester($command);
|
||||
$this->commandTester = $this->testerForCommand($command);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -113,12 +109,12 @@ class CreateDatabaseCommandTest extends TestCase
|
||||
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
|
||||
});
|
||||
$listTables = $this->schemaManager->listTableNames()->willReturn([]);
|
||||
$runCommand = $this->processHelper->mustRun(Argument::type(OutputInterface::class), [
|
||||
$runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [
|
||||
'/usr/local/bin/php',
|
||||
CreateDatabaseCommand::DOCTRINE_SCRIPT,
|
||||
CreateDatabaseCommand::DOCTRINE_CREATE_SCHEMA_COMMAND,
|
||||
'--no-interaction',
|
||||
], Argument::cetera())->willReturn(new Process([]));
|
||||
]);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
@@ -6,21 +6,19 @@ namespace ShlinkioTest\Shlink\CLI\Command\Db;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Helper\ProcessHelper;
|
||||
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Lock\LockInterface;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class MigrateDatabaseCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $processHelper;
|
||||
@@ -37,28 +35,25 @@ class MigrateDatabaseCommandTest extends TestCase
|
||||
$phpExecutableFinder = $this->prophesize(PhpExecutableFinder::class);
|
||||
$phpExecutableFinder->find(false)->willReturn('/usr/local/bin/php');
|
||||
|
||||
$this->processHelper = $this->prophesize(ProcessHelper::class);
|
||||
$this->processHelper = $this->prophesize(ProcessRunnerInterface::class);
|
||||
|
||||
$command = new MigrateDatabaseCommand(
|
||||
$locker->reveal(),
|
||||
$this->processHelper->reveal(),
|
||||
$phpExecutableFinder->reveal(),
|
||||
);
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
|
||||
$this->commandTester = new CommandTester($command);
|
||||
$this->commandTester = $this->testerForCommand($command);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function migrationsCommandIsRunWithProperVerbosity(): void
|
||||
{
|
||||
$runCommand = $this->processHelper->mustRun(Argument::type(OutputInterface::class), [
|
||||
$runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [
|
||||
'/usr/local/bin/php',
|
||||
MigrateDatabaseCommand::DOCTRINE_MIGRATIONS_SCRIPT,
|
||||
MigrateDatabaseCommand::DOCTRINE_MIGRATE_COMMAND,
|
||||
'--no-interaction',
|
||||
], Argument::cetera())->willReturn(new Process([]));
|
||||
]);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
@@ -5,18 +5,17 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Domain;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Symfony\Component\Console\Application;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class ListDomainsCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $domainService;
|
||||
@@ -24,12 +23,7 @@ class ListDomainsCommandTest extends TestCase
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->domainService = $this->prophesize(DomainServiceInterface::class);
|
||||
|
||||
$command = new ListDomainsCommand($this->domainService->reveal());
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
|
||||
$this->commandTester = new CommandTester($command);
|
||||
$this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService->reveal()));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
||||
@@ -6,13 +6,12 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand;
|
||||
use Shlinkio\Shlink\Core\Exception;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
use function array_pop;
|
||||
@@ -22,7 +21,7 @@ use const PHP_EOL;
|
||||
|
||||
class DeleteShortUrlCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $service;
|
||||
@@ -30,12 +29,7 @@ class DeleteShortUrlCommandTest extends TestCase
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->service = $this->prophesize(DeleteShortUrlServiceInterface::class);
|
||||
|
||||
$command = new DeleteShortUrlCommand($this->service->reveal());
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
|
||||
$this->commandTester = new CommandTester($command);
|
||||
$this->commandTester = $this->testerForCommand(new DeleteShortUrlCommand($this->service->reveal()));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\GenerateShortUrlCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
@@ -16,45 +15,45 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortener;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class GenerateShortUrlCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private const DOMAIN_CONFIG = [
|
||||
'schema' => 'http',
|
||||
'hostname' => 'foo.com',
|
||||
];
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $urlShortener;
|
||||
private ObjectProphecy $stringifier;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->urlShortener = $this->prophesize(UrlShortener::class);
|
||||
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), self::DOMAIN_CONFIG, 5);
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
$this->commandTester = new CommandTester($command);
|
||||
$this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class);
|
||||
$this->stringifier->stringify(Argument::type(ShortUrl::class))->willReturn('');
|
||||
|
||||
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), $this->stringifier->reveal(), 5);
|
||||
$this->commandTester = $this->testerForCommand($command);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
$shortUrl = ShortUrl::createEmpty();
|
||||
$urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willReturn($shortUrl);
|
||||
$stringify = $this->stringifier->stringify($shortUrl)->willReturn('stringified_short_url');
|
||||
|
||||
$this->commandTester->execute([
|
||||
'longUrl' => 'http://domain.com/foo/bar',
|
||||
'--maxVisits' => '3',
|
||||
'--max-visits' => '3',
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
self::assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output);
|
||||
self::assertStringContainsString('stringified_short_url', $output);
|
||||
$urlToShortCode->shouldHaveBeenCalledOnce();
|
||||
$stringify->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -78,7 +77,7 @@ class GenerateShortUrlCommandTest extends TestCase
|
||||
NonUniqueSlugException::fromSlug('my-slug'),
|
||||
);
|
||||
|
||||
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--customSlug' => 'my-slug']);
|
||||
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
|
||||
@@ -89,15 +88,15 @@ class GenerateShortUrlCommandTest extends TestCase
|
||||
/** @test */
|
||||
public function properlyProcessesProvidedTags(): void
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
$shortUrl = ShortUrl::createEmpty();
|
||||
$urlToShortCode = $this->urlShortener->shorten(
|
||||
Argument::type('string'),
|
||||
Argument::that(function (array $tags) {
|
||||
Argument::that(function (ShortUrlMeta $meta) {
|
||||
$tags = $meta->getTags();
|
||||
Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $tags);
|
||||
return $tags;
|
||||
return true;
|
||||
}),
|
||||
Argument::cetera(),
|
||||
)->willReturn($shortUrl);
|
||||
$stringify = $this->stringifier->stringify($shortUrl)->willReturn('stringified_short_url');
|
||||
|
||||
$this->commandTester->execute([
|
||||
'longUrl' => 'http://domain.com/foo/bar',
|
||||
@@ -106,8 +105,9 @@ class GenerateShortUrlCommandTest extends TestCase
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
self::assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output);
|
||||
self::assertStringContainsString('stringified_short_url', $output);
|
||||
$urlToShortCode->shouldHaveBeenCalledOnce();
|
||||
$stringify->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,10 +116,8 @@ class GenerateShortUrlCommandTest extends TestCase
|
||||
*/
|
||||
public function urlValidationHasExpectedValueBasedOnProvidedTags(array $options, ?bool $expectedValidateUrl): void
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
$shortUrl = ShortUrl::createEmpty();
|
||||
$urlToShortCode = $this->urlShortener->shorten(
|
||||
Argument::type('string'),
|
||||
Argument::type('array'),
|
||||
Argument::that(function (ShortUrlMeta $meta) use ($expectedValidateUrl) {
|
||||
Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl());
|
||||
return $meta;
|
||||
|
||||
@@ -5,13 +5,12 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Laminas\Paginator\Adapter\ArrayAdapter;
|
||||
use Laminas\Paginator\Paginator;
|
||||
use Pagerfanta\Adapter\ArrayAdapter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
@@ -19,34 +18,32 @@ use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
use Symfony\Component\Console\Application;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class GetVisitsCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $visitsTracker;
|
||||
private ObjectProphecy $visitsHelper;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->visitsTracker = $this->prophesize(VisitsTrackerInterface::class);
|
||||
$command = new GetVisitsCommand($this->visitsTracker->reveal());
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
$this->commandTester = new CommandTester($command);
|
||||
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
|
||||
$command = new GetVisitsCommand($this->visitsHelper->reveal());
|
||||
$this->commandTester = $this->testerForCommand($command);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function noDateFlagsTriesToListWithoutDateRange(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->visitsTracker->info(
|
||||
$this->visitsHelper->visitsForShortUrl(
|
||||
new ShortUrlIdentifier($shortCode),
|
||||
new VisitsParams(new DateRange(null, null)),
|
||||
)
|
||||
@@ -62,7 +59,7 @@ class GetVisitsCommandTest extends TestCase
|
||||
$shortCode = 'abc123';
|
||||
$startDate = '2016-01-01';
|
||||
$endDate = '2016-02-01';
|
||||
$this->visitsTracker->info(
|
||||
$this->visitsHelper->visitsForShortUrl(
|
||||
new ShortUrlIdentifier($shortCode),
|
||||
new VisitsParams(new DateRange(Chronos::parse($startDate), Chronos::parse($endDate))),
|
||||
)
|
||||
@@ -71,8 +68,8 @@ class GetVisitsCommandTest extends TestCase
|
||||
|
||||
$this->commandTester->execute([
|
||||
'shortCode' => $shortCode,
|
||||
'--startDate' => $startDate,
|
||||
'--endDate' => $endDate,
|
||||
'--start-date' => $startDate,
|
||||
'--end-date' => $endDate,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -81,18 +78,20 @@ class GetVisitsCommandTest extends TestCase
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$startDate = 'foo';
|
||||
$info = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams(new DateRange()))
|
||||
->willReturn(new Paginator(new ArrayAdapter([])));
|
||||
$info = $this->visitsHelper->visitsForShortUrl(
|
||||
new ShortUrlIdentifier($shortCode),
|
||||
new VisitsParams(new DateRange()),
|
||||
)->willReturn(new Paginator(new ArrayAdapter([])));
|
||||
|
||||
$this->commandTester->execute([
|
||||
'shortCode' => $shortCode,
|
||||
'--startDate' => $startDate,
|
||||
'--start-date' => $startDate,
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$info->shouldHaveBeenCalledOnce();
|
||||
self::assertStringContainsString(
|
||||
sprintf('Ignored provided "startDate" since its value "%s" is not a valid date', $startDate),
|
||||
sprintf('Ignored provided "start-date" since its value "%s" is not a valid date', $startDate),
|
||||
$output,
|
||||
);
|
||||
}
|
||||
@@ -101,10 +100,10 @@ class GetVisitsCommandTest extends TestCase
|
||||
public function outputIsProperlyGenerated(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn(
|
||||
$this->visitsHelper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn(
|
||||
new Paginator(new ArrayAdapter([
|
||||
(new Visit(new ShortUrl(''), new Visitor('bar', 'foo', '')))->locate(
|
||||
new VisitLocation(new Location('', 'Spain', '', '', 0, 0, '')),
|
||||
Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate(
|
||||
VisitLocation::fromGeolocation(new Location('', 'Spain', '', '', 0, 0, '')),
|
||||
),
|
||||
])),
|
||||
)->shouldBeCalledOnce();
|
||||
|
||||
@@ -5,24 +5,29 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Laminas\Paginator\Adapter\ArrayAdapter;
|
||||
use Laminas\Paginator\Paginator;
|
||||
use Pagerfanta\Adapter\ArrayAdapter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
use function count;
|
||||
use function explode;
|
||||
|
||||
class ListShortUrlsCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $shortUrlService;
|
||||
@@ -30,10 +35,10 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class);
|
||||
$app = new Application();
|
||||
$command = new ListShortUrlsCommand($this->shortUrlService->reveal(), []);
|
||||
$app->add($command);
|
||||
$this->commandTester = new CommandTester($command);
|
||||
$command = new ListShortUrlsCommand($this->shortUrlService->reveal(), new ShortUrlDataTransformer(
|
||||
new ShortUrlStringifier([]),
|
||||
));
|
||||
$this->commandTester = $this->testerForCommand($command);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -42,7 +47,7 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
// The paginator will return more than one page
|
||||
$data = [];
|
||||
for ($i = 0; $i < 50; $i++) {
|
||||
$data[] = new ShortUrl('url_' . $i);
|
||||
$data[] = ShortUrl::withLongUrl('url_' . $i);
|
||||
}
|
||||
|
||||
$this->shortUrlService->listShortUrls(Argument::cetera())
|
||||
@@ -56,6 +61,7 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
self::assertStringContainsString('Continue with page 2?', $output);
|
||||
self::assertStringContainsString('Continue with page 3?', $output);
|
||||
self::assertStringContainsString('Continue with page 4?', $output);
|
||||
self::assertStringNotContainsString('Continue with page 5?', $output);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -64,7 +70,7 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
// The paginator will return more than one page
|
||||
$data = [];
|
||||
for ($i = 0; $i < 30; $i++) {
|
||||
$data[] = new ShortUrl('url_' . $i);
|
||||
$data[] = ShortUrl::withLongUrl('url_' . $i);
|
||||
}
|
||||
|
||||
$this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
|
||||
@@ -89,24 +95,84 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
{
|
||||
$page = 5;
|
||||
$this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData(['page' => $page]))
|
||||
->willReturn(new Paginator(new ArrayAdapter()))
|
||||
->willReturn(new Paginator(new ArrayAdapter([])))
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
$this->commandTester->setInputs(['y']);
|
||||
$this->commandTester->execute(['--page' => $page]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function ifTagsFlagIsProvidedTagsColumnIsIncluded(): void
|
||||
{
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideOptionalFlags
|
||||
*/
|
||||
public function provideOptionalFlagsMakesNewColumnsToBeIncluded(
|
||||
array $input,
|
||||
array $expectedContents,
|
||||
array $notExpectedContents,
|
||||
ApiKey $apiKey
|
||||
): void {
|
||||
$this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
|
||||
->willReturn(new Paginator(new ArrayAdapter()))
|
||||
->willReturn(new Paginator(new ArrayAdapter([
|
||||
ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
|
||||
'longUrl' => 'foo.com',
|
||||
'tags' => ['foo', 'bar', 'baz'],
|
||||
'apiKey' => $apiKey,
|
||||
])),
|
||||
])))
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
$this->commandTester->setInputs(['y']);
|
||||
$this->commandTester->execute(['--showTags' => true]);
|
||||
$this->commandTester->execute($input);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
self::assertStringContainsString('Tags', $output);
|
||||
|
||||
if (count($expectedContents) === 0 && count($notExpectedContents) === 0) {
|
||||
self::fail('No expectations were run');
|
||||
}
|
||||
|
||||
foreach ($expectedContents as $column) {
|
||||
self::assertStringContainsString($column, $output);
|
||||
}
|
||||
foreach ($notExpectedContents as $column) {
|
||||
self::assertStringNotContainsString($column, $output);
|
||||
}
|
||||
}
|
||||
|
||||
public function provideOptionalFlags(): iterable
|
||||
{
|
||||
$apiKey = ApiKey::fromMeta(ApiKeyMeta::withName('my api key'));
|
||||
$key = $apiKey->toString();
|
||||
|
||||
yield 'tags only' => [
|
||||
['--show-tags' => true],
|
||||
['| Tags ', '| foo, bar, baz'],
|
||||
['| API Key ', '| API Key Name |', $key, '| my api key'],
|
||||
$apiKey,
|
||||
];
|
||||
yield 'api key only' => [
|
||||
['--show-api-key' => true],
|
||||
['| API Key ', $key],
|
||||
['| Tags ', '| foo, bar, baz', '| API Key Name |', '| my api key'],
|
||||
$apiKey,
|
||||
];
|
||||
yield 'api key name only' => [
|
||||
['--show-api-key-name' => true],
|
||||
['| API Key Name |', '| my api key'],
|
||||
['| Tags ', '| foo, bar, baz', '| API Key ', $key],
|
||||
$apiKey,
|
||||
];
|
||||
yield 'tags and api key' => [
|
||||
['--show-tags' => true, '--show-api-key' => true],
|
||||
['| API Key ', '| Tags ', '| foo, bar, baz', $key],
|
||||
['| API Key Name |', '| my api key'],
|
||||
$apiKey,
|
||||
];
|
||||
yield 'all' => [
|
||||
['--show-tags' => true, '--show-api-key' => true, '--show-api-key-name' => true],
|
||||
['| API Key ', '| Tags ', '| API Key Name |', '| foo, bar, baz', $key, '| my api key'],
|
||||
[],
|
||||
$apiKey,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,7 +193,7 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
'tags' => $tags,
|
||||
'startDate' => $startDate !== null ? Chronos::parse($startDate)->toAtomString() : null,
|
||||
'endDate' => $endDate !== null ? Chronos::parse($endDate)->toAtomString() : null,
|
||||
]))->willReturn(new Paginator(new ArrayAdapter()));
|
||||
]))->willReturn(new Paginator(new ArrayAdapter([])));
|
||||
|
||||
$this->commandTester->setInputs(['n']);
|
||||
$this->commandTester->execute($commandArgs);
|
||||
@@ -139,22 +205,22 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
{
|
||||
yield [[], 1, null, []];
|
||||
yield [['--page' => $page = 3], $page, null, []];
|
||||
yield [['--searchTerm' => $searchTerm = 'search this'], 1, $searchTerm, []];
|
||||
yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, []];
|
||||
yield [
|
||||
['--page' => $page = 3, '--searchTerm' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'],
|
||||
['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'],
|
||||
$page,
|
||||
$searchTerm,
|
||||
explode(',', $tags),
|
||||
];
|
||||
yield [
|
||||
['--startDate' => $startDate = '2019-01-01'],
|
||||
['--start-date' => $startDate = '2019-01-01'],
|
||||
1,
|
||||
null,
|
||||
[],
|
||||
$startDate,
|
||||
];
|
||||
yield [
|
||||
['--endDate' => $endDate = '2020-05-23'],
|
||||
['--end-date' => $endDate = '2020-05-23'],
|
||||
1,
|
||||
null,
|
||||
[],
|
||||
@@ -162,7 +228,7 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
$endDate,
|
||||
];
|
||||
yield [
|
||||
['--startDate' => $startDate = '2019-01-01', '--endDate' => $endDate = '2020-05-23'],
|
||||
['--start-date' => $startDate = '2019-01-01', '--end-date' => $endDate = '2020-05-23'],
|
||||
1,
|
||||
null,
|
||||
[],
|
||||
@@ -180,7 +246,7 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
{
|
||||
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
|
||||
'orderBy' => $expectedOrderBy,
|
||||
]))->willReturn(new Paginator(new ArrayAdapter()));
|
||||
]))->willReturn(new Paginator(new ArrayAdapter([])));
|
||||
|
||||
$this->commandTester->setInputs(['n']);
|
||||
$this->commandTester->execute($commandArgs);
|
||||
@@ -191,9 +257,9 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
public function provideOrderBy(): iterable
|
||||
{
|
||||
yield [[], null];
|
||||
yield [['--orderBy' => 'foo'], 'foo'];
|
||||
yield [['--orderBy' => 'foo,ASC'], ['foo' => 'ASC']];
|
||||
yield [['--orderBy' => 'bar,DESC'], ['bar' => 'DESC']];
|
||||
yield [['--order-by' => 'foo'], 'foo'];
|
||||
yield [['--order-by' => 'foo,ASC'], ['foo' => 'ASC']];
|
||||
yield [['--order-by' => 'bar,DESC'], ['bar' => 'DESC']];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -207,7 +273,7 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
'endDate' => null,
|
||||
'orderBy' => null,
|
||||
'itemsPerPage' => -1,
|
||||
]))->willReturn(new Paginator(new ArrayAdapter()));
|
||||
]))->willReturn(new Paginator(new ArrayAdapter([])));
|
||||
|
||||
$this->commandTester->execute(['--all' => true]);
|
||||
|
||||
|
||||
@@ -5,14 +5,13 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
use function sprintf;
|
||||
@@ -21,7 +20,7 @@ use const PHP_EOL;
|
||||
|
||||
class ResolveUrlCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $urlResolver;
|
||||
@@ -29,11 +28,7 @@ class ResolveUrlCommandTest extends TestCase
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
|
||||
$command = new ResolveUrlCommand($this->urlResolver->reveal());
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
|
||||
$this->commandTester = new CommandTester($command);
|
||||
$this->commandTester = $this->testerForCommand(new ResolveUrlCommand($this->urlResolver->reveal()));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -41,7 +36,7 @@ class ResolveUrlCommandTest extends TestCase
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$expectedUrl = 'http://domain.com/foo/bar';
|
||||
$shortUrl = new ShortUrl($expectedUrl);
|
||||
$shortUrl = ShortUrl::withLongUrl($expectedUrl);
|
||||
$this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn($shortUrl)
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
|
||||
@@ -6,16 +6,15 @@ namespace ShlinkioTest\Shlink\CLI\Command\Tag;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Tag\CreateTagCommand;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class CreateTagCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $tagService;
|
||||
@@ -23,12 +22,7 @@ class CreateTagCommandTest extends TestCase
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->tagService = $this->prophesize(TagServiceInterface::class);
|
||||
|
||||
$command = new CreateTagCommand($this->tagService->reveal());
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
|
||||
$this->commandTester = new CommandTester($command);
|
||||
$this->commandTester = $this->testerForCommand(new CreateTagCommand($this->tagService->reveal()));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
||||
@@ -5,16 +5,15 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class DeleteTagsCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $tagService;
|
||||
@@ -22,12 +21,7 @@ class DeleteTagsCommandTest extends TestCase
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->tagService = $this->prophesize(TagServiceInterface::class);
|
||||
|
||||
$command = new DeleteTagsCommand($this->tagService->reveal());
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
|
||||
$this->commandTester = new CommandTester($command);
|
||||
$this->commandTester = $this->testerForCommand(new DeleteTagsCommand($this->tagService->reveal()));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
||||
@@ -5,18 +5,17 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class ListTagsCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $tagService;
|
||||
@@ -24,12 +23,7 @@ class ListTagsCommandTest extends TestCase
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->tagService = $this->prophesize(TagServiceInterface::class);
|
||||
|
||||
$command = new ListTagsCommand($this->tagService->reveal());
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
|
||||
$this->commandTester = new CommandTester($command);
|
||||
$this->commandTester = $this->testerForCommand(new ListTagsCommand($this->tagService->reveal()));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
||||
@@ -5,19 +5,18 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class RenameTagCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $tagService;
|
||||
@@ -25,12 +24,7 @@ class RenameTagCommandTest extends TestCase
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->tagService = $this->prophesize(TagServiceInterface::class);
|
||||
|
||||
$command = new RenameTagCommand($this->tagService->reveal());
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
|
||||
$this->commandTester = new CommandTester($command);
|
||||
$this->commandTester = $this->testerForCommand(new RenameTagCommand($this->tagService->reveal()));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
||||
107
module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php
Normal file
107
module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Visit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class DownloadGeoLiteDbCommandTest extends TestCase
|
||||
{
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $dbUpdater;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
|
||||
$this->commandTester = $this->testerForCommand(new DownloadGeoLiteDbCommand($this->dbUpdater->reveal()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideFailureParams
|
||||
*/
|
||||
public function showsProperMessageWhenGeoLiteUpdateFails(
|
||||
bool $olderDbExists,
|
||||
string $expectedMessage,
|
||||
int $expectedExitCode
|
||||
): void {
|
||||
$checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will(
|
||||
function (array $args) use ($olderDbExists): void {
|
||||
[$beforeDownload, $handleProgress] = $args;
|
||||
|
||||
$beforeDownload($olderDbExists);
|
||||
$handleProgress(100, 50);
|
||||
|
||||
throw $olderDbExists
|
||||
? GeolocationDbUpdateFailedException::withOlderDb()
|
||||
: GeolocationDbUpdateFailedException::withoutOlderDb();
|
||||
},
|
||||
);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$exitCode = $this->commandTester->getStatusCode();
|
||||
|
||||
self::assertStringContainsString(
|
||||
sprintf('%s GeoLite2 db file...', $olderDbExists ? 'Updating' : 'Downloading'),
|
||||
$output,
|
||||
);
|
||||
self::assertStringContainsString($expectedMessage, $output);
|
||||
self::assertSame($expectedExitCode, $exitCode);
|
||||
$checkDbUpdate->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideFailureParams(): iterable
|
||||
{
|
||||
yield 'existing db' => [
|
||||
true,
|
||||
'[WARNING] GeoLite2 db file update failed. Visits will continue to be located',
|
||||
ExitCodes::EXIT_WARNING,
|
||||
];
|
||||
yield 'not existing db' => [
|
||||
false,
|
||||
'[ERROR] GeoLite2 db file download failed. It will not be possible to locate',
|
||||
ExitCodes::EXIT_FAILURE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideSuccessParams
|
||||
*/
|
||||
public function printsExpectedMessageWhenNoErrorOccurs(callable $checkUpdateBehavior, string $expectedMessage): void
|
||||
{
|
||||
$checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will($checkUpdateBehavior);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$exitCode = $this->commandTester->getStatusCode();
|
||||
|
||||
self::assertStringContainsString($expectedMessage, $output);
|
||||
self::assertSame(ExitCodes::EXIT_SUCCESS, $exitCode);
|
||||
$checkDbUpdate->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideSuccessParams(): iterable
|
||||
{
|
||||
yield 'up to date db' => [function (): void {
|
||||
}, '[INFO] GeoLite2 db file is up to date.'];
|
||||
yield 'outdated db' => [function (array $args): void {
|
||||
[$beforeDownload] = $args;
|
||||
$beforeDownload(true);
|
||||
}, '[OK] GeoLite2 db file properly downloaded.'];
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,10 @@ namespace ShlinkioTest\Shlink\CLI\Command\Visit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand;
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Common\Util\IpAddress;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
@@ -21,7 +20,7 @@ use Shlinkio\Shlink\Core\Visit\VisitLocator;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Exception\RuntimeException;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
@@ -33,37 +32,36 @@ use const PHP_EOL;
|
||||
|
||||
class LocateVisitsCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $visitService;
|
||||
private ObjectProphecy $ipResolver;
|
||||
private ObjectProphecy $lock;
|
||||
private ObjectProphecy $dbUpdater;
|
||||
private ObjectProphecy $downloadDbCommand;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->visitService = $this->prophesize(VisitLocator::class);
|
||||
$this->ipResolver = $this->prophesize(IpLocationResolverInterface::class);
|
||||
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
|
||||
|
||||
$locker = $this->prophesize(Lock\LockFactory::class);
|
||||
$this->lock = $this->prophesize(Lock\LockInterface::class);
|
||||
$this->lock->acquire(false)->willReturn(true);
|
||||
$this->lock->release()->will(function (): void {
|
||||
});
|
||||
$locker->createLock(Argument::type('string'), 90.0, false)->willReturn($this->lock->reveal());
|
||||
$locker->createLock(Argument::type('string'), 600.0, false)->willReturn($this->lock->reveal());
|
||||
|
||||
$command = new LocateVisitsCommand(
|
||||
$this->visitService->reveal(),
|
||||
$this->ipResolver->reveal(),
|
||||
$locker->reveal(),
|
||||
$this->dbUpdater->reveal(),
|
||||
);
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
|
||||
$this->commandTester = new CommandTester($command);
|
||||
$this->downloadDbCommand = $this->createCommandMock(DownloadGeoLiteDbCommand::NAME);
|
||||
$this->downloadDbCommand->run(Argument::cetera())->willReturn(ExitCodes::EXIT_SUCCESS);
|
||||
|
||||
$this->commandTester = $this->testerForCommand($command, $this->downloadDbCommand->reveal());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,8 +75,8 @@ class LocateVisitsCommandTest extends TestCase
|
||||
bool $expectWarningPrint,
|
||||
array $args
|
||||
): void {
|
||||
$visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
|
||||
$location = new VisitLocation(Location::emptyInstance());
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
|
||||
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
|
||||
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
|
||||
|
||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will($mockMethodBehavior);
|
||||
@@ -121,8 +119,8 @@ class LocateVisitsCommandTest extends TestCase
|
||||
*/
|
||||
public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message): void
|
||||
{
|
||||
$visit = new Visit(new ShortUrl(''), new Visitor('', '', $address));
|
||||
$location = new VisitLocation(Location::emptyInstance());
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $address, ''));
|
||||
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
|
||||
|
||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
|
||||
$this->invokeHelperMethods($visit, $location),
|
||||
@@ -154,8 +152,8 @@ class LocateVisitsCommandTest extends TestCase
|
||||
/** @test */
|
||||
public function errorWhileLocatingIpIsDisplayed(): void
|
||||
{
|
||||
$visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
|
||||
$location = new VisitLocation(Location::emptyInstance());
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
|
||||
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
|
||||
|
||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
|
||||
$this->invokeHelperMethods($visit, $location),
|
||||
@@ -202,41 +200,16 @@ class LocateVisitsCommandTest extends TestCase
|
||||
$resolveIpLocation->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideParams
|
||||
*/
|
||||
public function showsProperMessageWhenGeoLiteUpdateFails(bool $olderDbExists, string $expectedMessage): void
|
||||
/** @test */
|
||||
public function showsProperMessageWhenGeoLiteUpdateFails(): void
|
||||
{
|
||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(function (): void {
|
||||
});
|
||||
$checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will(
|
||||
function (array $args) use ($olderDbExists): void {
|
||||
[$mustBeUpdated, $handleProgress] = $args;
|
||||
|
||||
$mustBeUpdated($olderDbExists);
|
||||
$handleProgress(100, 50);
|
||||
|
||||
throw GeolocationDbUpdateFailedException::create($olderDbExists);
|
||||
},
|
||||
);
|
||||
$this->downloadDbCommand->run(Argument::cetera())->willReturn(ExitCodes::EXIT_FAILURE);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString(
|
||||
sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'),
|
||||
$output,
|
||||
);
|
||||
self::assertStringContainsString($expectedMessage, $output);
|
||||
$locateVisits->shouldHaveBeenCalledTimes((int) $olderDbExists);
|
||||
$checkDbUpdate->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideParams(): iterable
|
||||
{
|
||||
yield [true, '[Warning] GeoLite2 database update failed. Proceeding with old version.'];
|
||||
yield [false, 'GeoLite2 database download failed. It is not possible to locate visits.'];
|
||||
self::assertStringContainsString('It is not possible to locate visits without a GeoLite2 db file.', $output);
|
||||
$this->visitService->locateUnlocatedVisits(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
||||
@@ -14,26 +14,54 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideCreateArgs
|
||||
* @dataProvider providePrev
|
||||
*/
|
||||
public function createBuildsException(bool $olderDbExists, ?Throwable $prev): void
|
||||
public function withOlderDbBuildsException(?Throwable $prev): void
|
||||
{
|
||||
$e = GeolocationDbUpdateFailedException::create($olderDbExists, $prev);
|
||||
$e = GeolocationDbUpdateFailedException::withOlderDb($prev);
|
||||
|
||||
self::assertEquals($olderDbExists, $e->olderDbExists());
|
||||
self::assertTrue($e->olderDbExists());
|
||||
self::assertEquals(
|
||||
'An error occurred while updating geolocation database, and an older version could not be found',
|
||||
'An error occurred while updating geolocation database, but an older DB is already present.',
|
||||
$e->getMessage(),
|
||||
);
|
||||
self::assertEquals(0, $e->getCode());
|
||||
self::assertEquals($prev, $e->getPrevious());
|
||||
}
|
||||
|
||||
public function provideCreateArgs(): iterable
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider providePrev
|
||||
*/
|
||||
public function withoutOlderDbBuildsException(?Throwable $prev): void
|
||||
{
|
||||
yield 'older DB and no prev' => [true, null];
|
||||
yield 'older DB and prev' => [true, new RuntimeException('prev')];
|
||||
yield 'no older DB and no prev' => [false, null];
|
||||
yield 'no older DB and prev' => [false, new Exception('prev')];
|
||||
$e = GeolocationDbUpdateFailedException::withoutOlderDb($prev);
|
||||
|
||||
self::assertFalse($e->olderDbExists());
|
||||
self::assertEquals(
|
||||
'An error occurred while updating geolocation database, and an older version could not be found.',
|
||||
$e->getMessage(),
|
||||
);
|
||||
self::assertEquals(0, $e->getCode());
|
||||
self::assertEquals($prev, $e->getPrevious());
|
||||
}
|
||||
|
||||
public function providePrev(): iterable
|
||||
{
|
||||
yield 'no prev' => [null];
|
||||
yield 'RuntimeException' => [new RuntimeException('prev')];
|
||||
yield 'Exception' => [new Exception('prev')];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function withInvalidEpochInOldDbBuildsException(): void
|
||||
{
|
||||
$e = GeolocationDbUpdateFailedException::withInvalidEpochInOldDb('foobar');
|
||||
|
||||
self::assertTrue($e->olderDbExists());
|
||||
self::assertEquals(
|
||||
'Build epoch with value "foobar" from existing geolocation database, could not be parsed to integer.',
|
||||
$e->getMessage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,17 +6,13 @@ namespace ShlinkioTest\Shlink\CLI\Factory;
|
||||
|
||||
use Laminas\ServiceManager\ServiceManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Factory\ApplicationFactory;
|
||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
|
||||
class ApplicationFactoryTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private ApplicationFactory $factory;
|
||||
|
||||
@@ -54,17 +50,4 @@ class ApplicationFactoryTest extends TestCase
|
||||
AppOptions::class => new AppOptions(),
|
||||
]]);
|
||||
}
|
||||
|
||||
private function createCommandMock(string $name): ObjectProphecy
|
||||
{
|
||||
$command = $this->prophesize(Command::class);
|
||||
$command->getName()->willReturn($name);
|
||||
$command->getDefinition()->willReturn($name);
|
||||
$command->isEnabled()->willReturn(true);
|
||||
$command->getAliases()->willReturn([]);
|
||||
$command->setApplication(Argument::type(Application::class))->willReturn(function (): void {
|
||||
});
|
||||
|
||||
return $command;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,17 +80,9 @@ class GeolocationDbUpdaterTest extends TestCase
|
||||
public function exceptionIsThrownWhenOlderDbIsTooOldAndDownloadFails(int $days): void
|
||||
{
|
||||
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true);
|
||||
$getMeta = $this->geoLiteDbReader->metadata()->willReturn(new Metadata([
|
||||
'binary_format_major_version' => '',
|
||||
'binary_format_minor_version' => '',
|
||||
'build_epoch' => Chronos::now()->subDays($days)->getTimestamp(),
|
||||
'database_type' => '',
|
||||
'languages' => '',
|
||||
'description' => '',
|
||||
'ip_version' => '',
|
||||
'node_count' => 1,
|
||||
'record_size' => 4,
|
||||
]));
|
||||
$getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch(
|
||||
Chronos::now()->subDays($days)->getTimestamp(),
|
||||
));
|
||||
$prev = new RuntimeException('');
|
||||
$download = $this->dbUpdater->downloadFreshCopy(null)->willThrow($prev);
|
||||
|
||||
@@ -120,21 +112,12 @@ class GeolocationDbUpdaterTest extends TestCase
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideSmallDays
|
||||
* @param string|int $buildEpoch
|
||||
*/
|
||||
public function databaseIsNotUpdatedIfItIsYoungerThanOneWeek(int $days): void
|
||||
public function databaseIsNotUpdatedIfItIsYoungerThanOneWeek($buildEpoch): void
|
||||
{
|
||||
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true);
|
||||
$getMeta = $this->geoLiteDbReader->metadata()->willReturn(new Metadata([
|
||||
'binary_format_major_version' => '',
|
||||
'binary_format_minor_version' => '',
|
||||
'build_epoch' => Chronos::now()->subDays($days)->getTimestamp(),
|
||||
'database_type' => '',
|
||||
'languages' => '',
|
||||
'description' => '',
|
||||
'ip_version' => '',
|
||||
'node_count' => 1,
|
||||
'record_size' => 4,
|
||||
]));
|
||||
$getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch($buildEpoch));
|
||||
$download = $this->dbUpdater->downloadFreshCopy(null)->will(function (): void {
|
||||
});
|
||||
|
||||
@@ -147,6 +130,48 @@ class GeolocationDbUpdaterTest extends TestCase
|
||||
|
||||
public function provideSmallDays(): iterable
|
||||
{
|
||||
return map(range(0, 34), fn (int $days) => [$days]);
|
||||
$generateParamsWithTimestamp = static function (int $days) {
|
||||
$timestamp = Chronos::now()->subDays($days)->getTimestamp();
|
||||
return [$days % 2 === 0 ? $timestamp : (string) $timestamp];
|
||||
};
|
||||
|
||||
return map(range(0, 34), $generateParamsWithTimestamp);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function exceptionIsThrownWhenCheckingExistingDatabaseWithInvalidBuildEpoch(): void
|
||||
{
|
||||
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true);
|
||||
$getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch('invalid'));
|
||||
$download = $this->dbUpdater->downloadFreshCopy(null)->will(function (): void {
|
||||
});
|
||||
|
||||
$this->expectException(GeolocationDbUpdateFailedException::class);
|
||||
$this->expectExceptionMessage(
|
||||
'Build epoch with value "invalid" from existing geolocation database, could not be parsed to integer.',
|
||||
);
|
||||
$fileExists->shouldBeCalledOnce();
|
||||
$getMeta->shouldBeCalledOnce();
|
||||
$download->shouldNotBeCalled();
|
||||
|
||||
$this->geolocationDbUpdater->checkDbUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|int $buildEpoch
|
||||
*/
|
||||
private function buildMetaWithBuildEpoch($buildEpoch): Metadata
|
||||
{
|
||||
return new Metadata([
|
||||
'binary_format_major_version' => '',
|
||||
'binary_format_minor_version' => '',
|
||||
'build_epoch' => $buildEpoch,
|
||||
'database_type' => '',
|
||||
'languages' => '',
|
||||
'description' => '',
|
||||
'ip_version' => '',
|
||||
'node_count' => 1,
|
||||
'record_size' => 4,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
106
module/CLI/test/Util/ProcessRunnerTest.php
Normal file
106
module/CLI/test/Util/ProcessRunnerTest.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Util;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Util\ProcessRunner;
|
||||
use Symfony\Component\Console\Helper\DebugFormatterHelper;
|
||||
use Symfony\Component\Console\Helper\HelperSet;
|
||||
use Symfony\Component\Console\Helper\ProcessHelper;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class ProcessRunnerTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private ProcessRunner $runner;
|
||||
private ObjectProphecy $helper;
|
||||
private ObjectProphecy $formatter;
|
||||
private ObjectProphecy $process;
|
||||
private ObjectProphecy $output;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->helper = $this->prophesize(ProcessHelper::class);
|
||||
$this->formatter = $this->prophesize(DebugFormatterHelper::class);
|
||||
$helperSet = $this->prophesize(HelperSet::class);
|
||||
$helperSet->get('debug_formatter')->willReturn($this->formatter->reveal());
|
||||
$this->helper->getHelperSet()->willReturn($helperSet->reveal());
|
||||
$this->process = $this->prophesize(Process::class);
|
||||
|
||||
$this->runner = new ProcessRunner($this->helper->reveal(), fn () => $this->process->reveal());
|
||||
$this->output = $this->prophesize(OutputInterface::class);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function noMessagesAreWrittenWhenOutputIsNotVerbose(): void
|
||||
{
|
||||
$isVeryVerbose = $this->output->isVeryVerbose()->willReturn(false);
|
||||
$isDebug = $this->output->isDebug()->willReturn(false);
|
||||
$mustRun = $this->process->mustRun(Argument::cetera())->willReturn($this->process->reveal());
|
||||
|
||||
$this->runner->run($this->output->reveal(), []);
|
||||
|
||||
$isVeryVerbose->shouldHaveBeenCalledTimes(2);
|
||||
$isDebug->shouldHaveBeenCalledOnce();
|
||||
$mustRun->shouldHaveBeenCalledOnce();
|
||||
$this->process->isSuccessful()->shouldNotHaveBeenCalled();
|
||||
$this->process->getCommandLine()->shouldNotHaveBeenCalled();
|
||||
$this->output->write(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
$this->helper->wrapCallback(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
$this->formatter->start(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
$this->formatter->stop(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function someMessagesAreWrittenWhenOutputIsVerbose(): void
|
||||
{
|
||||
$isVeryVerbose = $this->output->isVeryVerbose()->willReturn(true);
|
||||
$isDebug = $this->output->isDebug()->willReturn(false);
|
||||
$mustRun = $this->process->mustRun(Argument::cetera())->willReturn($this->process->reveal());
|
||||
$isSuccessful = $this->process->isSuccessful()->willReturn(true);
|
||||
$getCommandLine = $this->process->getCommandLine()->willReturn('true');
|
||||
$start = $this->formatter->start(Argument::cetera())->willReturn('');
|
||||
$stop = $this->formatter->stop(Argument::cetera())->willReturn('');
|
||||
|
||||
$this->runner->run($this->output->reveal(), []);
|
||||
|
||||
$isVeryVerbose->shouldHaveBeenCalledTimes(2);
|
||||
$isDebug->shouldHaveBeenCalledOnce();
|
||||
$mustRun->shouldHaveBeenCalledOnce();
|
||||
$this->output->write(Argument::cetera())->shouldHaveBeenCalledTimes(2);
|
||||
$this->helper->wrapCallback(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
$isSuccessful->shouldHaveBeenCalledTimes(2);
|
||||
$getCommandLine->shouldHaveBeenCalledOnce();
|
||||
$start->shouldHaveBeenCalledOnce();
|
||||
$stop->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function wrapsCallbackWhenOutputIsDebug(): void
|
||||
{
|
||||
$isVeryVerbose = $this->output->isVeryVerbose()->willReturn(false);
|
||||
$isDebug = $this->output->isDebug()->willReturn(true);
|
||||
$mustRun = $this->process->mustRun(Argument::cetera())->willReturn($this->process->reveal());
|
||||
$wrapCallback = $this->helper->wrapCallback(Argument::cetera())->willReturn(function (): void {
|
||||
});
|
||||
|
||||
$this->runner->run($this->output->reveal(), []);
|
||||
|
||||
$isVeryVerbose->shouldHaveBeenCalledTimes(2);
|
||||
$isDebug->shouldHaveBeenCalledOnce();
|
||||
$mustRun->shouldHaveBeenCalledOnce();
|
||||
$wrapCallback->shouldHaveBeenCalledOnce();
|
||||
$this->process->isSuccessful()->shouldNotHaveBeenCalled();
|
||||
$this->process->getCommandLine()->shouldNotHaveBeenCalled();
|
||||
$this->output->write(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
$this->formatter->start(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
$this->formatter->stop(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ return [
|
||||
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
ErrorHandler\NotFoundTypeResolverMiddleware::class => ConfigAbstractFactory::class,
|
||||
ErrorHandler\NotFoundTrackerMiddleware::class => ConfigAbstractFactory::class,
|
||||
ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class,
|
||||
ErrorHandler\NotFoundTemplateHandler::class => InvokableFactory::class,
|
||||
|
||||
@@ -22,18 +24,23 @@ return [
|
||||
Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class,
|
||||
Options\NotFoundRedirectOptions::class => ConfigAbstractFactory::class,
|
||||
Options\UrlShortenerOptions::class => ConfigAbstractFactory::class,
|
||||
Options\TrackingOptions::class => ConfigAbstractFactory::class,
|
||||
|
||||
Service\UrlShortener::class => ConfigAbstractFactory::class,
|
||||
Service\VisitsTracker::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrlService::class => ConfigAbstractFactory::class,
|
||||
Visit\VisitLocator::class => ConfigAbstractFactory::class,
|
||||
Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
|
||||
Tag\TagService::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrl\ShortCodeHelper::class => ConfigAbstractFactory::class,
|
||||
|
||||
Tag\TagService::class => ConfigAbstractFactory::class,
|
||||
|
||||
Domain\DomainService::class => ConfigAbstractFactory::class,
|
||||
|
||||
Visit\VisitsTracker::class => ConfigAbstractFactory::class,
|
||||
Visit\VisitLocator::class => ConfigAbstractFactory::class,
|
||||
Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
|
||||
Visit\Transformer\OrphanVisitDataTransformer::class => InvokableFactory::class,
|
||||
|
||||
Util\UrlValidator::class => ConfigAbstractFactory::class,
|
||||
Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class,
|
||||
Util\RedirectResponseHelper::class => ConfigAbstractFactory::class,
|
||||
@@ -41,12 +48,18 @@ return [
|
||||
Action\RedirectAction::class => ConfigAbstractFactory::class,
|
||||
Action\PixelAction::class => ConfigAbstractFactory::class,
|
||||
Action\QrCodeAction::class => ConfigAbstractFactory::class,
|
||||
Action\RobotsAction::class => ConfigAbstractFactory::class,
|
||||
|
||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ConfigAbstractFactory::class,
|
||||
ShortUrl\Helper\ShortUrlStringifier::class => ConfigAbstractFactory::class,
|
||||
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ConfigAbstractFactory::class,
|
||||
ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class,
|
||||
|
||||
Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class,
|
||||
|
||||
Importer\ImportedLinksProcessor::class => ConfigAbstractFactory::class,
|
||||
|
||||
Crawling\CrawlingHelper::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
|
||||
'aliases' => [
|
||||
@@ -55,29 +68,36 @@ return [
|
||||
],
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'],
|
||||
ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\VisitsTracker::class],
|
||||
ErrorHandler\NotFoundRedirectHandler::class => [
|
||||
NotFoundRedirectOptions::class,
|
||||
Util\RedirectResponseHelper::class,
|
||||
'config.router.base_path',
|
||||
],
|
||||
|
||||
Options\AppOptions::class => ['config.app_options'],
|
||||
Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'],
|
||||
Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'],
|
||||
Options\UrlShortenerOptions::class => ['config.url_shortener'],
|
||||
Options\TrackingOptions::class => ['config.tracking'],
|
||||
|
||||
Service\UrlShortener::class => [
|
||||
Util\UrlValidator::class,
|
||||
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class,
|
||||
'em',
|
||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
|
||||
Service\ShortUrl\ShortCodeHelper::class,
|
||||
],
|
||||
Service\VisitsTracker::class => [
|
||||
Visit\VisitsTracker::class => [
|
||||
'em',
|
||||
EventDispatcherInterface::class,
|
||||
'config.url_shortener.anonymize_remote_addr',
|
||||
Options\TrackingOptions::class,
|
||||
],
|
||||
Service\ShortUrlService::class => [
|
||||
'em',
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class,
|
||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
|
||||
],
|
||||
Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class, Util\UrlValidator::class],
|
||||
Visit\VisitLocator::class => ['em'],
|
||||
Visit\VisitsStatsHelper::class => ['em'],
|
||||
Tag\TagService::class => ['em'],
|
||||
@@ -96,26 +116,33 @@ return [
|
||||
|
||||
Action\RedirectAction::class => [
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
Service\VisitsTracker::class,
|
||||
Options\AppOptions::class,
|
||||
Visit\VisitsTracker::class,
|
||||
Options\TrackingOptions::class,
|
||||
Util\RedirectResponseHelper::class,
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\PixelAction::class => [
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
Service\VisitsTracker::class,
|
||||
Options\AppOptions::class,
|
||||
Visit\VisitsTracker::class,
|
||||
Options\TrackingOptions::class,
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\QrCodeAction::class => [
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
'config.url_shortener.domain',
|
||||
ShortUrl\Helper\ShortUrlStringifier::class,
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\RobotsAction::class => [Crawling\CrawlingHelper::class],
|
||||
|
||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em'],
|
||||
ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'],
|
||||
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class],
|
||||
ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class],
|
||||
|
||||
Mercure\MercureUpdatesGenerator::class => ['config.url_shortener.domain'],
|
||||
Mercure\MercureUpdatesGenerator::class => [
|
||||
ShortUrl\Transformer\ShortUrlDataTransformer::class,
|
||||
Visit\Transformer\OrphanVisitDataTransformer::class,
|
||||
],
|
||||
|
||||
Importer\ImportedLinksProcessor::class => [
|
||||
'em',
|
||||
@@ -123,6 +150,8 @@ return [
|
||||
Service\ShortUrl\ShortCodeHelper::class,
|
||||
Util\DoctrineBatchHelper::class,
|
||||
],
|
||||
|
||||
Crawling\CrawlingHelper::class => ['em'],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -84,4 +84,20 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
->build();
|
||||
|
||||
$builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain');
|
||||
|
||||
$builder->createField('title', Types::STRING)
|
||||
->columnName('title')
|
||||
->length(512)
|
||||
->nullable()
|
||||
->build();
|
||||
|
||||
$builder->createField('titleWasAutoResolved', Types::BOOLEAN)
|
||||
->columnName('title_was_auto_resolved')
|
||||
->option('default', false)
|
||||
->build();
|
||||
|
||||
$builder->createField('crawlable', Types::BOOLEAN)
|
||||
->columnName('crawlable')
|
||||
->option('default', false)
|
||||
->build();
|
||||
};
|
||||
|
||||
@@ -47,11 +47,27 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
->build();
|
||||
|
||||
$builder->createManyToOne('shortUrl', Entity\ShortUrl::class)
|
||||
->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE')
|
||||
->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE')
|
||||
->build();
|
||||
|
||||
$builder->createManyToOne('visitLocation', Entity\VisitLocation::class)
|
||||
->addJoinColumn('visit_location_id', 'id', true, false, 'Set NULL')
|
||||
->cascadePersist()
|
||||
->build();
|
||||
|
||||
$builder->createField('visitedUrl', Types::STRING)
|
||||
->columnName('visited_url')
|
||||
->length(Visitor::VISITED_URL_MAX_LENGTH)
|
||||
->nullable()
|
||||
->build();
|
||||
|
||||
$builder->createField('type', Types::STRING)
|
||||
->columnName('type')
|
||||
->length(255)
|
||||
->build();
|
||||
|
||||
$builder->createField('potentialBot', Types::BOOLEAN)
|
||||
->columnName('potential_bot')
|
||||
->option('default', false)
|
||||
->build();
|
||||
};
|
||||
|
||||
@@ -7,45 +7,51 @@ namespace Shlinkio\Shlink\Core;
|
||||
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
use Symfony\Component\Mercure\Publisher;
|
||||
use Symfony\Component\Mercure\Hub;
|
||||
|
||||
return [
|
||||
|
||||
'events' => [
|
||||
'regular' => [
|
||||
EventDispatcher\Event\VisitLocated::class => [
|
||||
EventDispatcher\NotifyVisitToMercure::class,
|
||||
EventDispatcher\NotifyVisitToWebHooks::class,
|
||||
EventDispatcher\Event\UrlVisited::class => [
|
||||
EventDispatcher\LocateVisit::class,
|
||||
],
|
||||
],
|
||||
'async' => [
|
||||
EventDispatcher\Event\ShortUrlVisited::class => [
|
||||
EventDispatcher\LocateShortUrlVisit::class,
|
||||
EventDispatcher\Event\VisitLocated::class => [
|
||||
EventDispatcher\NotifyVisitToMercure::class,
|
||||
EventDispatcher\NotifyVisitToWebHooks::class,
|
||||
EventDispatcher\UpdateGeoLiteDb::class,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
EventDispatcher\LocateShortUrlVisit::class => ConfigAbstractFactory::class,
|
||||
EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class,
|
||||
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
|
||||
EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
|
||||
EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
|
||||
'delegators' => [
|
||||
EventDispatcher\LocateShortUrlVisit::class => [
|
||||
EventDispatcher\NotifyVisitToMercure::class => [
|
||||
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
|
||||
],
|
||||
EventDispatcher\NotifyVisitToWebHooks::class => [
|
||||
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
EventDispatcher\LocateShortUrlVisit::class => [
|
||||
EventDispatcher\LocateVisit::class => [
|
||||
IpLocationResolverInterface::class,
|
||||
'em',
|
||||
'Logger_Shlink',
|
||||
GeolocationDbUpdater::class,
|
||||
DbUpdater::class,
|
||||
EventDispatcherInterface::class,
|
||||
],
|
||||
EventDispatcher\NotifyVisitToWebHooks::class => [
|
||||
@@ -53,15 +59,16 @@ return [
|
||||
'em',
|
||||
'Logger_Shlink',
|
||||
'config.url_shortener.visits_webhooks',
|
||||
'config.url_shortener.domain',
|
||||
ShortUrl\Transformer\ShortUrlDataTransformer::class,
|
||||
Options\AppOptions::class,
|
||||
],
|
||||
EventDispatcher\NotifyVisitToMercure::class => [
|
||||
Publisher::class,
|
||||
Hub::class,
|
||||
Mercure\MercureUpdatesGenerator::class,
|
||||
'em',
|
||||
'Logger_Shlink',
|
||||
],
|
||||
EventDispatcher\UpdateGeoLiteDb::class => [GeolocationDbUpdater::class, 'Logger_Shlink'],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user