mirror of
https://github.com/shlinkio/shlink.git
synced 2026-02-28 12:13:13 +08:00
Compare commits
347 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6ed39b18b | ||
|
|
958c4704f8 | ||
|
|
ef075fb0ce | ||
|
|
556520583a | ||
|
|
399c56a097 | ||
|
|
f078d95588 | ||
|
|
33911afcd6 | ||
|
|
ae8d31e83f | ||
|
|
72c4052012 | ||
|
|
f713a1fa7e | ||
|
|
62488ac4e5 | ||
|
|
ab4c6e5fca | ||
|
|
26f4a969c9 | ||
|
|
703965915d | ||
|
|
24e38a3cf9 | ||
|
|
b12cfaedf3 | ||
|
|
71807e698c | ||
|
|
1d155298c1 | ||
|
|
4dfc5ae681 | ||
|
|
26f237069c | ||
|
|
b6e1c65c4c | ||
|
|
11f94b8306 | ||
|
|
01bcedef7a | ||
|
|
e51384fcc0 | ||
|
|
83c53c8b2e | ||
|
|
1afe08caed | ||
|
|
7289833928 | ||
|
|
f4d10df0f3 | ||
|
|
652b0df054 | ||
|
|
0e9ea5027c | ||
|
|
658303d375 | ||
|
|
ccc3a4b584 | ||
|
|
ef5ac86e0a | ||
|
|
91b90b276a | ||
|
|
85c32c3c9a | ||
|
|
40838255a7 | ||
|
|
a67ccb384f | ||
|
|
cb31e5a581 | ||
|
|
3c12a55872 | ||
|
|
6da8b11674 | ||
|
|
552489611f | ||
|
|
e48d0f4f0c | ||
|
|
49b6063501 | ||
|
|
dd049feb40 | ||
|
|
76a86c452e | ||
|
|
41aec15fab | ||
|
|
245cb0e35d | ||
|
|
7a0b1e8494 | ||
|
|
70c1c9f018 | ||
|
|
97e965157b | ||
|
|
04bbd471ff | ||
|
|
650a286982 | ||
|
|
ad44a8441a | ||
|
|
b339cf2429 | ||
|
|
9cd97c2f1e | ||
|
|
a7f6b60cba | ||
|
|
0d7dc50670 | ||
|
|
4bc5b9261f | ||
|
|
fb572d5abb | ||
|
|
8fa4219b30 | ||
|
|
a52d0cd419 | ||
|
|
0080ab5132 | ||
|
|
8afa582aa5 | ||
|
|
d847c7648e | ||
|
|
c140db16d1 | ||
|
|
adbf7c6f5e | ||
|
|
5cec697be3 | ||
|
|
587bbfdd73 | ||
|
|
b3a2ceedea | ||
|
|
621f18bf40 | ||
|
|
99c1a59dd4 | ||
|
|
3a149c9edc | ||
|
|
fdaf5fb2f3 | ||
|
|
2f83e90c8b | ||
|
|
05acd4ae88 | ||
|
|
87007677ed | ||
|
|
4ee0032c2a | ||
|
|
06583a0bc1 | ||
|
|
024c9c1a7a | ||
|
|
f3855dbc6f | ||
|
|
758dac47c3 | ||
|
|
81393a76b4 | ||
|
|
9949bb654d | ||
|
|
b0b9902f40 | ||
|
|
5aa8de11f4 | ||
|
|
b18c9e495f | ||
|
|
d3590234a3 | ||
|
|
39adef8ab8 | ||
|
|
13e443880a | ||
|
|
45961144b9 | ||
|
|
34129b8d24 | ||
|
|
48bd97fe41 | ||
|
|
b1b67c497e | ||
|
|
237fb95b4b | ||
|
|
c1b7c6ba6c | ||
|
|
d8add9291f | ||
|
|
a93edf158e | ||
|
|
fdadf3ba07 | ||
|
|
3e26f1113d | ||
|
|
822652cac3 | ||
|
|
1447687ebe | ||
|
|
12150f775d | ||
|
|
5f2f179581 | ||
|
|
407134bab1 | ||
|
|
de5b895fad | ||
|
|
80e3f01562 | ||
|
|
6904dcfed0 | ||
|
|
21863e8de6 | ||
|
|
d75be372cb | ||
|
|
edaf999bf5 | ||
|
|
3e98485c8b | ||
|
|
cc292886a6 | ||
|
|
0c1b36d0d4 | ||
|
|
a06957e9fa | ||
|
|
390bc59d99 | ||
|
|
85464f0fbb | ||
|
|
42f7a68ba5 | ||
|
|
e3397a7c90 | ||
|
|
46b4a21617 | ||
|
|
fc0aba6311 | ||
|
|
0b96a79c41 | ||
|
|
a5929ebb29 | ||
|
|
ce9ec0d738 | ||
|
|
961178fd82 | ||
|
|
49c73a9590 | ||
|
|
92c80e7833 | ||
|
|
6d5bce0078 | ||
|
|
112cbb9039 | ||
|
|
812c5f4993 | ||
|
|
921f303404 | ||
|
|
e0a9f8120c | ||
|
|
8ecc241a4b | ||
|
|
30e34151ed | ||
|
|
d734578f74 | ||
|
|
37c8328eed | ||
|
|
e71f6bb528 | ||
|
|
f7ae52f86e | ||
|
|
067d1cc41c | ||
|
|
b97af7efb9 | ||
|
|
fd0ecc05b2 | ||
|
|
5b934c3f9a | ||
|
|
c7a2f499e0 | ||
|
|
713f7e7bc9 | ||
|
|
09078e4c6a | ||
|
|
1f66ec2af5 | ||
|
|
936e5b3b86 | ||
|
|
99f28b569b | ||
|
|
0c83dea8b7 | ||
|
|
30edfdbdc5 | ||
|
|
60ef98b836 | ||
|
|
73c8b53882 | ||
|
|
425d8f0a3f | ||
|
|
92a83b82a0 | ||
|
|
d1ec15febf | ||
|
|
dd345c82ea | ||
|
|
2bf3e6a13b | ||
|
|
0b04476c99 | ||
|
|
229dc93132 | ||
|
|
0952c488be | ||
|
|
c4f28b3a32 | ||
|
|
201f25e0ad | ||
|
|
0c3523c34a | ||
|
|
0d7a0ee9ea | ||
|
|
931bdb0cd7 | ||
|
|
8807a78463 | ||
|
|
d832133410 | ||
|
|
cdde59b543 | ||
|
|
463dfe9729 | ||
|
|
805c8c87ba | ||
|
|
7ba2cfc010 | ||
|
|
40794c476f | ||
|
|
c3ab871366 | ||
|
|
42a5296f93 | ||
|
|
183db4ff80 | ||
|
|
0bc9bd9281 | ||
|
|
9bed7ef156 | ||
|
|
8f68e4b9f5 | ||
|
|
6589c8fce6 | ||
|
|
38b313a25d | ||
|
|
dab0ebeb99 | ||
|
|
27bf7220b9 | ||
|
|
e68ef87c66 | ||
|
|
29b747c192 | ||
|
|
2047d6b772 | ||
|
|
71e7938b7a | ||
|
|
6bce219eb3 | ||
|
|
dfcac525bc | ||
|
|
da307aee0a | ||
|
|
edf2b5b4c2 | ||
|
|
f41d947cf7 | ||
|
|
54bc169525 | ||
|
|
05d55c4000 | ||
|
|
739f5eb421 | ||
|
|
0aab1bdc4e | ||
|
|
47f99cf6cc | ||
|
|
55c9773a02 | ||
|
|
4b66aaba5c | ||
|
|
4223408090 | ||
|
|
58e6b0b683 | ||
|
|
891438c672 | ||
|
|
910864eaaf | ||
|
|
598c0757be | ||
|
|
01e0a95e14 | ||
|
|
f459a99e7e | ||
|
|
85e18a4754 | ||
|
|
1650499a38 | ||
|
|
51f243995a | ||
|
|
aeafb244d9 | ||
|
|
142417dda1 | ||
|
|
da658185c3 | ||
|
|
ef82158368 | ||
|
|
083ccd36b7 | ||
|
|
d61c79da84 | ||
|
|
8f76c3e202 | ||
|
|
23aa7a015c | ||
|
|
674a4416cf | ||
|
|
db85915c2f | ||
|
|
dfc8e8d74e | ||
|
|
b2b424a4ed | ||
|
|
3433899577 | ||
|
|
b1f814e118 | ||
|
|
7aa6afeb30 | ||
|
|
d414496a3c | ||
|
|
d4684fd01f | ||
|
|
bb444a02fe | ||
|
|
e980a8d121 | ||
|
|
f493baaf2b | ||
|
|
28f26920dd | ||
|
|
69e994c067 | ||
|
|
656083cb6f | ||
|
|
ab9ea887d2 | ||
|
|
9ac6a50e66 | ||
|
|
acc9cb94b5 | ||
|
|
01829c82ee | ||
|
|
9c02ea8799 | ||
|
|
d202538581 | ||
|
|
a84b642ba5 | ||
|
|
74176c298f | ||
|
|
91e21441f7 | ||
|
|
896b7f2d73 | ||
|
|
66ed152358 | ||
|
|
257134cd80 | ||
|
|
a4373aee91 | ||
|
|
7442905873 | ||
|
|
d3af51f684 | ||
|
|
04419a7242 | ||
|
|
a45d6e6b44 | ||
|
|
37b1306eb3 | ||
|
|
cff6573767 | ||
|
|
a2f34e02ad | ||
|
|
796543d194 | ||
|
|
3b25fb27fe | ||
|
|
3b20f955ff | ||
|
|
c81ae9c40d | ||
|
|
7ceae7af87 | ||
|
|
5e02cfe375 | ||
|
|
6e836b5fd9 | ||
|
|
8753e3a77f | ||
|
|
6a2227efc5 | ||
|
|
1fbcea7a06 | ||
|
|
168c839cf1 | ||
|
|
162e913cc4 | ||
|
|
5aaf50d68e | ||
|
|
d2f5be1d18 | ||
|
|
36ab455a49 | ||
|
|
ee8cab8455 | ||
|
|
bd884e85d4 | ||
|
|
5ceb6fb740 | ||
|
|
0d6155e8bc | ||
|
|
a78c59c11a | ||
|
|
173420c608 | ||
|
|
10b0ec301b | ||
|
|
1706a869d9 | ||
|
|
d0393799d2 | ||
|
|
739433ba8b | ||
|
|
a15e9c29c8 | ||
|
|
d58f89aa26 | ||
|
|
b7671f70da | ||
|
|
52366b9dd4 | ||
|
|
32417e40cb | ||
|
|
4cb44be9a0 | ||
|
|
a484455b0b | ||
|
|
4b3ed2b7ba | ||
|
|
e2986a7b4c | ||
|
|
82e04800aa | ||
|
|
5d367da626 | ||
|
|
59de5a5f55 | ||
|
|
0855104068 | ||
|
|
8c6f97c4e2 | ||
|
|
2d16856582 | ||
|
|
41e903cf26 | ||
|
|
4872bd3a92 | ||
|
|
8b675f55cc | ||
|
|
acda7f02c6 | ||
|
|
184ff90b9f | ||
|
|
d8be3c28cb | ||
|
|
3d358ab046 | ||
|
|
960bdfc232 | ||
|
|
101b4daff4 | ||
|
|
13431ff8cf | ||
|
|
4cdcad29df | ||
|
|
a4c34ff7be | ||
|
|
2b7b5e9a8f | ||
|
|
58db902084 | ||
|
|
983e3c9eaa | ||
|
|
dbe35cf567 | ||
|
|
8298f9d491 | ||
|
|
16a951b938 | ||
|
|
51fcbfb3c2 | ||
|
|
e01e370d16 | ||
|
|
736ac8ba90 | ||
|
|
d07104b8d9 | ||
|
|
cad53e397a | ||
|
|
3608a6d068 | ||
|
|
92ddd2eebe | ||
|
|
bf0b58b344 | ||
|
|
ff543b151c | ||
|
|
d842025835 | ||
|
|
230e56370a | ||
|
|
a8514a9ae4 | ||
|
|
148f7a9cfe | ||
|
|
29d50cabc2 | ||
|
|
a8f8297131 | ||
|
|
cd4b632d75 | ||
|
|
843754b7e7 | ||
|
|
847cc2bc50 | ||
|
|
751bd15785 | ||
|
|
c12db7567e | ||
|
|
e8069a10ba | ||
|
|
9742bf13e4 | ||
|
|
6441707c76 | ||
|
|
23bcba4fd9 | ||
|
|
9049a205b7 | ||
|
|
8cfa0b595c | ||
|
|
4b958e8b87 | ||
|
|
bcd5d2848d | ||
|
|
b59cbeceac | ||
|
|
46f948a584 | ||
|
|
14bf3a134b | ||
|
|
1557438fdf | ||
|
|
27b680e0cd | ||
|
|
14314ef939 | ||
|
|
bf5c168d7d | ||
|
|
1e0791416d | ||
|
|
ab8d42b609 | ||
|
|
96dbdbe7c9 | ||
|
|
6f135ad6ab |
2
.github/ISSUE_TEMPLATE.md
vendored
2
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,7 +1,7 @@
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be expected.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personally if an issue gets eventually closed.
|
||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
-->
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/Bug.md
vendored
8
.github/ISSUE_TEMPLATE/Bug.md
vendored
@@ -7,18 +7,18 @@ labels: bug
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be expected.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personally if an issue gets eventually closed.
|
||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
|
||||
With that said, please fill in the information requested next. More information might be requested next (like logs or system configs).
|
||||
-->
|
||||
|
||||
#### How Shlink is set-up
|
||||
#### How Shlink is set up
|
||||
|
||||
* Shlink Version: x.y.z
|
||||
* PHP Version: x.y.z
|
||||
* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted openswoole|Docker image
|
||||
* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted openswoole|Self-hosted RoadRunner|Openswoole Docker image|RoadRunner Docker image
|
||||
* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z)
|
||||
|
||||
#### Summary
|
||||
@@ -31,7 +31,7 @@ With that said, please fill in the information requested next. More information
|
||||
|
||||
#### Expected behavior
|
||||
|
||||
<!-- How did you expected to behave? -->
|
||||
<!-- How did you expect it to behave? -->
|
||||
|
||||
#### How to reproduce
|
||||
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
2
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
@@ -7,7 +7,7 @@ labels: feature
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be expected.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personally if an issue gets eventually closed.
|
||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
6
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
@@ -7,18 +7,18 @@ labels: question
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge and open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be expected.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personally if an issue gets eventually closed.
|
||||
You may also be asked to provide tests or ways to reproduce reported bugs.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
|
||||
With that said, please fill in the information requested next. More information might be requested next (like logs or system configs).
|
||||
-->
|
||||
|
||||
#### How Shlink is set-up
|
||||
#### How Shlink is set up
|
||||
|
||||
* Shlink Version: x.y.z
|
||||
* PHP Version: x.y.z
|
||||
* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted openswoole|Docker image
|
||||
* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted openswoole|Self-hosted RoadRunner|Openswoole Docker image|RoadRunner Docker image
|
||||
* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z)
|
||||
|
||||
#### Summary
|
||||
|
||||
7
.github/actions/ci-setup/action.yml
vendored
7
.github/actions/ci-setup/action.yml
vendored
@@ -28,7 +28,7 @@ runs:
|
||||
extensions: ${{ inputs.php-extensions }}
|
||||
key: ${{ inputs.extensions-cache-key }}
|
||||
- name: Cache extensions
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.extcache.outputs.dir }}
|
||||
key: ${{ steps.extcache.outputs.key }}
|
||||
@@ -41,10 +41,7 @@ runs:
|
||||
extensions: ${{ inputs.php-extensions }}
|
||||
coverage: pcov
|
||||
ini-values: pcov.directory=module
|
||||
- run: echo "::set-output name=composerArgs::${{ inputs.php-version == '8.2' && '--ignore-platform-req=php' || '' }}"
|
||||
id: composer_args
|
||||
shell: bash
|
||||
- name: Install dependencies
|
||||
if: ${{ inputs.install-deps == 'yes' }}
|
||||
run: composer install --no-interaction --prefer-dist ${{ steps.composer_args.outputs.composerArgs }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
shell: bash
|
||||
|
||||
3
.github/workflows/ci-db-tests.yml
vendored
3
.github/workflows/ci-db-tests.yml
vendored
@@ -14,7 +14,6 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.1', '8.2']
|
||||
continue-on-error: ${{ matrix.php-version == '8.2' }}
|
||||
env:
|
||||
LC_ALL: C
|
||||
steps:
|
||||
@@ -28,7 +27,7 @@ jobs:
|
||||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-extensions: openswoole-4.11.1, pdo_sqlsrv-5.10.1
|
||||
php-extensions: openswoole-4.12.1, pdo_sqlsrv-5.10.1
|
||||
extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }}
|
||||
- name: Create test database
|
||||
if: ${{ inputs.platform == 'ms' }}
|
||||
|
||||
14
.github/workflows/ci-docker-image-build.yml
vendored
Normal file
14
.github/workflows/ci-docker-image-build.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: Build docker image
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
|
||||
jobs:
|
||||
build-docker-image:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- run: docker build -t shlink-docker-image:temp .
|
||||
9
.github/workflows/ci-mutation-tests.yml
vendored
9
.github/workflows/ci-mutation-tests.yml
vendored
@@ -14,13 +14,12 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.1', '8.2']
|
||||
continue-on-error: ${{ matrix.php-version == '8.2' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-extensions: openswoole-4.11.1
|
||||
php-extensions: openswoole-4.12.1
|
||||
extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
@@ -28,14 +27,14 @@ jobs:
|
||||
path: build
|
||||
- name: Resolve infection args
|
||||
id: infection_args
|
||||
run: echo "::set-output name=args::--logger-github=false"
|
||||
run: echo "args=--logger-github=false" >> $GITHUB_OUTPUT
|
||||
# TODO Try to filter mutation tests to improve execution times. Investigate why --git-diff-lines --git-diff-base=develop does not work
|
||||
# run: |
|
||||
# BRANCH="${GITHUB_REF#refs/heads/}" |
|
||||
# if [[ $BRANCH == 'main' || $BRANCH == 'develop' ]]; then
|
||||
# echo "::set-output name=args::--logger-github=false"
|
||||
# echo "args=--logger-github=false" >> $GITHUB_OUTPUT
|
||||
# else
|
||||
# echo "::set-output name=args::--logger-github=false --git-diff-lines --git-diff-base=develop"
|
||||
# echo "args=--logger-github=false --git-diff-lines --git-diff-base=develop" >> $GITHUB_OUTPUT
|
||||
# fi;
|
||||
shell: bash
|
||||
- if: ${{ inputs.test-group == 'unit' }}
|
||||
|
||||
3
.github/workflows/ci-tests.yml
vendored
3
.github/workflows/ci-tests.yml
vendored
@@ -14,7 +14,6 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.1', '8.2']
|
||||
continue-on-error: ${{ matrix.php-version == '8.2' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Start postgres database server
|
||||
@@ -26,7 +25,7 @@ jobs:
|
||||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-extensions: openswoole-4.11.1
|
||||
php-extensions: openswoole-4.12.1
|
||||
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
|
||||
- run: composer test:${{ inputs.test-group }}:ci
|
||||
- uses: actions/upload-artifact@v3
|
||||
|
||||
48
.github/workflows/ci.yml
vendored
48
.github/workflows/ci.yml
vendored
@@ -1,12 +1,28 @@
|
||||
name: Continuous integration
|
||||
|
||||
on:
|
||||
pull_request: null
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'LICENSE'
|
||||
- '.*'
|
||||
- '*.md'
|
||||
- '*.xml'
|
||||
- '*.yml*'
|
||||
- '*.json5'
|
||||
- '*.neon'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- 2.x
|
||||
paths-ignore:
|
||||
- 'LICENSE'
|
||||
- '.*'
|
||||
- '*.md'
|
||||
- '*.xml'
|
||||
- '*.yml*'
|
||||
- '*.json5'
|
||||
- '*.neon'
|
||||
|
||||
jobs:
|
||||
static-analysis:
|
||||
@@ -20,7 +36,7 @@ jobs:
|
||||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-extensions: openswoole-4.11.1
|
||||
php-extensions: openswoole-4.12.1
|
||||
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ matrix.command }}
|
||||
- run: composer ${{ matrix.command }}
|
||||
|
||||
@@ -44,7 +60,8 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.1', '8.2']
|
||||
continue-on-error: ${{ matrix.php-version == '8.2' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
|
||||
@@ -52,10 +69,7 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
- run: echo "::set-output name=composerArgs::${{ matrix.php-version == '8.2' && '--ignore-platform-req=php' || '' }}"
|
||||
id: composer_args
|
||||
shell: bash
|
||||
- run: composer install --no-interaction --prefer-dist ${{ steps.composer_args.outputs.composerArgs }}
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr
|
||||
- run: composer test:api:rr
|
||||
|
||||
@@ -138,8 +152,8 @@ jobs:
|
||||
- run: mv build/coverage-db/coverage-db.cov build/coverage-db.cov
|
||||
- run: mv build/coverage-api/coverage-api.cov build/coverage-api.cov
|
||||
- run: mv build/coverage-cli/coverage-cli.cov build/coverage-cli.cov
|
||||
- run: wget https://phar.phpunit.de/phpcov-8.2.1.phar
|
||||
- run: php phpcov-8.2.1.phar merge build --clover build/clover.xml
|
||||
- run: wget https://phar.phpunit.de/phpcov-9.0.0.phar
|
||||
- run: php phpcov-9.0.0.phar merge build --clover build/clover.xml
|
||||
- name: Publish coverage
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
@@ -161,19 +175,3 @@ jobs:
|
||||
coverage-db
|
||||
coverage-api
|
||||
coverage-cli
|
||||
|
||||
build-docker-image:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 100
|
||||
- uses: marceloprado/has-changed-path@v1
|
||||
id: changed-dockerfile
|
||||
with:
|
||||
paths: ./Dockerfile
|
||||
- if: ${{ steps.changed-dockerfile.outputs.changed == 'true' }}
|
||||
run: docker build -t shlink-docker-image:temp .
|
||||
- if: ${{ steps.changed-dockerfile.outputs.changed != 'true' }}
|
||||
run: echo "Dockerfile didn't change. Skipped"
|
||||
|
||||
@@ -4,11 +4,19 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
paths-ignore:
|
||||
- 'LICENSE'
|
||||
- '.*'
|
||||
- '*.md'
|
||||
- '*.xml'
|
||||
- '*.yml*'
|
||||
- '*.json5'
|
||||
- '*.neon'
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-openswool:
|
||||
build-openswoole:
|
||||
uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main
|
||||
secrets: inherit
|
||||
with:
|
||||
6
.github/workflows/publish-release.yml
vendored
6
.github/workflows/publish-release.yml
vendored
@@ -10,14 +10,14 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.1']
|
||||
php-version: ['8.1', '8.2']
|
||||
swoole: ['yes', 'no']
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-extensions: openswoole-4.11.1
|
||||
php-extensions: openswoole-4.12.1
|
||||
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
|
||||
install-deps: 'no'
|
||||
- if: ${{ matrix.swoole == 'yes' }}
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.1']
|
||||
php-version: ['8.1', '8.2']
|
||||
swoole: ['yes', 'no']
|
||||
steps:
|
||||
- uses: geekyeggo/delete-artifact@v1
|
||||
|
||||
4
.github/workflows/publish-swagger-spec.yml
vendored
4
.github/workflows/publish-swagger-spec.yml
vendored
@@ -15,12 +15,12 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Determine version
|
||||
id: determine_version
|
||||
run: echo "::set-output name=version::${GITHUB_REF#refs/tags/}"
|
||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
shell: bash
|
||||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-extensions: openswoole-4.11.1
|
||||
php-extensions: openswoole-4.12.1
|
||||
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
|
||||
- run: composer swagger:inline
|
||||
- run: mkdir ${{ steps.determine_version.outputs.version }}
|
||||
|
||||
179
CHANGELOG.md
179
CHANGELOG.md
@@ -4,6 +4,173 @@ 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).
|
||||
|
||||
## [3.5.4] - 2023-04-12
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#1742](https://github.com/shlinkio/shlink/issues/1742) Fix URLs using schemas which do not contain `//`, like `mailto:`, to no longer be considered valid.
|
||||
* [#1743](https://github.com/shlinkio/shlink/issues/1743) Fix Error when trying to create short URLs from CLI on an openswoole context.
|
||||
|
||||
Unfortunately the reason are real-time updates do not work with openswoole when outside an openswoole request, so the feature has been disabled for that context.
|
||||
|
||||
|
||||
## [3.5.3] - 2023-03-31
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#1715](https://github.com/shlinkio/shlink/issues/1715) Fix short URL creation/edition allowing long URLs without schema. Now a validation error is thrown.
|
||||
* [#1537](https://github.com/shlinkio/shlink/issues/1537) Fix incorrect list of tags being returned for some author-only API keys.
|
||||
* [#1738](https://github.com/shlinkio/shlink/issues/1738) Fix memory leak when importing short URLs with many visits.
|
||||
|
||||
|
||||
## [3.5.2] - 2023-02-16
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* [#1696](https://github.com/shlinkio/shlink/issues/1696) Migrated to PHPUnit 10.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#1698](https://github.com/shlinkio/shlink/issues/1698) Fixed error 500 in `robots.txt`.
|
||||
* [#1688](https://github.com/shlinkio/shlink/issues/1688) Fixed huge performance degradation on `/tags/stats` endpoint.
|
||||
* [#1693](https://github.com/shlinkio/shlink/issues/1693) Fixed Shlink thinking database already exists if it finds foreign tables.
|
||||
|
||||
|
||||
## [3.5.1] - 2023-02-04
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* [#1685](https://github.com/shlinkio/shlink/issues/1685) Changed `loosely` mode to `loose`, as it was a typo. The old one keeps working and maps to the new one, but it's considered deprecated.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#1682](https://github.com/shlinkio/shlink/issues/1682) Fixed incorrect case-insensitive checks in short URLs when using Microsoft SQL server.
|
||||
* [#1684](https://github.com/shlinkio/shlink/issues/1684) Fixed entities metadata cache not being cleared at docker container start-up when using redis with replication.
|
||||
|
||||
|
||||
## [3.5.0] - 2023-01-28
|
||||
### Added
|
||||
* [#1557](https://github.com/shlinkio/shlink/issues/1557) Added support to dynamically redirect to different long URLs based on the visitor's device type.
|
||||
|
||||
For the moment, only `android`, `ios` and `desktop` can have their own specific long URL, and when the visitor cannot be matched against any of them, the regular long URL will be used.
|
||||
|
||||
In the future, more granular device types could be added if appropriate (iOS tablet, android table, tablet, mobile phone, Linux, Mac, Windows, etc).
|
||||
|
||||
In order to match the visitor's device, the `User-Agent` header is used.
|
||||
|
||||
* [#1632](https://github.com/shlinkio/shlink/issues/1632) Added amount of bots, non-bots and total visits to the visits summary endpoint.
|
||||
* [#1633](https://github.com/shlinkio/shlink/issues/1633) Added amount of bots, non-bots and total visits to the tag stats endpoint.
|
||||
* [#1653](https://github.com/shlinkio/shlink/issues/1653) Added support for all HTTP methods in short URLs, together with two new redirect status codes, 307 and 308.
|
||||
|
||||
Existing Shlink instances will continue to work the same. However, if you decide to set the redirect status codes as 307 or 308, Shlink will also return a redirect for short URLs even when the request method is different from `GET`.
|
||||
|
||||
The status 308 is equivalent to 301, and 307 is equivalent to 302. The difference is that the spec requires the client to respect the original HTTP method when performing the redirect. With 301 and 302, some old clients might perform a `GET` request during the redirect, regardless the original request method.
|
||||
|
||||
* [#1662](https://github.com/shlinkio/shlink/issues/1662) Added support to provide openswoole-specific config options via env vars prefixed with `OPENSWOOLE_`.
|
||||
* [#1389](https://github.com/shlinkio/shlink/issues/1389) and [#706](https://github.com/shlinkio/shlink/issues/706) Added support for case-insensitive short URLs.
|
||||
|
||||
In order to achieve this, a new env var/config option has been implemented (`SHORT_URL_MODE`), which allows either `strict` or ~~`loosely`~~ `loose`.
|
||||
|
||||
Default value is `strict`, but if `loose` is provided, then short URLs will be matched in a case-insensitive way, and new short URLs will be generated with short-codes in lowercase only.
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* [#1676](https://github.com/shlinkio/shlink/issues/1676) Deprecated `GET /short-urls/shorten` endpoint. Use `POST /short-urls` to create short URLs instead.
|
||||
* [#1678](https://github.com/shlinkio/shlink/issues/1678) Deprecated `validateUrl` option on URL creation/edition.
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#1639](https://github.com/shlinkio/shlink/issues/1639) Fixed 500 error returned when request body is not valid JSON, instead of a proper descriptive error.
|
||||
|
||||
|
||||
## [3.4.0] - 2022-12-16
|
||||
### Added
|
||||
* [#1612](https://github.com/shlinkio/shlink/issues/1612) Allowed to filter short URLs out of lists, when `validUntil` date is in the past or have reached their maximum amount of visits.
|
||||
|
||||
This can be done by:
|
||||
|
||||
* Providing `excludeMaxVisitsReached=true` and/or `excludePastValidUntil=true` to the `GET /short-urls` endpoint.
|
||||
* Providing `--exclude-max-visits-reached` and/or `--exclude-past-valid-until` to the `short-urls:list` command.
|
||||
|
||||
* [#1613](https://github.com/shlinkio/shlink/issues/1613) Added amount of visits coming from bots, non-bots and total to every short URL in the short URLs list.
|
||||
|
||||
Additionally, added option to order by non-bot visits, by passing `nonBotVisits-DESC` or `nonBotVisits-ASC`.
|
||||
|
||||
* [#1599](https://github.com/shlinkio/shlink/issues/1599) Added support for credentials on redis DSNs, either only password, or both username and password.
|
||||
* [#1616](https://github.com/shlinkio/shlink/issues/1616) Added support to import orphan visits when importing short URLs from another Shlink instance.
|
||||
* [#1519](https://github.com/shlinkio/shlink/issues/1519) Allowing to search short URLs by default domain.
|
||||
* [#1555](https://github.com/shlinkio/shlink/issues/1555) and [#1625](https://github.com/shlinkio/shlink/issues/1625) Added full support for PHP 8.2, updating the docker image to this version.
|
||||
|
||||
### Changed
|
||||
* [#1563](https://github.com/shlinkio/shlink/issues/1563) Moved logic to reuse command options to option classes instead of base abstract command classes.
|
||||
* [#1569](https://github.com/shlinkio/shlink/issues/1569) Migrated test doubles from phpspec/prophecy to PHPUnit mocks.
|
||||
* [#1329](https://github.com/shlinkio/shlink/issues/1329) Split some logic from `VisitRepository` and `ShortUrlRepository` into separated repository classes.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#1618](https://github.com/shlinkio/shlink/issues/1618) Fixed imported short URLs and visits dates not being set to the target server timezone.
|
||||
* [#1578](https://github.com/shlinkio/shlink/issues/1578) Fixed short URL allowing an empty string as the domain during creation.
|
||||
* [#1580](https://github.com/shlinkio/shlink/issues/1580) Fixed `FLUSHDB` being run on Shlink docker start-up when using redis, causing full cache to be flushed.
|
||||
|
||||
|
||||
## [3.3.2] - 2022-10-18
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#1576](https://github.com/shlinkio/shlink/issues/1576) Fixed error when trying to retry visits location from CLI.
|
||||
|
||||
|
||||
## [3.3.1] - 2022-09-30
|
||||
### Added
|
||||
* *Nothing*
|
||||
@@ -1376,7 +1543,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
||||
|
||||
Endpoints and commands which create short URLs support providing the `domain` now (via query param or CLI flag). If not provided, the short URLs will still be "attached" to the default domain.
|
||||
|
||||
Custom slugs can be created on multiple domains, allowing to share links like `https://doma.in/my-campaign` and `https://example.com/my-campaign`, under the same shlink instance.
|
||||
Custom slugs can be created on multiple domains, allowing to share links like `https://s.test/my-campaign` and `https://example.com/my-campaign`, under the same shlink instance.
|
||||
|
||||
When resolving a short URL to redirect end users, the following rules are applied:
|
||||
|
||||
@@ -1839,7 +2006,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
||||
```json
|
||||
{
|
||||
"shortCode": "12Kb3",
|
||||
"shortUrl": "https://doma.in/12Kb3",
|
||||
"shortUrl": "https://s.test/12Kb3",
|
||||
"longUrl": "https://shlink.io",
|
||||
"dateCreated": "2016-05-01T20:34:16+02:00",
|
||||
"visitsCount": 1029,
|
||||
@@ -1906,7 +2073,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
||||
* [#174](https://github.com/shlinkio/shlink/issues/174) Fixed geolocation not working due to a deprecation on used service.
|
||||
* [#172](https://github.com/shlinkio/shlink/issues/172) Documented missing filtering params for `[GET] /short-codes/{shortCode}/visits` API endpoint, which allow the list to be filtered by date range.
|
||||
|
||||
For example: `https://doma.in/rest/v1/short-urls/abc123/visits?startDate=2017-05-23&endDate=2017-10-05`
|
||||
For example: `https://s.test/rest/v1/short-urls/abc123/visits?startDate=2017-05-23&endDate=2017-10-05`
|
||||
|
||||
* [#169](https://github.com/shlinkio/shlink/issues/169) Fixed unhandled error when parsing `ShortUrlMeta` and date fields are already `DateTime` instances.
|
||||
|
||||
@@ -1978,7 +2145,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
||||
|
||||
This eases integration with third party services.
|
||||
|
||||
With this feature, a simple request to a URL like `https://doma.in/rest/v1/short-codes/shorten?apiKey=[YOUR_API_KEY]&longUrl=[URL_TO_BE_SHORTENED]` would return the shortened one in JSON or plain text format.
|
||||
With this feature, a simple request to a URL like `https://s.test/rest/v1/short-codes/shorten?apiKey=[YOUR_API_KEY]&longUrl=[URL_TO_BE_SHORTENED]` would return the shortened one in JSON or plain text format.
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
@@ -2014,7 +2181,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
||||
### Added
|
||||
* [#125](https://github.com/shlinkio/shlink/issues/125) Implemented a path which returns a 1px image instead of a redirection.
|
||||
|
||||
Useful to track emails. Just add an image pointing to a URL like `https://doma.in/abc123/track` to any email and an invisible image will be generated tracking every time the email is opened.
|
||||
Useful to track emails. Just add an image pointing to a URL like `https://s.test/abc123/track` to any email and an invisible image will be generated tracking every time the email is opened.
|
||||
|
||||
* [#132](https://github.com/shlinkio/shlink/issues/132) Added infection to improve tests
|
||||
|
||||
@@ -2295,7 +2462,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
||||
### Added
|
||||
* [#46](https://github.com/shlinkio/shlink/issues/46) Defined a route that returns a QR code representing the shortened URL.
|
||||
|
||||
In order to get the QR code URL, use a pattern like `https://doma.in/abc123/qr-code`
|
||||
In order to get the QR code URL, use a pattern like `https://s.test/abc123/qr-code`
|
||||
|
||||
* [#32](https://github.com/shlinkio/shlink/issues/32) Added support for other cache adapters by improving the Cache factory
|
||||
* [#14](https://github.com/shlinkio/shlink/issues/14) Added logger and enabled errors logging
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -1,12 +1,13 @@
|
||||
FROM php:8.1.9-alpine3.16 as base
|
||||
FROM php:8.2-alpine3.17 as base
|
||||
|
||||
ARG SHLINK_VERSION=latest
|
||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||
ARG SHLINK_RUNTIME=openswoole
|
||||
ENV SHLINK_RUNTIME ${SHLINK_RUNTIME}
|
||||
ENV OPENSWOOLE_VERSION 4.11.1
|
||||
ENV OPENSWOOLE_VERSION 4.12.1
|
||||
ENV PDO_SQLSRV_VERSION 5.10.1
|
||||
ENV MS_ODBC_SQL_VERSION 17.5.2.2
|
||||
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
|
||||
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
|
||||
ENV LC_ALL "C"
|
||||
|
||||
WORKDIR /etc/shlink
|
||||
@@ -14,7 +15,7 @@ WORKDIR /etc/shlink
|
||||
# Install required PHP extensions
|
||||
RUN \
|
||||
# Temp install dev dependencies needed to compile the extensions
|
||||
apk add --no-cache --virtual .dev-deps sqlite-dev postgresql-dev icu-dev libzip-dev zlib-dev libpng-dev && \
|
||||
apk add --no-cache --virtual .dev-deps sqlite-dev postgresql-dev icu-dev libzip-dev zlib-dev libpng-dev linux-headers && \
|
||||
docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql intl calendar sockets bcmath zip gd && \
|
||||
apk add --no-cache sqlite-libs && \
|
||||
docker-php-ext-install -j"$(nproc)" pdo_sqlite && \
|
||||
@@ -29,11 +30,11 @@ RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
|
||||
docker-php-ext-enable openswoole ; \
|
||||
fi; \
|
||||
if [ $(uname -m) == "x86_64" ]; then \
|
||||
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --no-cache --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \
|
||||
docker-php-ext-enable pdo_sqlsrv && \
|
||||
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk ; \
|
||||
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk ; \
|
||||
fi; \
|
||||
apk del .phpize-deps
|
||||
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016-2021 Alejandro Celaya
|
||||
Copyright (c) 2016-2023 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
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||

|
||||
|
||||
[](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Continuous+integration%22)
|
||||
[](https://github.com/shlinkio/shlink/actions/workflows/ci.yml?query=workflow%3A%22Continuous+integration%22)
|
||||
[](https://app.codecov.io/gh/shlinkio/shlink)
|
||||
[](https://dashboard.stryker-mutator.io/reports/github.com/shlinkio/shlink/develop)
|
||||
[](https://packagist.org/packages/shlinkio/shlink)
|
||||
[](https://hub.docker.com/r/shlinkio/shlink/)
|
||||
[](https://github.com/shlinkio/shlink/blob/main/LICENSE)
|
||||
[](https://twitter.com/shlinkio)
|
||||
[](https://twitter.com/shlinkio)
|
||||
[](https://fosstodon.org/@shlinkio)
|
||||
[](https://slnk.to/donate)
|
||||
|
||||
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own domain.
|
||||
@@ -35,7 +36,7 @@ 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 8.1
|
||||
* PHP 8.1 or 8.2
|
||||
* The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath.
|
||||
* apcu extension is recommended if you don't plan to use openswoole.
|
||||
* xml extension is required if you want to generate QR codes in svg format.
|
||||
|
||||
@@ -20,62 +20,61 @@
|
||||
"akrabat/ip-address-middleware": "^2.1",
|
||||
"cakephp/chronos": "^2.3",
|
||||
"doctrine/migrations": "^3.5",
|
||||
"doctrine/orm": "^2.12",
|
||||
"endroid/qr-code": "^4.4",
|
||||
"geoip2/geoip2": "^2.12",
|
||||
"guzzlehttp/guzzle": "^7.4",
|
||||
"doctrine/orm": "^2.14",
|
||||
"endroid/qr-code": "^4.7",
|
||||
"geoip2/geoip2": "^2.13",
|
||||
"guzzlehttp/guzzle": "^7.5",
|
||||
"happyr/doctrine-specification": "^2.0",
|
||||
"jaybizzle/crawler-detect": "^1.2.110",
|
||||
"laminas/laminas-config": "^3.7",
|
||||
"laminas/laminas-config-aggregator": "^1.8",
|
||||
"laminas/laminas-diactoros": "^2.14",
|
||||
"laminas/laminas-inputfilter": "^2.19",
|
||||
"laminas/laminas-servicemanager": "^3.16",
|
||||
"laminas/laminas-stdlib": "^3.11",
|
||||
"lcobucci/jwt": "^4.1",
|
||||
"league/uri": "^6.7",
|
||||
"jaybizzle/crawler-detect": "^1.2.112",
|
||||
"laminas/laminas-config": "^3.8",
|
||||
"laminas/laminas-config-aggregator": "^1.13",
|
||||
"laminas/laminas-diactoros": "^2.24",
|
||||
"laminas/laminas-inputfilter": "^2.24",
|
||||
"laminas/laminas-servicemanager": "^3.20",
|
||||
"laminas/laminas-stdlib": "^3.16",
|
||||
"league/uri": "^6.8",
|
||||
"lstrojny/functional-php": "^1.17",
|
||||
"mezzio/mezzio": "^3.11",
|
||||
"mezzio/mezzio-fastroute": "^3.5",
|
||||
"mezzio/mezzio-problem-details": "^1.6",
|
||||
"mezzio/mezzio-swoole": "^4.3",
|
||||
"mezzio/mezzio": "^3.15",
|
||||
"mezzio/mezzio-fastroute": "^3.8",
|
||||
"mezzio/mezzio-problem-details": "^1.11",
|
||||
"mezzio/mezzio-swoole": "^4.6",
|
||||
"mlocati/ip-lib": "^1.18",
|
||||
"mobiledetect/mobiledetectlib": "^3.74",
|
||||
"ocramius/proxy-manager": "^2.14",
|
||||
"pagerfanta/core": "^3.6",
|
||||
"pagerfanta/core": "^3.7",
|
||||
"php-middleware/request-id": "^4.1",
|
||||
"pugx/shortid-php": "^1.0",
|
||||
"ramsey/uuid": "^4.3",
|
||||
"shlinkio/shlink-common": "^5.1",
|
||||
"shlinkio/shlink-config": "^2.1",
|
||||
"pugx/shortid-php": "^1.1",
|
||||
"ramsey/uuid": "^4.7",
|
||||
"shlinkio/shlink-common": "^5.4",
|
||||
"shlinkio/shlink-config": "^2.4",
|
||||
"shlinkio/shlink-event-dispatcher": "^2.6",
|
||||
"shlinkio/shlink-importer": "^4.0",
|
||||
"shlinkio/shlink-installer": "^8.2",
|
||||
"shlinkio/shlink-ip-geolocation": "^3.1",
|
||||
"spiral/roadrunner": "^2.11",
|
||||
"spiral/roadrunner-jobs": "^2.3",
|
||||
"symfony/console": "^6.1",
|
||||
"symfony/filesystem": "^6.1",
|
||||
"symfony/lock": "^6.1",
|
||||
"symfony/process": "^6.1",
|
||||
"symfony/string": "^6.1"
|
||||
"shlinkio/shlink-importer": "^5.0",
|
||||
"shlinkio/shlink-installer": "^8.3",
|
||||
"shlinkio/shlink-ip-geolocation": "^3.2",
|
||||
"spiral/roadrunner": "^2.12",
|
||||
"spiral/roadrunner-jobs": "^2.7",
|
||||
"symfony/console": "^6.2",
|
||||
"symfony/filesystem": "^6.2",
|
||||
"symfony/lock": "^6.2",
|
||||
"symfony/process": "^6.2",
|
||||
"symfony/string": "^6.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"cebe/php-openapi": "^1.7",
|
||||
"devster/ubench": "^2.1",
|
||||
"dms/phpunit-arraysubset-asserts": "^0.4.0",
|
||||
"infection/infection": "^0.26.15",
|
||||
"openswoole/ide-helper": "~4.11.1",
|
||||
"phpspec/prophecy-phpunit": "^2.0",
|
||||
"phpstan/phpstan": "^1.8",
|
||||
"infection/infection": "^0.26.19",
|
||||
"openswoole/ide-helper": "~4.11.5",
|
||||
"phpstan/phpstan": "^1.9",
|
||||
"phpstan/phpstan-doctrine": "^1.3",
|
||||
"phpstan/phpstan-phpunit": "^1.3",
|
||||
"phpstan/phpstan-symfony": "^1.2",
|
||||
"phpunit/php-code-coverage": "^9.2",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"phpunit/php-code-coverage": "^10.0",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"shlinkio/php-coding-standard": "~2.3.0",
|
||||
"shlinkio/shlink-test-utils": "^3.3",
|
||||
"symfony/var-dumper": "^6.1",
|
||||
"veewee/composer-run-parallel": "^1.1"
|
||||
"shlinkio/shlink-test-utils": "^3.5",
|
||||
"symfony/var-dumper": "^6.2",
|
||||
"veewee/composer-run-parallel": "^1.2"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -96,7 +95,8 @@
|
||||
"ShlinkioApiTest\\Shlink\\Rest\\": "module/Rest/test-api",
|
||||
"ShlinkioDbTest\\Shlink\\Rest\\": "module/Rest/test-db",
|
||||
"ShlinkioTest\\Shlink\\Core\\": "module/Core/test",
|
||||
"ShlinkioDbTest\\Shlink\\Core\\": "module/Core/test-db"
|
||||
"ShlinkioDbTest\\Shlink\\Core\\": "module/Core/test-db",
|
||||
"ShlinkioApiTest\\Shlink\\Core\\": "module/Core/test-api"
|
||||
},
|
||||
"files": [
|
||||
"config/test/constants.php"
|
||||
@@ -109,7 +109,7 @@
|
||||
],
|
||||
"cs": "phpcs",
|
||||
"cs:fix": "phpcbf",
|
||||
"stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/config config docker/config data/migrations --level=8",
|
||||
"stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/test* module/*/config config docker/config data/migrations --level=8",
|
||||
"test": [
|
||||
"@parallel test:unit test:db",
|
||||
"@parallel test:api test:cli"
|
||||
@@ -131,11 +131,11 @@
|
||||
"test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml --log-junit=build/coverage-cli/junit.xml",
|
||||
"test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli",
|
||||
"test:cli:pretty": "GENERATE_COVERAGE=pretty composer test:cli",
|
||||
"infect:ci:base": "infection --threads=max --only-covered --only-covering-test-cases --skip-initial-tests",
|
||||
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=84",
|
||||
"infect:ci:base": "infection --threads=max --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.json5",
|
||||
"infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=80 --configuration=infection-api.json5",
|
||||
"infect:ci:cli": "@infect:ci:base --coverage=build/coverage-cli --min-msi=80 --configuration=infection-cli.json5",
|
||||
"infect:ci:cli": "@infect:ci:base --coverage=build/coverage-cli --min-msi=90 --configuration=infection-cli.json5",
|
||||
"infect:ci": "@parallel infect:ci:unit infect:ci:db infect:ci:api infect:ci:cli",
|
||||
"infect:test": [
|
||||
"@parallel test:unit:ci test:db:sqlite:ci test:api:ci",
|
||||
|
||||
24
config/autoload/cache.global.php
Normal file
24
config/autoload/cache.global.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
|
||||
return (static function (): array {
|
||||
$redisServers = EnvVars::REDIS_SERVERS->loadFromEnv();
|
||||
$redis = ['pub_sub_enabled' => $redisServers !== null && EnvVars::REDIS_PUB_SUB_ENABLED->loadFromEnv(false)];
|
||||
$cacheRedisBlock = $redisServers === null ? [] : [
|
||||
'redis' => [
|
||||
'servers' => $redisServers,
|
||||
'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(),
|
||||
],
|
||||
];
|
||||
|
||||
return [
|
||||
'cache' => [
|
||||
'namespace' => 'Shlink',
|
||||
...$cacheRedisBlock,
|
||||
],
|
||||
'redis' => $redis,
|
||||
];
|
||||
})();
|
||||
@@ -42,6 +42,9 @@ return (static function (): array {
|
||||
'port' => EnvVars::DB_PORT->loadFromEnv($resolveDefaultPort()),
|
||||
'unix_socket' => $isMysqlCompatible ? EnvVars::DB_UNIX_SOCKET->loadFromEnv() : null,
|
||||
'charset' => $resolveCharset(),
|
||||
'driverOptions' => $driver !== 'mssql' ? [] : [
|
||||
'TrustServerCertificate' => 'true',
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -6,12 +6,40 @@ return [
|
||||
|
||||
'entity_manager' => [
|
||||
'connection' => [
|
||||
// MySQL
|
||||
'user' => 'root',
|
||||
'password' => 'root',
|
||||
'driver' => 'pdo_mysql',
|
||||
'host' => 'shlink_db_mysql',
|
||||
'dbname' => 'shlink',
|
||||
// 'dbname' => 'shlink_foo',
|
||||
'charset' => 'utf8mb4',
|
||||
|
||||
// MariaDB
|
||||
// 'user' => 'root',
|
||||
// 'password' => 'root',
|
||||
// 'driver' => 'pdo_mysql',
|
||||
// 'host' => 'shlink_db_maria',
|
||||
// 'dbname' => 'shlink_foo',
|
||||
// 'charset' => 'utf8mb4',
|
||||
|
||||
// Postgres
|
||||
// 'user' => 'postgres',
|
||||
// 'password' => 'root',
|
||||
// 'driver' => 'pdo_pgsql',
|
||||
// 'host' => 'shlink_db_postgres',
|
||||
// 'dbname' => 'shlink_foo',
|
||||
// 'charset' => 'utf8',
|
||||
|
||||
// MSSQL
|
||||
// 'user' => 'sa',
|
||||
// 'password' => 'Passw0rd!',
|
||||
// 'driver' => 'pdo_sqlsrv',
|
||||
// 'host' => 'shlink_db_ms',
|
||||
// 'dbname' => 'shlink_foo',
|
||||
// 'driverOptions' => [
|
||||
// 'TrustServerCertificate' => 'true',
|
||||
// ],
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ return [
|
||||
Option\UrlShortener\AppendExtraPathConfigOption::class,
|
||||
Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class,
|
||||
Option\UrlShortener\EnableTrailingSlashConfigOption::class,
|
||||
Option\UrlShortener\ShortUrlModeConfigOption::class,
|
||||
Option\Tracking\IpAnonymizationConfigOption::class,
|
||||
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
|
||||
Option\Tracking\DisableTrackParamConfigOption::class,
|
||||
|
||||
@@ -5,14 +5,16 @@ declare(strict_types=1);
|
||||
use Monolog\Level;
|
||||
use Shlinkio\Shlink\Common\Logger\LoggerType;
|
||||
|
||||
$isSwoole = extension_loaded('openswoole');
|
||||
use function Shlinkio\Shlink\Config\runningInOpenswoole;
|
||||
|
||||
$logToStream = runningInOpenswoole();
|
||||
|
||||
return [
|
||||
|
||||
'logger' => [
|
||||
'Shlink' => [
|
||||
// For swoole, send logs as stream
|
||||
'type' => $isSwoole ? LoggerType::STREAM->value : LoggerType::FILE->value,
|
||||
// For openswoole, send logs as stream
|
||||
'type' => $logToStream ? LoggerType::STREAM->value : LoggerType::FILE->value,
|
||||
'level' => Level::Debug->value,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -16,7 +16,7 @@ return [
|
||||
],
|
||||
|
||||
'redirects' => [
|
||||
'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE->loadFromEnv(DEFAULT_REDIRECT_STATUS_CODE),
|
||||
'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE->loadFromEnv(DEFAULT_REDIRECT_STATUS_CODE->value),
|
||||
'redirect_cache_lifetime' => (int) EnvVars::REDIRECT_CACHE_LIFETIME->loadFromEnv(
|
||||
DEFAULT_REDIRECT_CACHE_LIFETIME,
|
||||
),
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
|
||||
return (static function (): array {
|
||||
$redisServers = EnvVars::REDIS_SERVERS->loadFromEnv();
|
||||
$pubSub = [
|
||||
'redis' => [
|
||||
'pub_sub_enabled' => $redisServers !== null && EnvVars::REDIS_PUB_SUB_ENABLED->loadFromEnv(false),
|
||||
],
|
||||
];
|
||||
|
||||
return match ($redisServers) {
|
||||
null => $pubSub,
|
||||
default => [
|
||||
'cache' => [
|
||||
'redis' => [
|
||||
'servers' => $redisServers,
|
||||
'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(),
|
||||
],
|
||||
],
|
||||
...$pubSub,
|
||||
],
|
||||
};
|
||||
})();
|
||||
@@ -7,6 +7,8 @@ return [
|
||||
'cache' => [
|
||||
'redis' => [
|
||||
'servers' => 'tcp://shlink_redis:6379',
|
||||
// 'servers' => 'tcp://barbar@shlink_redis_acl:6379',
|
||||
// 'servers' => 'tcp://foo:bar@shlink_redis_acl:6379',
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
|
||||
use function Shlinkio\Shlink\Config\getOpenswooleConfigFromEnv;
|
||||
|
||||
use const Shlinkio\Shlink\MIN_TASK_WORKERS;
|
||||
|
||||
return (static function (): array {
|
||||
@@ -21,6 +23,7 @@ return (static function (): array {
|
||||
'process-name' => 'shlink',
|
||||
|
||||
'options' => [
|
||||
...getOpenswooleConfigFromEnv(),
|
||||
'worker_num' => (int) EnvVars::WEB_WORKER_NUM->loadFromEnv(16),
|
||||
'task_worker_num' => max($taskWorkers, MIN_TASK_WORKERS),
|
||||
],
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
||||
|
||||
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
|
||||
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
|
||||
@@ -12,6 +13,8 @@ return (static function (): array {
|
||||
(int) EnvVars::DEFAULT_SHORT_CODES_LENGTH->loadFromEnv(DEFAULT_SHORT_CODES_LENGTH),
|
||||
MIN_SHORT_CODES_LENGTH,
|
||||
);
|
||||
$modeFromEnv = EnvVars::SHORT_URL_MODE->loadFromEnv(ShortUrlMode::STRICT->value);
|
||||
$mode = ShortUrlMode::tryDeprecated($modeFromEnv) ?? ShortUrlMode::STRICT;
|
||||
|
||||
return [
|
||||
|
||||
@@ -25,6 +28,7 @@ return (static function (): array {
|
||||
'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(false),
|
||||
'multi_segment_slugs_enabled' => (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false),
|
||||
'trailing_slash_enabled' => (bool) EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false),
|
||||
'mode' => $mode,
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -15,6 +15,7 @@ use function class_exists;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
use function Shlinkio\Shlink\Config\openswooleIsInstalled;
|
||||
use function Shlinkio\Shlink\Config\runningInRoadRunner;
|
||||
use function Shlinkio\Shlink\Core\enumValues;
|
||||
|
||||
use const PHP_SAPI;
|
||||
|
||||
@@ -23,7 +24,7 @@ $enableSwoole = PHP_SAPI === 'cli' && openswooleIsInstalled() && ! runningInRoad
|
||||
|
||||
return (new ConfigAggregator\ConfigAggregator([
|
||||
! $isTestEnv
|
||||
? new EnvVarLoaderProvider('config/params/generated_config.php', Core\Config\EnvVars::values())
|
||||
? new EnvVarLoaderProvider('config/params/generated_config.php', enumValues(Core\Config\EnvVars::class))
|
||||
: new ConfigAggregator\ArrayProvider([]),
|
||||
Mezzio\ConfigProvider::class,
|
||||
Mezzio\Router\ConfigProvider::class,
|
||||
@@ -48,6 +49,7 @@ return (new ConfigAggregator\ConfigAggregator([
|
||||
// Routes have to be loaded last
|
||||
new ConfigAggregator\PhpFileProvider('config/autoload/routes.config.php'),
|
||||
], 'data/cache/app_config.php', [
|
||||
Core\Config\BasePathPrefixer::class,
|
||||
Core\Config\MultiSegmentSlugProcessor::class,
|
||||
Core\Config\PostProcessor\BasePathPrefixer::class,
|
||||
Core\Config\PostProcessor\MultiSegmentSlugProcessor::class,
|
||||
Core\Config\PostProcessor\ShortUrlMethodsProcessor::class,
|
||||
]))->getMergedConfig();
|
||||
|
||||
@@ -4,19 +4,19 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink;
|
||||
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Shlinkio\Shlink\Core\Util\RedirectStatus;
|
||||
|
||||
const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15;
|
||||
const DEFAULT_SHORT_CODES_LENGTH = 5;
|
||||
const MIN_SHORT_CODES_LENGTH = 4;
|
||||
const DEFAULT_REDIRECT_STATUS_CODE = StatusCodeInterface::STATUS_FOUND;
|
||||
const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; // Deprecated. Default to 307 for Shlink v4
|
||||
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
|
||||
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
|
||||
const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside a html title tag
|
||||
const LOOSE_URI_MATCHER = '/(.+)\:(.+)/i'; // Matches anything starting with a schema.
|
||||
const DEFAULT_QR_CODE_SIZE = 300;
|
||||
const DEFAULT_QR_CODE_MARGIN = 0;
|
||||
const DEFAULT_QR_CODE_FORMAT = 'png';
|
||||
const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
|
||||
const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true;
|
||||
const MIN_TASK_WORKERS = 4;
|
||||
const MIGRATIONS_TABLE = 'migrations';
|
||||
|
||||
@@ -23,10 +23,10 @@ if (file_exists($covFile)) {
|
||||
}
|
||||
|
||||
$testHelper->createTestDb(
|
||||
['bin/cli', 'db:create'],
|
||||
['bin/cli', 'db:migrate'],
|
||||
['bin/doctrine', 'orm:schema-tool:drop'],
|
||||
['bin/doctrine', 'dbal:run-sql'],
|
||||
createDbCommand: ['bin/cli', 'db:create'],
|
||||
migrateDbCommand: ['bin/cli', 'db:migrate'],
|
||||
dropSchemaCommand: ['bin/doctrine', 'orm:schema-tool:drop'],
|
||||
runSqlCommand: ['bin/doctrine', 'dbal:run-sql'],
|
||||
);
|
||||
CliTest\CliTestCase::setSeedFixturesCallback(
|
||||
static fn () => $testHelper->seedFixtures($em, $config['data_fixtures'] ?? []),
|
||||
|
||||
@@ -6,3 +6,10 @@ namespace ShlinkioTest\Shlink;
|
||||
|
||||
const API_TESTS_HOST = '127.0.0.1';
|
||||
const API_TESTS_PORT = 9999;
|
||||
|
||||
const ANDROID_USER_AGENT = 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
. 'Chrome/109.0.5414.86 Mobile Safari/537.36';
|
||||
const IOS_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 '
|
||||
. '(KHTML, like Gecko) FxiOS/109.0 Mobile/15E148 Safari/605.1.15';
|
||||
const DESKTOP_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like '
|
||||
. 'Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/109.0.1518.61';
|
||||
|
||||
@@ -84,7 +84,7 @@ $buildDbConnection = static function (): array {
|
||||
return match ($driver) {
|
||||
'sqlite' => [
|
||||
'driver' => 'pdo_sqlite',
|
||||
'path' => sys_get_temp_dir() . '/shlink-tests.db',
|
||||
'memory' => true,
|
||||
],
|
||||
'postgres' => [
|
||||
'driver' => 'pdo_pgsql',
|
||||
@@ -101,6 +101,9 @@ $buildDbConnection = static function (): array {
|
||||
'user' => 'sa',
|
||||
'password' => 'Passw0rd!',
|
||||
'dbname' => 'shlink_test',
|
||||
'driverOptions' => [
|
||||
'TrustServerCertificate' => 'true',
|
||||
],
|
||||
],
|
||||
default => [ // mysql and maria
|
||||
'driver' => 'pdo_mysql',
|
||||
@@ -128,7 +131,7 @@ return [
|
||||
'url_shortener' => [
|
||||
'domain' => [
|
||||
'schema' => 'http',
|
||||
'hostname' => 'doma.in',
|
||||
'hostname' => 's.test',
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
set -ex
|
||||
|
||||
curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add -
|
||||
curl https://packages.microsoft.com/config/ubuntu/20.04/prod.list > /etc/apt/sources.list.d/mssql-release.list
|
||||
curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list > /etc/apt/sources.list.d/mssql-release.list
|
||||
apt-get update
|
||||
ACCEPT_EULA=Y apt-get install msodbcsql17
|
||||
apt-get install unixodbc-dev
|
||||
ACCEPT_EULA=Y apt-get install msodbcsql18
|
||||
# apt-get install unixodbc-dev
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<VirtualHost *:80>
|
||||
ServerName doma.in
|
||||
ServerName s.test
|
||||
DocumentRoot "/path/to/shlink/public"
|
||||
|
||||
<Directory "/path/to/shlink/public">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
server {
|
||||
server_name doma.in;
|
||||
server_name s.test;
|
||||
listen 80;
|
||||
root /path/to/shlink/public;
|
||||
index index.php;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
FROM php:8.1.9-fpm-alpine3.16
|
||||
FROM php:8.2-fpm-alpine3.17
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.21
|
||||
ENV PDO_SQLSRV_VERSION 5.10.1
|
||||
ENV MS_ODBC_SQL_VERSION 17.5.2.2
|
||||
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
|
||||
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
|
||||
|
||||
RUN apk update
|
||||
|
||||
@@ -30,7 +31,9 @@ RUN docker-php-ext-install gd
|
||||
RUN apk add --no-cache postgresql-dev
|
||||
RUN docker-php-ext-install pdo_pgsql
|
||||
|
||||
RUN docker-php-ext-install sockets
|
||||
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
|
||||
docker-php-ext-install sockets && \
|
||||
apk del .phpize-deps
|
||||
RUN docker-php-ext-install bcmath
|
||||
|
||||
# Install APCu extension
|
||||
@@ -44,13 +47,13 @@ RUN mkdir -p /usr/src/php/ext/apcu \
|
||||
&& 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_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
RUN wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
|
||||
docker-php-ext-enable pdo_sqlsrv pcov && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk
|
||||
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk
|
||||
|
||||
# Install composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
|
||||
2
data/infra/redis/redis-acl.conf
Normal file
2
data/infra/redis/redis-acl.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
user foo allcommands allkeys on >bar
|
||||
requirepass barbar
|
||||
@@ -1,9 +1,10 @@
|
||||
FROM php:8.1.9-alpine3.16
|
||||
FROM php:8.2-alpine3.17
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.21
|
||||
ENV PDO_SQLSRV_VERSION 5.10.1
|
||||
ENV MS_ODBC_SQL_VERSION 17.5.2.2
|
||||
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
|
||||
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
|
||||
|
||||
RUN apk update
|
||||
|
||||
@@ -30,7 +31,9 @@ RUN docker-php-ext-install gd
|
||||
RUN apk add --no-cache postgresql-dev
|
||||
RUN docker-php-ext-install pdo_pgsql
|
||||
|
||||
RUN docker-php-ext-install sockets
|
||||
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
|
||||
docker-php-ext-install sockets && \
|
||||
apk del .phpize-deps
|
||||
RUN docker-php-ext-install bcmath
|
||||
|
||||
# Install APCu extension
|
||||
@@ -44,13 +47,13 @@ RUN mkdir -p /usr/src/php/ext/apcu \
|
||||
&& 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_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
RUN wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
|
||||
docker-php-ext-enable pdo_sqlsrv pcov && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk
|
||||
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk
|
||||
|
||||
# Install composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
FROM php:8.1.9-alpine3.16
|
||||
FROM php:8.2-alpine3.17
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.21
|
||||
ENV INOTIFY_VERSION 3.0.0
|
||||
ENV OPENSWOOLE_VERSION 4.11.1
|
||||
ENV OPENSWOOLE_VERSION 4.12.1
|
||||
ENV PDO_SQLSRV_VERSION 5.10.1
|
||||
ENV MS_ODBC_SQL_VERSION 17.5.2.2
|
||||
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
|
||||
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
|
||||
|
||||
RUN apk update
|
||||
|
||||
@@ -32,7 +33,9 @@ RUN docker-php-ext-install gd
|
||||
RUN apk add --no-cache postgresql-dev
|
||||
RUN docker-php-ext-install pdo_pgsql
|
||||
|
||||
RUN docker-php-ext-install sockets
|
||||
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
|
||||
docker-php-ext-install sockets && \
|
||||
apk del .phpize-deps
|
||||
RUN docker-php-ext-install bcmath
|
||||
|
||||
# Install APCu extension
|
||||
@@ -54,13 +57,13 @@ RUN mkdir -p /usr/src/php/ext/inotify \
|
||||
&& rm /tmp/inotify.tar.gz
|
||||
|
||||
# Install openswoole, pcov and mssql driver
|
||||
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 && \
|
||||
RUN wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install openswoole-${OPENSWOOLE_VERSION} pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
|
||||
docker-php-ext-enable openswoole pdo_sqlsrv pcov && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk
|
||||
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk
|
||||
|
||||
# Install composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
|
||||
53
data/migrations/Version20230103105343.php
Normal file
53
data/migrations/Version20230103105343.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Platforms\MySQLPlatform;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20230103105343 extends AbstractMigration
|
||||
{
|
||||
private const TABLE_NAME = 'device_long_urls';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->skipIf($schema->hasTable(self::TABLE_NAME));
|
||||
|
||||
$table = $schema->createTable(self::TABLE_NAME);
|
||||
$table->addColumn('id', Types::BIGINT, [
|
||||
'unsigned' => true,
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
]);
|
||||
$table->setPrimaryKey(['id']);
|
||||
|
||||
$table->addColumn('device_type', Types::STRING, ['length' => 255]);
|
||||
$table->addColumn('long_url', Types::STRING, ['length' => 2048]);
|
||||
$table->addColumn('short_url_id', Types::BIGINT, [
|
||||
'unsigned' => true,
|
||||
'notnull' => true,
|
||||
]);
|
||||
|
||||
$table->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [
|
||||
'onDelete' => 'CASCADE',
|
||||
'onUpdate' => 'RESTRICT',
|
||||
]);
|
||||
|
||||
$table->addUniqueIndex(['device_type', 'short_url_id'], 'UQ_device_type_per_short_url');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->skipIf(! $schema->hasTable(self::TABLE_NAME));
|
||||
$schema->dropTable(self::TABLE_NAME);
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
|
||||
}
|
||||
}
|
||||
50
data/migrations/Version20230130090946.php
Normal file
50
data/migrations/Version20230130090946.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Platforms\MySQLPlatform;
|
||||
use Doctrine\DBAL\Platforms\SQLServerPlatform;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20230130090946 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->skipIf(! $this->isMsSql(), 'This only sets MsSQL-specific database options');
|
||||
|
||||
$shortUrls = $schema->getTable('short_urls');
|
||||
$shortCode = $shortUrls->getColumn('short_code');
|
||||
// Drop the unique index before changing the collation, as the field is part of this index
|
||||
$shortUrls->dropIndex('unique_short_code_plus_domain');
|
||||
$shortCode->setPlatformOption('collation', 'Latin1_General_CS_AS');
|
||||
}
|
||||
|
||||
public function postUp(Schema $schema): void
|
||||
{
|
||||
if ($this->isMsSql()) {
|
||||
// The index needs to be re-created in postUp, but here, we can only use statements run against the
|
||||
// connection directly
|
||||
$this->connection->executeStatement(
|
||||
'CREATE INDEX unique_short_code_plus_domain ON short_urls (domain_id, short_code);',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// No down
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
|
||||
}
|
||||
|
||||
private function isMsSql(): bool
|
||||
{
|
||||
return $this->connection->getDatabasePlatform() instanceof SQLServerPlatform;
|
||||
}
|
||||
}
|
||||
27
data/migrations/Version20230211171904.php
Normal file
27
data/migrations/Version20230211171904.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Platforms\MySQLPlatform;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20230211171904 extends AbstractMigration
|
||||
{
|
||||
private const INDEX_NAME = 'IDX_visits_potential_bot';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$visits = $schema->getTable('visits');
|
||||
$this->skipIf($visits->hasIndex(self::INDEX_NAME));
|
||||
|
||||
$visits->addIndex(['short_url_id', 'potential_bot'], self::INDEX_NAME);
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
|
||||
}
|
||||
}
|
||||
28
data/migrations/Version20230303164233.php
Normal file
28
data/migrations/Version20230303164233.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Platforms\MySQLPlatform;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20230303164233 extends AbstractMigration
|
||||
{
|
||||
private const INDEX_NAME = 'visits_potential_bot_IDX';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$visits = $schema->getTable('visits');
|
||||
$this->skipIf($visits->hasIndex(self::INDEX_NAME));
|
||||
|
||||
$visits->dropIndex('IDX_visits_potential_bot'); // Old index
|
||||
$visits->addIndex(['potential_bot'], self::INDEX_NAME);
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ services:
|
||||
- shlink_db_maria
|
||||
- shlink_db_ms
|
||||
- shlink_redis
|
||||
- shlink_redis_acl
|
||||
- shlink_mercure
|
||||
- shlink_mercure_proxy
|
||||
- shlink_rabbitmq
|
||||
@@ -65,6 +66,7 @@ services:
|
||||
- shlink_db_maria
|
||||
- shlink_db_ms
|
||||
- shlink_redis
|
||||
- shlink_redis_acl
|
||||
- shlink_mercure
|
||||
- shlink_mercure_proxy
|
||||
- shlink_rabbitmq
|
||||
@@ -89,6 +91,7 @@ services:
|
||||
- shlink_db_maria
|
||||
- shlink_db_ms
|
||||
- shlink_redis
|
||||
- shlink_redis_acl
|
||||
- shlink_mercure
|
||||
- shlink_mercure_proxy
|
||||
- shlink_rabbitmq
|
||||
@@ -99,7 +102,7 @@ services:
|
||||
|
||||
shlink_db_mysql:
|
||||
container_name: shlink_db_mysql
|
||||
image: mysql:5.7
|
||||
image: mysql:8.0
|
||||
ports:
|
||||
- "3307:3306"
|
||||
volumes:
|
||||
@@ -146,10 +149,19 @@ services:
|
||||
|
||||
shlink_redis:
|
||||
container_name: shlink_redis
|
||||
image: redis:6.0-alpine
|
||||
image: redis:6.2-alpine
|
||||
ports:
|
||||
- "6380:6379"
|
||||
|
||||
shlink_redis_acl:
|
||||
container_name: shlink_redis_acl
|
||||
image: redis:6.2-alpine
|
||||
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
|
||||
ports:
|
||||
- "6382:6379"
|
||||
volumes:
|
||||
- ./data/infra/redis/redis-acl.conf:/usr/local/etc/redis/redis.conf
|
||||
|
||||
shlink_mercure_proxy:
|
||||
container_name: shlink_mercure_proxy
|
||||
image: nginx:1.19.6-alpine
|
||||
@@ -163,7 +175,7 @@ services:
|
||||
|
||||
shlink_mercure:
|
||||
container_name: shlink_mercure
|
||||
image: dunglas/mercure:v0.13
|
||||
image: dunglas/mercure:v0.14
|
||||
ports:
|
||||
- "3080:80"
|
||||
environment:
|
||||
|
||||
@@ -11,7 +11,7 @@ It exposes a shlink instance served with [openswoole](https://openswoole.com/),
|
||||
|
||||
The most basic way to run Shlink's docker image is by providing these mandatory env vars.
|
||||
|
||||
* `DEFAULT_DOMAIN`: The default short domain used for this shlink instance. For example **doma.in**.
|
||||
* `DEFAULT_DOMAIN`: The default short domain used for this shlink instance. For example **s.test**.
|
||||
* `IS_HTTPS_ENABLED`: Either **true** or **false**. Tells if Shlink is being served with HTTPs or not.
|
||||
* `GEOLITE_LICENSE_KEY`: Your GeoLite2 license key. [Learn more](https://shlink.io/documentation/geolite-license-key/) about this.
|
||||
|
||||
@@ -21,7 +21,7 @@ To run shlink on top of a local docker service, and using an internal SQLite dat
|
||||
docker run \
|
||||
--name shlink \
|
||||
-p 8080:8080 \
|
||||
-e DEFAULT_DOMAIN=doma.in \
|
||||
-e DEFAULT_DOMAIN=s.test \
|
||||
-e IS_HTTPS_ENABLED=true \
|
||||
-e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 \
|
||||
shlinkio/shlink:stable
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
log_errors_max_len=0
|
||||
zend.assertions=1
|
||||
assert.exception=1
|
||||
memory_limit=256M
|
||||
memory_limit=512M
|
||||
|
||||
77
docs/adr/2023-01-06-support-any-http-method-in-short-urls.md
Normal file
77
docs/adr/2023-01-06-support-any-http-method-in-short-urls.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Support any HTTP method in short URLs
|
||||
|
||||
* Status: Accepted
|
||||
* Date: 2023-01-06
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
There has been a report that Shlink behaves as if a short URL was not found when the request HTTP method is not `GET`.
|
||||
|
||||
They want it to accept other methods so that they can do things like POSTing stuff that then gets "redirected" to the original URL.
|
||||
|
||||
This presents two main problems:
|
||||
|
||||
* Changing this could be considered a breaking change, in case someone is relying on this behavior (Shlink to only redirect on `GET`).
|
||||
* Shlink currently supports two redirect statuses ([301](https://httpwg.org/specs/rfc9110.html#status.301) and [302](https://httpwg.org/specs/rfc9110.html#status.302)), which can be configured by the server admin.
|
||||
|
||||
For historical reasons, a client might switch from the original method to `GET` when any of these is returned, not resulting in the desired behavior anyway.
|
||||
|
||||
Instead, statuses [308](https://httpwg.org/specs/rfc9110.html#status.308) and [307](https://httpwg.org/specs/rfc9110.html#status.307) should be used.
|
||||
|
||||
## Considered options
|
||||
|
||||
There's actually two problems to solve here. Some combinations are implicitly required:
|
||||
|
||||
* **To support other HTTP methods in short URLs**
|
||||
* Start supporting all HTTP methods.
|
||||
* Introduce a feature flag to allow users decide if they want to support all methods or just `GET`.
|
||||
* **To support other redirects statuses (308 and 307)**
|
||||
* Switch to status 308 and 307 and stop using 301 and 302.
|
||||
* Allow users to configure which of the 4 status codes they want to use, insteadof just supporting 301 and 302.
|
||||
* Allow users to configure between two combinations: 301+308 and 302+307, using 301 or 302 for `GET` requests, and 308 or 307 for the rest.
|
||||
|
||||
> **Note**
|
||||
> I asked on social networks, and these were the results (not too many answers though):
|
||||
> * https://fosstodon.org/@shlinkio/109626773392324128
|
||||
> * https://twitter.com/shlinkio/status/1610347091741507585
|
||||
|
||||
## Decision outcome
|
||||
|
||||
Because of backwards compatibility, it feels like the bets option is allowing to configure between 301, 302, 308 and 307.
|
||||
|
||||
This has the benefit that we can keep existing behavior intact. Existing instances will continue working only on `GET`, with statuses 301 or 302.
|
||||
|
||||
Anyone who wants to opt-in, can switch to 308 or 307, and the short URLs will transparently work on other HTTP methods in that case.
|
||||
|
||||
The only drawback is that this difference in the behavior when 308 or 307 are configured needs to be documented, and explained in shlink-installer.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Start supporting all HTTP methods
|
||||
|
||||
* Good: Because the change in code is pretty simple.
|
||||
* Bad: Because it would be potentially a breaking change for anyone trusting current behavior for anything.
|
||||
|
||||
### Support HTTP methods via feature flag
|
||||
|
||||
* Good: because it would be safer for existing instances and opt-in for anyone interested in this change of behavior.
|
||||
* Bad: Because it requires more changes in code.
|
||||
* Bad: Because it requires a new config entry in the shlink-installer.
|
||||
|
||||
### Switch to statuses 308 and 307
|
||||
|
||||
* Good: Because we keep supporting just two status codes.
|
||||
* Bad: Because it requires applying mapping/transformation to convert old configurations.
|
||||
* Bad: Because it requires changes in shlink-installer.
|
||||
|
||||
### Allow users to configure between 301, 302, 308 and 307
|
||||
|
||||
* Good: Because it's fully backwards compatible with existing configs.
|
||||
* Good: Because it would implicitly allow enabling all HTTP methods if 308 or 307 are selected, and keep only `GET` for 301 and 302, without the need for a separated feature flag.
|
||||
* Bad: Because it requires dynamically supporting only `GET` or all methods, depending on the selected status.
|
||||
|
||||
### Allow users to configure between 301+308 or 302+307
|
||||
|
||||
* Good: Because it would allow a more explicit redirects config, where values are not 301 and 302, but something like "permanent" and "temporary".
|
||||
* Bad: Because it implicitly changes the behavior of existing instances, making them respond to redirects with a method other than `GET`, and with a status code other than the one they explicitly configured.
|
||||
* Bad: because existing `REDIRECT_STATUS_CODE` env var might not make sense anymore, requiring a new one and logic to map from one to another.
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome.
|
||||
|
||||
* [2023-01-06 Support any HTTP method in short URLs](2023-01-06-support-any-http-method-in-short-urls.md)
|
||||
* [2022-08-05 Support multi-segment custom slugs](2022-08-05-support-multi-segment-custom-slugs.md)
|
||||
* [2022-01-15 Update env vars behavior to have precedence over installer options](2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md)
|
||||
* [2021-08-05 Migrate to a new caching library](2021-08-05-migrate-to-a-new-caching-library.md)
|
||||
|
||||
@@ -111,12 +111,19 @@
|
||||
"type": "string",
|
||||
"description": "The original long URL."
|
||||
},
|
||||
"deviceLongUrls": {
|
||||
"$ref": "#/components/schemas/DeviceLongUrls"
|
||||
},
|
||||
"dateCreated": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The date in which the short URL was created in ISO format."
|
||||
},
|
||||
"visitsSummary": {
|
||||
"$ref": "#/components/schemas/VisitsSummary"
|
||||
},
|
||||
"visitsCount": {
|
||||
"deprecated": true,
|
||||
"type": "integer",
|
||||
"description": "The number of visits that this short URL has received."
|
||||
},
|
||||
@@ -146,10 +153,19 @@
|
||||
},
|
||||
"example": {
|
||||
"shortCode": "12C18",
|
||||
"shortUrl": "https://doma.in/12C18",
|
||||
"shortUrl": "https://s.test/12C18",
|
||||
"longUrl": "https://store.steampowered.com",
|
||||
"deviceLongUrls": {
|
||||
"android": "https://store.steampowered.com/android",
|
||||
"ios": "https://store.steampowered.com/ios",
|
||||
"desktop": null
|
||||
},
|
||||
"dateCreated": "2016-08-21T20:34:16+02:00",
|
||||
"visitsCount": 328,
|
||||
"visitsSummary": {
|
||||
"total": 328,
|
||||
"nonBots": 285,
|
||||
"bots": 43
|
||||
},
|
||||
"tags": [
|
||||
"games",
|
||||
"tech"
|
||||
@@ -189,6 +205,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"VisitsSummary": {
|
||||
"type": "object",
|
||||
"required": ["total", "nonBots", "bots"],
|
||||
"properties": {
|
||||
"total": {
|
||||
"description": "The total amount of visits",
|
||||
"type": "number"
|
||||
},
|
||||
"nonBots": {
|
||||
"description": "The amount of visits which were not identified as bots",
|
||||
"type": "number"
|
||||
},
|
||||
"bots": {
|
||||
"description": "The amount of visits that were identified as potential bots",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"DeviceLongUrls": {
|
||||
"type": "object",
|
||||
"required": ["android", "ios", "desktop"],
|
||||
"properties": {
|
||||
"android": {
|
||||
"description": "The long URL to redirect to when the short URL is visited from a device running Android",
|
||||
"type": "string"
|
||||
},
|
||||
"ios": {
|
||||
"description": "The long URL to redirect to when the short URL is visited from a device running iOS",
|
||||
"type": "string"
|
||||
},
|
||||
"desktop": {
|
||||
"description": "The long URL to redirect to when the short URL is visited from a desktop browser",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Visit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -266,7 +318,7 @@
|
||||
"timezone": "America/Los_Angeles"
|
||||
},
|
||||
"potentialBot": false,
|
||||
"visitedUrl": "https://doma.in",
|
||||
"visitedUrl": "https://s.test",
|
||||
"type": "base_url"
|
||||
}
|
||||
},
|
||||
|
||||
20
docs/swagger/definitions/DeviceLongUrls.json
Normal file
20
docs/swagger/definitions/DeviceLongUrls.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"android": {
|
||||
"description": "The long URL to redirect to when the short URL is visited from a device running Android",
|
||||
"type": "string",
|
||||
"nullable": false
|
||||
},
|
||||
"ios": {
|
||||
"description": "The long URL to redirect to when the short URL is visited from a device running iOS",
|
||||
"type": "string",
|
||||
"nullable": false
|
||||
},
|
||||
"desktop": {
|
||||
"description": "The long URL to redirect to when the short URL is visited from a desktop browser",
|
||||
"type": "string",
|
||||
"nullable": false
|
||||
}
|
||||
}
|
||||
}
|
||||
17
docs/swagger/definitions/DeviceLongUrlsEdit.json
Normal file
17
docs/swagger/definitions/DeviceLongUrlsEdit.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"type": "object",
|
||||
"allOf": [{
|
||||
"$ref": "./DeviceLongUrls.json"
|
||||
}],
|
||||
"properties": {
|
||||
"android": {
|
||||
"nullable": true
|
||||
},
|
||||
"ios": {
|
||||
"nullable": true
|
||||
},
|
||||
"desktop": {
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
7
docs/swagger/definitions/DeviceLongUrlsResp.json
Normal file
7
docs/swagger/definitions/DeviceLongUrlsResp.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["android", "ios", "desktop"],
|
||||
"allOf": [{
|
||||
"$ref": "./DeviceLongUrlsEdit.json"
|
||||
}]
|
||||
}
|
||||
@@ -4,8 +4,10 @@
|
||||
"shortCode",
|
||||
"shortUrl",
|
||||
"longUrl",
|
||||
"deviceLongUrls",
|
||||
"dateCreated",
|
||||
"visitsCount",
|
||||
"visitsSummary",
|
||||
"tags",
|
||||
"meta",
|
||||
"domain",
|
||||
@@ -26,14 +28,21 @@
|
||||
"type": "string",
|
||||
"description": "The original long URL."
|
||||
},
|
||||
"deviceLongUrls": {
|
||||
"$ref": "./DeviceLongUrlsResp.json"
|
||||
},
|
||||
"dateCreated": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The date in which the short URL was created in ISO format."
|
||||
},
|
||||
"visitsCount": {
|
||||
"deprecated": true,
|
||||
"type": "integer",
|
||||
"description": "The number of visits that this short URL has received."
|
||||
"description": "**[DEPRECATED]** Use `visitsSummary.total` instead."
|
||||
},
|
||||
"visitsSummary": {
|
||||
"$ref": "./VisitsSummary.json"
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"description": "The long URL this short URL will redirect to",
|
||||
"type": "string"
|
||||
},
|
||||
"deviceLongUrls": {
|
||||
"$ref": "./DeviceLongUrlsEdit.json"
|
||||
},
|
||||
"validSince": {
|
||||
"description": "The date (in ISO-8601 format) from which this short code will be valid",
|
||||
"type": "string",
|
||||
@@ -21,7 +24,8 @@
|
||||
"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",
|
||||
"deprecated": true,
|
||||
"description": "**[DEPRECATED]** Tells if the long URL should or should not be validated as a reachable URL. Defaults to `false`",
|
||||
"type": "boolean"
|
||||
},
|
||||
"tags": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["tag", "shortUrlsCount", "visitsSummary", "visitsCount"],
|
||||
"properties": {
|
||||
"tag": {
|
||||
"type": "string",
|
||||
@@ -9,9 +10,13 @@
|
||||
"type": "number",
|
||||
"description": "The amount of short URLs using this tag"
|
||||
},
|
||||
"userAgent": {
|
||||
"visitsSummary": {
|
||||
"$ref": "./VisitsSummary.json"
|
||||
},
|
||||
"visitsCount": {
|
||||
"deprecated": true,
|
||||
"type": "number",
|
||||
"description": "The combined amount of visits received by short URLs with this tag"
|
||||
"description": "**[DEPRECATED]** Use visitsSummary.total instead"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["visitsCount", "orphanVisitsCount"],
|
||||
"required": ["nonOrphanVisits", "orphanVisits", "visitsCount", "orphanVisitsCount"],
|
||||
"properties": {
|
||||
"nonOrphanVisits": {
|
||||
"$ref": "./VisitsSummary.json"
|
||||
},
|
||||
"orphanVisits": {
|
||||
"$ref": "./VisitsSummary.json"
|
||||
},
|
||||
"visitsCount": {
|
||||
"deprecated": true,
|
||||
"type": "number",
|
||||
"description": "The total amount of visits received on any short URL."
|
||||
"description": "**[DEPRECATED]** Use nonOrphanVisits.total instead"
|
||||
},
|
||||
"orphanVisitsCount": {
|
||||
"deprecated": true,
|
||||
"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)."
|
||||
"description": "**[DEPRECATED]** Use orphanVisits.total instead"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
docs/swagger/definitions/VisitsSummary.json
Normal file
18
docs/swagger/definitions/VisitsSummary.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["total", "nonBots", "bots"],
|
||||
"properties": {
|
||||
"total": {
|
||||
"description": "The total amount of visits.",
|
||||
"type": "integer"
|
||||
},
|
||||
"nonBots": {
|
||||
"description": "The amount of visits which were not identified as bots.",
|
||||
"type": "integer"
|
||||
},
|
||||
"bots": {
|
||||
"description": "The amount of visits that were identified as potential bots.",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,10 +73,12 @@
|
||||
"shortCode-DESC",
|
||||
"dateCreated-ASC",
|
||||
"dateCreated-DESC",
|
||||
"title-ASC",
|
||||
"title-DESC",
|
||||
"visits-ASC",
|
||||
"visits-DESC",
|
||||
"title-ASC",
|
||||
"title-DESC"
|
||||
"nonBotVisits-ASC",
|
||||
"nonBotVisits-DESC"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -97,6 +99,32 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "excludeMaxVisitsReached",
|
||||
"in": "query",
|
||||
"description": "If true, short URLs which already reached their maximum amount of visits will be excluded.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"true",
|
||||
"false"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "excludePastValidUntil",
|
||||
"in": "query",
|
||||
"description": "If true, short URLs which validUntil date is on the past will be excluded.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"true",
|
||||
"false"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
@@ -133,10 +161,19 @@
|
||||
"data": [
|
||||
{
|
||||
"shortCode": "12C18",
|
||||
"shortUrl": "https://doma.in/12C18",
|
||||
"shortUrl": "https://s.test/12C18",
|
||||
"longUrl": "https://store.steampowered.com",
|
||||
"deviceLongUrls": {
|
||||
"android": null,
|
||||
"ios": null,
|
||||
"desktop": null
|
||||
},
|
||||
"dateCreated": "2016-08-21T20:34:16+02:00",
|
||||
"visitsCount": 328,
|
||||
"visitsSummary": {
|
||||
"total": 328,
|
||||
"nonBots": 328,
|
||||
"bots": 0
|
||||
},
|
||||
"tags": [
|
||||
"games",
|
||||
"tech"
|
||||
@@ -152,10 +189,19 @@
|
||||
},
|
||||
{
|
||||
"shortCode": "12Kb3",
|
||||
"shortUrl": "https://doma.in/12Kb3",
|
||||
"shortUrl": "https://s.test/12Kb3",
|
||||
"longUrl": "https://shlink.io",
|
||||
"deviceLongUrls": {
|
||||
"android": null,
|
||||
"ios": "https://shlink.io/ios",
|
||||
"desktop": null
|
||||
},
|
||||
"dateCreated": "2016-05-01T20:34:16+02:00",
|
||||
"visitsCount": 1029,
|
||||
"visitsSummary": {
|
||||
"total": 1029,
|
||||
"nonBots": 900,
|
||||
"bots": 129
|
||||
},
|
||||
"tags": [
|
||||
"shlink"
|
||||
],
|
||||
@@ -172,8 +218,17 @@
|
||||
"shortCode": "123bA",
|
||||
"shortUrl": "https://example.com/123bA",
|
||||
"longUrl": "https://www.google.com",
|
||||
"deviceLongUrls": {
|
||||
"android": null,
|
||||
"ios": null,
|
||||
"desktop": null
|
||||
},
|
||||
"dateCreated": "2015-10-01T20:34:16+02:00",
|
||||
"visitsCount": 25,
|
||||
"visitsSummary": {
|
||||
"total": 25,
|
||||
"nonBots": 0,
|
||||
"bots": 25
|
||||
},
|
||||
"tags": [],
|
||||
"meta": {
|
||||
"validSince": "2017-01-21T00:00:00+02:00",
|
||||
@@ -241,6 +296,9 @@
|
||||
"type": "object",
|
||||
"required": ["longUrl"],
|
||||
"properties": {
|
||||
"deviceLongUrls": {
|
||||
"$ref": "../definitions/DeviceLongUrls.json"
|
||||
},
|
||||
"customSlug": {
|
||||
"description": "A unique custom slug to be used instead of the generated short code",
|
||||
"type": "string"
|
||||
@@ -256,10 +314,6 @@
|
||||
"shortCodeLength": {
|
||||
"description": "The length for generated short code. It has to be at least 4 and defaults to 5. It will be ignored when customSlug is provided",
|
||||
"type": "number"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,10 +332,19 @@
|
||||
},
|
||||
"example": {
|
||||
"shortCode": "12C18",
|
||||
"shortUrl": "https://doma.in/12C18",
|
||||
"shortUrl": "https://s.test/12C18",
|
||||
"longUrl": "https://store.steampowered.com",
|
||||
"deviceLongUrls": {
|
||||
"android": null,
|
||||
"ios": null,
|
||||
"desktop": null
|
||||
},
|
||||
"dateCreated": "2016-08-21T20:34:16+02:00",
|
||||
"visitsCount": 0,
|
||||
"visitsSummary": {
|
||||
"total": 0,
|
||||
"nonBots": 0,
|
||||
"bots": 0
|
||||
},
|
||||
"tags": [
|
||||
"games",
|
||||
"tech"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"get": {
|
||||
"operationId": "shortenUrl",
|
||||
"deprecated": true,
|
||||
"tags": [
|
||||
"Short URLs"
|
||||
],
|
||||
"summary": "Create a short URL",
|
||||
"description": "Creates a short URL in a single API call. Useful for third party integrations.",
|
||||
"description": "**[Deprecated]** Use [Create short URL](#/Short%20URLs/createShortUrl) instead",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
@@ -52,10 +53,19 @@
|
||||
},
|
||||
"example": {
|
||||
"longUrl": "https://github.com/shlinkio/shlink",
|
||||
"shortUrl": "https://doma.in/abc123",
|
||||
"deviceLongUrls": {
|
||||
"android": null,
|
||||
"ios": null,
|
||||
"desktop": null
|
||||
},
|
||||
"shortUrl": "https://s.test/abc123",
|
||||
"shortCode": "abc123",
|
||||
"dateCreated": "2016-08-21T20:34:16+02:00",
|
||||
"visitsCount": 0,
|
||||
"visitsSummary": {
|
||||
"total": 0,
|
||||
"nonBots": 0,
|
||||
"bots": 0
|
||||
},
|
||||
"tags": [
|
||||
"games",
|
||||
"tech"
|
||||
@@ -74,7 +84,7 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "https://doma.in/abc123"
|
||||
"example": "https://s.test/abc123"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -38,10 +38,19 @@
|
||||
},
|
||||
"example": {
|
||||
"shortCode": "12Kb3",
|
||||
"shortUrl": "https://doma.in/12Kb3",
|
||||
"shortUrl": "https://s.test/12Kb3",
|
||||
"longUrl": "https://shlink.io",
|
||||
"deviceLongUrls": {
|
||||
"android": null,
|
||||
"ios": null,
|
||||
"desktop": null
|
||||
},
|
||||
"dateCreated": "2016-05-01T20:34:16+02:00",
|
||||
"visitsCount": 1029,
|
||||
"visitsSummary": {
|
||||
"total": 1029,
|
||||
"nonBots": 820,
|
||||
"bots": 209
|
||||
},
|
||||
"tags": [
|
||||
"shlink"
|
||||
],
|
||||
@@ -156,10 +165,19 @@
|
||||
},
|
||||
"example": {
|
||||
"shortCode": "12Kb3",
|
||||
"shortUrl": "https://doma.in/12Kb3",
|
||||
"shortUrl": "https://s.test/12Kb3",
|
||||
"longUrl": "https://shlink.io",
|
||||
"deviceLongUrls": {
|
||||
"android": "https://shlink.io/android",
|
||||
"ios": null,
|
||||
"desktop": null
|
||||
},
|
||||
"dateCreated": "2016-05-01T20:34:16+02:00",
|
||||
"visitsCount": 1029,
|
||||
"visitsSummary": {
|
||||
"total": 1029,
|
||||
"nonBots": 900,
|
||||
"bots": 129
|
||||
},
|
||||
"tags": [
|
||||
"shlink"
|
||||
],
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
{
|
||||
"name": "orderBy",
|
||||
"in": "query",
|
||||
"description": "To determine how to order the results.<br /><br />**Important!** Ordering by `shortUrlsCount` or `visitsCount` has a [known performance issue](https://github.com/shlinkio/shlink/issues/1346) which makes loading a subset of the list take as much as loading the whole list.<br />If you plan to order by any of these fields, it's worth loading the whole list with no pagination.",
|
||||
"description": "To determine how to order the results.<br /><br />**Important!** Ordering by `shortUrlsCount`, `visits` or `nonBotVisits` has a [known performance issue](https://github.com/shlinkio/shlink/issues/1346) which makes loading a subset of the list take as much as loading the whole list.<br />If you plan to order by any of these fields, it's worth loading the whole list with no pagination.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
@@ -54,8 +54,10 @@
|
||||
"tag-DESC",
|
||||
"shortUrlsCount-ASC",
|
||||
"shortUrlsCount-DESC",
|
||||
"visitsCount-ASC",
|
||||
"visitsCount-DESC"
|
||||
"visits-ASC",
|
||||
"visits-DESC",
|
||||
"nonBotVisits-ASC",
|
||||
"nonBotVisits-DESC"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -73,7 +75,6 @@
|
||||
"required": ["data"],
|
||||
"properties": {
|
||||
"data": {
|
||||
"description": "The tag stats will be returned only if the withStats param was provided with value 'true'",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "../definitions/TagInfo.json"
|
||||
@@ -92,12 +93,20 @@
|
||||
{
|
||||
"tag": "games",
|
||||
"shortUrlsCount": 10,
|
||||
"visitsCount": 521
|
||||
"visitsSummary": {
|
||||
"total": 521,
|
||||
"nonBots": 521,
|
||||
"bots": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "shlink",
|
||||
"shortUrlsCount": 7,
|
||||
"visitsCount": 1087
|
||||
"visitsSummary": {
|
||||
"total": 1087,
|
||||
"nonBots": 1000,
|
||||
"bots": 87
|
||||
}
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
|
||||
@@ -31,8 +31,16 @@
|
||||
},
|
||||
"example": {
|
||||
"visits": {
|
||||
"visitsCount": 1569874,
|
||||
"orphanVisitsCount": 71345
|
||||
"nonOrphanVisits": {
|
||||
"total": 64994,
|
||||
"nonBots": 64986,
|
||||
"bots": 8
|
||||
},
|
||||
"orphanVisits": {
|
||||
"total": 37,
|
||||
"nonBots": 34,
|
||||
"bots": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"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",
|
||||
"visitedUrl": "https://s.test",
|
||||
"type": "base_url"
|
||||
},
|
||||
{
|
||||
@@ -112,7 +112,7 @@
|
||||
"timezone": "America/Los_Angeles"
|
||||
},
|
||||
"potentialBot": false,
|
||||
"visitedUrl": "https://doma.in/foo",
|
||||
"visitedUrl": "https://s.test/foo",
|
||||
"type": "invalid_short_url"
|
||||
},
|
||||
{
|
||||
@@ -121,7 +121,7 @@
|
||||
"userAgent": "some_web_crawler/1.4",
|
||||
"visitLocation": null,
|
||||
"potentialBot": true,
|
||||
"visitedUrl": "https://doma.in/foo/bar/baz",
|
||||
"visitedUrl": "https://s.test/foo/bar/baz",
|
||||
"type": "regular_404"
|
||||
}
|
||||
],
|
||||
|
||||
2
indocker
2
indocker
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Run docker containers if they are not up yet
|
||||
if ! [[ $(docker ps | grep shlink) ]]; then
|
||||
if ! [[ $(docker ps | grep shlink_swoole) ]]; then
|
||||
docker-compose up -d
|
||||
fi
|
||||
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use const Shlinkio\Shlink\MIGRATIONS_TABLE;
|
||||
|
||||
return [
|
||||
|
||||
'migrations_paths' => [
|
||||
'ShlinkMigrations' => 'data/migrations',
|
||||
],
|
||||
'table_storage' => [
|
||||
'table_name' => MIGRATIONS_TABLE,
|
||||
'table_name' => 'migrations',
|
||||
],
|
||||
'custom_template' => 'data/migrations_template.txt',
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use GeoIp2\Database\Reader;
|
||||
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
@@ -84,7 +83,7 @@ return [
|
||||
],
|
||||
Command\ShortUrl\ResolveUrlCommand::class => [ShortUrl\ShortUrlResolver::class],
|
||||
Command\ShortUrl\ListShortUrlsCommand::class => [
|
||||
ShortUrl\ShortUrlService::class,
|
||||
ShortUrl\ShortUrlListService::class,
|
||||
ShortUrl\Transformer\ShortUrlDataTransformer::class,
|
||||
],
|
||||
Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class],
|
||||
@@ -116,7 +115,7 @@ return [
|
||||
LockFactory::class,
|
||||
Util\ProcessRunner::class,
|
||||
PhpExecutableFinder::class,
|
||||
Connection::class,
|
||||
'em',
|
||||
NoDbNameConnectionFactory::SERVICE_NAME,
|
||||
],
|
||||
Command\Db\MigrateDatabaseCommand::class => [
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\CLI\ApiKey;
|
||||
use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
use function is_string;
|
||||
@@ -19,8 +20,8 @@ class RoleResolver implements RoleResolverInterface
|
||||
|
||||
public function determineRoles(InputInterface $input): array
|
||||
{
|
||||
$domainAuthority = $input->getOption(self::DOMAIN_ONLY_PARAM);
|
||||
$author = $input->getOption(self::AUTHOR_ONLY_PARAM);
|
||||
$domainAuthority = $input->getOption(Role::DOMAIN_SPECIFIC->paramName());
|
||||
$author = $input->getOption(Role::AUTHORED_SHORT_URLS->paramName());
|
||||
|
||||
$roleDefinitions = [];
|
||||
if ($author) {
|
||||
|
||||
@@ -9,9 +9,6 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
interface RoleResolverInterface
|
||||
{
|
||||
public const AUTHOR_ONLY_PARAM = 'author-only';
|
||||
public const DOMAIN_ONLY_PARAM = 'domain-only';
|
||||
|
||||
/**
|
||||
* @return RoleDefinition[]
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,7 @@ use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
|
||||
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;
|
||||
@@ -32,8 +33,8 @@ class GenerateKeyCommand extends Command
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$authorOnly = RoleResolverInterface::AUTHOR_ONLY_PARAM;
|
||||
$domainOnly = RoleResolverInterface::DOMAIN_ONLY_PARAM;
|
||||
$authorOnly = Role::AUTHORED_SHORT_URLS->paramName();
|
||||
$domainOnly = Role::DOMAIN_SPECIFIC->paramName();
|
||||
$help = <<<HELP
|
||||
The <info>%command.name%</info> generates a new valid API key.
|
||||
|
||||
@@ -99,7 +100,7 @@ class GenerateKeyCommand extends Command
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->success(sprintf('Generated API key: "%s"', $apiKey->toString()));
|
||||
|
||||
if (! $apiKey->isAdmin()) {
|
||||
if (! ApiKey::isAdmin($apiKey)) {
|
||||
ShlinkTable::default($io)->render(
|
||||
['Role name', 'Role metadata'],
|
||||
$apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, 0)]),
|
||||
|
||||
@@ -59,11 +59,11 @@ class ListKeysCommand extends Command
|
||||
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
|
||||
}
|
||||
$rowData[] = $expiration?->toAtomString() ?? '-';
|
||||
$rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles(
|
||||
$rowData[] = ApiKey::isAdmin($apiKey) ? 'Admin' : implode("\n", $apiKey->mapRoles(
|
||||
fn (Role $role, array $meta) =>
|
||||
empty($meta)
|
||||
? Role::toFriendlyName($role)
|
||||
: sprintf('%s: %s', Role::toFriendlyName($role), Role::domainAuthorityFromMeta($meta)),
|
||||
? $role->toFriendlyName()
|
||||
: sprintf('%s: %s', $role->toFriendlyName(), Role::domainAuthorityFromMeta($meta)),
|
||||
));
|
||||
|
||||
return $rowData;
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace Shlinkio\Shlink\CLI\Command\Db;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Platforms\SqlitePlatform;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -15,12 +17,13 @@ use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
use function Functional\contains;
|
||||
use function Functional\filter;
|
||||
|
||||
use const Shlinkio\Shlink\MIGRATIONS_TABLE;
|
||||
use function Functional\map;
|
||||
use function Functional\some;
|
||||
|
||||
class CreateDatabaseCommand extends AbstractDatabaseCommand
|
||||
{
|
||||
private readonly Connection $regularConn;
|
||||
|
||||
public const NAME = 'db:create';
|
||||
public const DOCTRINE_SCRIPT = 'bin/doctrine';
|
||||
public const DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create';
|
||||
@@ -29,9 +32,10 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
|
||||
LockFactory $locker,
|
||||
ProcessRunnerInterface $processRunner,
|
||||
PhpExecutableFinder $phpFinder,
|
||||
private Connection $regularConn,
|
||||
private Connection $noDbNameConn,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Connection $noDbNameConn,
|
||||
) {
|
||||
$this->regularConn = $this->em->getConnection();
|
||||
parent::__construct($locker, $processRunner, $phpFinder);
|
||||
}
|
||||
|
||||
@@ -74,6 +78,8 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
|
||||
// Otherwise, it will fail to connect and will not be able to create the new database
|
||||
$schemaManager = $this->noDbNameConn->createSchemaManager();
|
||||
$databases = $schemaManager->listDatabases();
|
||||
// We cannot use getDatabase() to get the database name here, because then the driver will try to connect, and
|
||||
// it does not exist yet. We need to read from the raw params instead.
|
||||
$shlinkDatabase = $this->regularConn->getParams()['dbname'] ?? null;
|
||||
|
||||
if ($shlinkDatabase !== null && ! contains($databases, $shlinkDatabase)) {
|
||||
@@ -83,10 +89,14 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
|
||||
|
||||
private function schemaExists(): bool
|
||||
{
|
||||
// If at least one of the shlink tables exist, we will consider the database exists somehow.
|
||||
// We exclude the migrations table, in case db:migrate was run first by mistake.
|
||||
// Any other inconsistency will be taken care by the migrations.
|
||||
$schemaManager = $this->regularConn->createSchemaManager();
|
||||
return ! empty(filter($schemaManager->listTableNames(), fn (string $table) => $table !== MIGRATIONS_TABLE));
|
||||
$existingTables = $schemaManager->listTableNames();
|
||||
|
||||
$allMetadata = $this->em->getMetadataFactory()->getAllMetadata();
|
||||
$shlinkTables = map($allMetadata, static fn (ClassMetadata $metadata) => $metadata->getTableName());
|
||||
|
||||
// If at least one of the shlink tables exist, we will consider the database exists somehow.
|
||||
// Any other inconsistency will be taken care of by the migrations.
|
||||
return some($shlinkTables, static fn (string $shlinkTable) => contains($existingTables, $shlinkTable));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class GetDomainVisitsCommand extends AbstractVisitsListCommand
|
||||
parent::__construct($visitsHelper);
|
||||
}
|
||||
|
||||
protected function doConfigure(): void
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
@@ -103,7 +102,7 @@ class CreateShortUrlCommand extends Command
|
||||
'validate-url',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Forces the long URL to be validated, regardless what is globally configured.',
|
||||
'[DEPRECATED] Makes the URL to be validated as publicly accessible.',
|
||||
)
|
||||
->addOption(
|
||||
'crawlable',
|
||||
@@ -162,7 +161,7 @@ class CreateShortUrlCommand extends Command
|
||||
$doValidateUrl = $input->getOption('validate-url');
|
||||
|
||||
try {
|
||||
$shortUrl = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([
|
||||
$result = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([
|
||||
ShortUrlInputFilter::LONG_URL => $longUrl,
|
||||
ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'),
|
||||
ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'),
|
||||
@@ -175,12 +174,16 @@ class CreateShortUrlCommand extends Command
|
||||
ShortUrlInputFilter::TAGS => $tags,
|
||||
ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'),
|
||||
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
|
||||
EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $this->options->multiSegmentSlugsEnabled,
|
||||
]));
|
||||
], $this->options));
|
||||
|
||||
$result->onEventDispatchingError(static fn () => $io->isVerbose() && $io->warning(
|
||||
'Short URL properly created, but the real-time updates cannot be notified when generating the '
|
||||
. 'short URL from the command line. Migrate to roadrunner in order to bypass this limitation.',
|
||||
));
|
||||
|
||||
$io->writeln([
|
||||
sprintf('Processed long URL: <info>%s</info>', $longUrl),
|
||||
sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($shortUrl)),
|
||||
sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($result->shortUrl)),
|
||||
]);
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
} catch (InvalidUrlException | NonUniqueSlugException $e) {
|
||||
|
||||
@@ -20,7 +20,7 @@ class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
public const NAME = 'short-url:visits';
|
||||
|
||||
protected function doConfigure(): void
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
|
||||
@@ -4,7 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
|
||||
use Shlinkio\Shlink\CLI\Option\EndDateOption;
|
||||
use Shlinkio\Shlink\CLI\Option\StartDateOption;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
@@ -14,7 +15,8 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -27,20 +29,25 @@ use function Functional\map;
|
||||
use function implode;
|
||||
use function sprintf;
|
||||
|
||||
class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
class ListShortUrlsCommand extends Command
|
||||
{
|
||||
use PagerfantaUtilsTrait;
|
||||
|
||||
public const NAME = 'short-url:list';
|
||||
|
||||
private readonly StartDateOption $startDateOption;
|
||||
private readonly EndDateOption $endDateOption;
|
||||
|
||||
public function __construct(
|
||||
private ShortUrlServiceInterface $shortUrlService,
|
||||
private DataTransformerInterface $transformer,
|
||||
private readonly ShortUrlListServiceInterface $shortUrlService,
|
||||
private readonly DataTransformerInterface $transformer,
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->startDateOption = new StartDateOption($this, 'short URLs');
|
||||
$this->endDateOption = new EndDateOption($this, 'short URLs');
|
||||
}
|
||||
|
||||
protected function doConfigure(): void
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
@@ -70,6 +77,18 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
InputOption::VALUE_NONE,
|
||||
'If tags is provided, returns only short URLs having ALL tags.',
|
||||
)
|
||||
->addOption(
|
||||
'exclude-max-visits-reached',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Excludes short URLs which reached their max amount of visits.',
|
||||
)
|
||||
->addOption(
|
||||
'exclude-past-valid-until',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Excludes short URLs which have a "validUntil" date in the past.',
|
||||
)
|
||||
->addOption(
|
||||
'order-by',
|
||||
'o',
|
||||
@@ -104,16 +123,6 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
);
|
||||
}
|
||||
|
||||
protected function getStartDateDesc(string $optionName): string
|
||||
{
|
||||
return sprintf('Allows to filter short URLs, returning only those created after "%s".', $optionName);
|
||||
}
|
||||
|
||||
protected function getEndDateDesc(string $optionName): string
|
||||
{
|
||||
return sprintf('Allows to filter short URLs, returning only those created before "%s".', $optionName);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
@@ -124,8 +133,8 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
$tagsMode = $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
|
||||
$tags = ! empty($tags) ? explode(',', $tags) : [];
|
||||
$all = $input->getOption('all');
|
||||
$startDate = $this->getStartDateOption($input, $output);
|
||||
$endDate = $this->getEndDateOption($input, $output);
|
||||
$startDate = $this->startDateOption->get($input, $output);
|
||||
$endDate = $this->endDateOption->get($input, $output);
|
||||
$orderBy = $this->processOrderBy($input);
|
||||
$columnsMap = $this->resolveColumnsMap($input);
|
||||
|
||||
@@ -136,6 +145,8 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
ShortUrlsParamsInputFilter::ORDER_BY => $orderBy,
|
||||
ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(),
|
||||
ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(),
|
||||
ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED => $input->getOption('exclude-max-visits-reached'),
|
||||
ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL => $input->getOption('exclude-past-valid-until'),
|
||||
];
|
||||
|
||||
if ($all) {
|
||||
|
||||
@@ -25,7 +25,7 @@ class GetTagVisitsCommand extends AbstractVisitsListCommand
|
||||
parent::__construct($visitsHelper);
|
||||
}
|
||||
|
||||
protected function doConfigure(): void
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
|
||||
@@ -46,7 +46,7 @@ class ListTagsCommand extends Command
|
||||
|
||||
return map(
|
||||
$tags,
|
||||
static fn (TagInfo $tagInfo) => [$tagInfo->tag, $tagInfo->shortUrlsCount, $tagInfo->visitsCount],
|
||||
static fn (TagInfo $tagInfo) => [$tagInfo->tag, $tagInfo->shortUrlsCount, $tagInfo->visitsSummary->total],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Util;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Throwable;
|
||||
|
||||
use function is_string;
|
||||
use function sprintf;
|
||||
|
||||
abstract class AbstractWithDateRangeCommand extends Command
|
||||
{
|
||||
private const START_DATE = 'start-date';
|
||||
private const END_DATE = 'end-date';
|
||||
|
||||
final protected function configure(): void
|
||||
{
|
||||
$this->doConfigure();
|
||||
$this
|
||||
->addOption(self::START_DATE, 's', InputOption::VALUE_REQUIRED, $this->getStartDateDesc(self::START_DATE))
|
||||
->addOption(self::END_DATE, 'e', InputOption::VALUE_REQUIRED, $this->getEndDateDesc(self::END_DATE));
|
||||
}
|
||||
|
||||
protected function getStartDateOption(InputInterface $input, OutputInterface $output): ?Chronos
|
||||
{
|
||||
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 = $input->getOption($key);
|
||||
if (empty($value) || ! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Chronos::parse($value);
|
||||
} catch (Throwable $e) {
|
||||
$output->writeln(sprintf(
|
||||
'<comment>> Ignored provided "%s" since its value "%s" is not a valid date. <</comment>',
|
||||
$key,
|
||||
$value,
|
||||
));
|
||||
|
||||
if ($output->isVeryVerbose()) {
|
||||
$this->getApplication()?->renderThrowable($e, $output);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
abstract protected function doConfigure(): void;
|
||||
|
||||
abstract protected function getStartDateDesc(string $optionName): string;
|
||||
|
||||
abstract protected function getEndDateDesc(string $optionName): string;
|
||||
}
|
||||
@@ -4,13 +4,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
|
||||
use Shlinkio\Shlink\CLI\Option\EndDateOption;
|
||||
use Shlinkio\Shlink\CLI\Option\StartDateOption;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
@@ -19,29 +21,23 @@ use function Functional\map;
|
||||
use function Functional\select_keys;
|
||||
use function Shlinkio\Shlink\Common\buildDateRange;
|
||||
use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly;
|
||||
use function sprintf;
|
||||
|
||||
abstract class AbstractVisitsListCommand extends AbstractWithDateRangeCommand
|
||||
abstract class AbstractVisitsListCommand extends Command
|
||||
{
|
||||
private readonly StartDateOption $startDateOption;
|
||||
private readonly EndDateOption $endDateOption;
|
||||
|
||||
public function __construct(protected readonly VisitsStatsHelperInterface $visitsHelper)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
final protected function getStartDateDesc(string $optionName): string
|
||||
{
|
||||
return sprintf('Allows to filter visits, returning only those older than "%s".', $optionName);
|
||||
}
|
||||
|
||||
final protected function getEndDateDesc(string $optionName): string
|
||||
{
|
||||
return sprintf('Allows to filter visits, returning only those newer than "%s".', $optionName);
|
||||
$this->startDateOption = new StartDateOption($this, 'visits');
|
||||
$this->endDateOption = new EndDateOption($this, 'visits');
|
||||
}
|
||||
|
||||
final protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$startDate = $this->getStartDateOption($input, $output);
|
||||
$endDate = $this->getEndDateOption($input, $output);
|
||||
$startDate = $this->startDateOption->get($input, $output);
|
||||
$endDate = $this->endDateOption->get($input, $output);
|
||||
$paginator = $this->getVisitsPaginator($input, buildDateRange($startDate, $endDate));
|
||||
[$rows, $headers] = $this->resolveRowsAndHeaders($paginator);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
|
||||
parent::__construct($visitsHelper);
|
||||
}
|
||||
|
||||
protected function doConfigure(): void
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
|
||||
@@ -14,7 +14,7 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
public const NAME = 'visit:orphan';
|
||||
|
||||
protected function doConfigure(): void
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
|
||||
@@ -15,7 +15,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
|
||||
|
||||
private function __construct(string $message, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, 0, $previous);
|
||||
parent::__construct($message, previous: $previous);
|
||||
}
|
||||
|
||||
public static function withOlderDb(?Throwable $prev = null): self
|
||||
|
||||
51
module/CLI/src/Option/DateOption.php
Normal file
51
module/CLI/src/Option/DateOption.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Option;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Throwable;
|
||||
|
||||
use function is_string;
|
||||
use function sprintf;
|
||||
|
||||
class DateOption
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Command $command,
|
||||
private readonly string $name,
|
||||
string $shortcut,
|
||||
string $description,
|
||||
) {
|
||||
$command->addOption($name, $shortcut, InputOption::VALUE_REQUIRED, $description);
|
||||
}
|
||||
|
||||
public function get(InputInterface $input, OutputInterface $output): ?Chronos
|
||||
{
|
||||
$value = $input->getOption($this->name);
|
||||
if (empty($value) || ! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Chronos::parse($value);
|
||||
} catch (Throwable $e) {
|
||||
$output->writeln(sprintf(
|
||||
'<comment>> Ignored provided "%s" since its value "%s" is not a valid date. <</comment>',
|
||||
$this->name,
|
||||
$value,
|
||||
));
|
||||
|
||||
if ($output->isVeryVerbose()) {
|
||||
$this->command->getApplication()?->renderThrowable($e, $output);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
module/CLI/src/Option/EndDateOption.php
Normal file
30
module/CLI/src/Option/EndDateOption.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Option;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class EndDateOption
|
||||
{
|
||||
private readonly DateOption $dateOption;
|
||||
|
||||
public function __construct(Command $command, string $descriptionHint)
|
||||
{
|
||||
$this->dateOption = new DateOption($command, 'end-date', 'e', sprintf(
|
||||
'Allows to filter %s, returning only those newer than provided date.',
|
||||
$descriptionHint,
|
||||
));
|
||||
}
|
||||
|
||||
public function get(InputInterface $input, OutputInterface $output): ?Chronos
|
||||
{
|
||||
return $this->dateOption->get($input, $output);
|
||||
}
|
||||
}
|
||||
30
module/CLI/src/Option/StartDateOption.php
Normal file
30
module/CLI/src/Option/StartDateOption.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Option;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class StartDateOption
|
||||
{
|
||||
private readonly DateOption $dateOption;
|
||||
|
||||
public function __construct(Command $command, string $descriptionHint)
|
||||
{
|
||||
$this->dateOption = new DateOption($command, 'start-date', 's', sprintf(
|
||||
'Allows to filter %s, returning only those older than provided date.',
|
||||
$descriptionHint,
|
||||
));
|
||||
}
|
||||
|
||||
public function get(InputInterface $input, OutputInterface $output): ?Chronos
|
||||
{
|
||||
return $this->dateOption->get($input, $output);
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioCliTest\Shlink\CLI\Command;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
|
||||
|
||||
class GenerateApiKeyTest extends CliTestCase
|
||||
{
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function outputIsCorrect(): void
|
||||
{
|
||||
[$output, $exitCode] = $this->exec([GenerateKeyCommand::NAME]);
|
||||
|
||||
@@ -5,16 +5,15 @@ declare(strict_types=1);
|
||||
namespace ShlinkioCliTest\Shlink\CLI\Command;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
|
||||
|
||||
class ListApiKeysTest extends CliTestCase
|
||||
{
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideFlags
|
||||
*/
|
||||
#[Test, DataProvider('provideFlags')]
|
||||
public function generatesExpectedOutput(array $flags, string $expectedOutput): void
|
||||
{
|
||||
[$output, $exitCode] = $this->exec([ListKeysCommand::NAME, ...$flags]);
|
||||
@@ -23,7 +22,7 @@ class ListApiKeysTest extends CliTestCase
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $exitCode);
|
||||
}
|
||||
|
||||
public function provideFlags(): iterable
|
||||
public static function provideFlags(): iterable
|
||||
{
|
||||
$expiredApiKeyDate = Chronos::now()->subDay()->startOfDay()->toAtomString();
|
||||
$enabledOnlyOutput = <<<OUT
|
||||
|
||||
75
module/CLI/test-cli/Command/ListShortUrlsTest.php
Normal file
75
module/CLI/test-cli/Command/ListShortUrlsTest.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioCliTest\Shlink\CLI\Command;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
|
||||
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
|
||||
|
||||
class ListShortUrlsTest extends CliTestCase
|
||||
{
|
||||
#[Test, DataProvider('provideFlagsAndOutput')]
|
||||
public function generatesExpectedOutput(array $flags, string $expectedOutput): void
|
||||
{
|
||||
[$output] = $this->exec([ListShortUrlsCommand::NAME, ...$flags], ['no']);
|
||||
self::assertStringContainsString($expectedOutput, $output);
|
||||
}
|
||||
|
||||
public static function provideFlagsAndOutput(): iterable
|
||||
{
|
||||
// phpcs:disable Generic.Files.LineLength
|
||||
yield 'no flags' => [[], <<<OUTPUT
|
||||
+--------------------+---------------+-------------------------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
|
||||
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
|
||||
+--------------------+---------------+-------------------------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
|
||||
| ghi789 | | http://example.com/ghi789 | https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/ | 2019-01-01T00:00:30+00:00 | 0 |
|
||||
| custom | | http://s.test/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
|
||||
| def456 | | http://s.test/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
|
||||
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
|
||||
| abc123 | My cool title | http://s.test/abc123 | https://shlink.io | 2018-05-01T00:00:00+00:00 | 3 |
|
||||
| ghi789 | | http://s.test/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
|
||||
+--------------------+---------------+-------------------------------------------+---------------------------- Page 1 of 1 ------------------------------------------------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
yield 'start date' => [['--start-date=2019-01'], <<<OUTPUT
|
||||
+------------+-------+---------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
|
||||
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
|
||||
+------------+-------+---------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
|
||||
| ghi789 | | http://example.com/ghi789 | https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/ | 2019-01-01T00:00:30+00:00 | 0 |
|
||||
| custom | | http://s.test/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
|
||||
| def456 | | http://s.test/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
|
||||
+------------+-------+---------------------------+-------------------------------------------- Page 1 of 1 --------------------------------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
yield 'end date' => [['-e 2018-12-01'], <<<OUTPUT
|
||||
+--------------------+---------------+-------------------------------------------+----------------------------------+---------------------------+--------------+
|
||||
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
|
||||
+--------------------+---------------+-------------------------------------------+----------------------------------+---------------------------+--------------+
|
||||
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
|
||||
| abc123 | My cool title | http://s.test/abc123 | https://shlink.io | 2018-05-01T00:00:00+00:00 | 3 |
|
||||
| ghi789 | | http://s.test/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
|
||||
+--------------------+---------------+----------------------------------- Page 1 of 1 ------------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
yield 'start and end date' => [['-s 2018-06-20', '--end-date=2019-01-01T00:00:20+00:00'], <<<OUTPUT
|
||||
+--------------------+-------+-------------------------------------------+-----------------------------------------------------------------------------------------------------+---------------------------+--------------+
|
||||
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
|
||||
+--------------------+-------+-------------------------------------------+-----------------------------------------------------------------------------------------------------+---------------------------+--------------+
|
||||
| custom | | http://s.test/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
|
||||
| def456 | | http://s.test/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
|
||||
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
|
||||
+--------------------+-------+-------------------------------------------+----------------------------- Page 1 of 1 -----------------------------------------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
yield 'expired excluded' => [['--exclude-max-visits-reached', '--exclude-past-valid-until'], <<<OUTPUT
|
||||
+--------------------+-------+-------------------------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
|
||||
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
|
||||
+--------------------+-------+-------------------------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
|
||||
| ghi789 | | http://example.com/ghi789 | https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/ | 2019-01-01T00:00:30+00:00 | 0 |
|
||||
| custom | | http://s.test/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
|
||||
| def456 | | http://s.test/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
|
||||
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
|
||||
+--------------------+-------+-------------------------------------------+-------------------------------- Page 1 of 1 --------------------------------------------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
// phpcs:enable
|
||||
}
|
||||
}
|
||||
@@ -4,107 +4,119 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\ApiKey;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\ApiKey\RoleResolver;
|
||||
use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
use function Functional\map;
|
||||
|
||||
class RoleResolverTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private RoleResolver $resolver;
|
||||
private ObjectProphecy $domainService;
|
||||
private MockObject & DomainServiceInterface $domainService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->domainService = $this->prophesize(DomainServiceInterface::class);
|
||||
$this->resolver = new RoleResolver($this->domainService->reveal(), 'default.com');
|
||||
$this->domainService = $this->createMock(DomainServiceInterface::class);
|
||||
$this->resolver = new RoleResolver($this->domainService, 'default.com');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideRoles
|
||||
*/
|
||||
#[Test, DataProvider('provideRoles')]
|
||||
public function properRolesAreResolvedBasedOnInput(
|
||||
InputInterface $input,
|
||||
callable $createInput,
|
||||
array $expectedRoles,
|
||||
int $expectedDomainCalls,
|
||||
): void {
|
||||
$getDomain = $this->domainService->getOrCreate('example.com')->willReturn(
|
||||
Domain::withAuthority('example.com')->setId('1'),
|
||||
);
|
||||
$input = $createInput($this);
|
||||
$this->domainService->expects($this->exactly($expectedDomainCalls))->method('getOrCreate')->with(
|
||||
'example.com',
|
||||
)->willReturn(self::domainWithId(Domain::withAuthority('example.com')));
|
||||
|
||||
$result = $this->resolver->determineRoles($input);
|
||||
|
||||
self::assertEquals($expectedRoles, $result);
|
||||
$getDomain->shouldHaveBeenCalledTimes($expectedDomainCalls);
|
||||
}
|
||||
|
||||
public function provideRoles(): iterable
|
||||
public static function provideRoles(): iterable
|
||||
{
|
||||
$domain = Domain::withAuthority('example.com')->setId('1');
|
||||
$buildInput = function (array $definition): InputInterface {
|
||||
$input = $this->prophesize(InputInterface::class);
|
||||
$domain = self::domainWithId(Domain::withAuthority('example.com'));
|
||||
$buildInput = static fn (array $definition) => function (TestCase $test) use ($definition): InputInterface {
|
||||
$input = $test->createStub(InputInterface::class);
|
||||
$input->method('getOption')->willReturnMap(
|
||||
map($definition, static fn (mixed $returnValue, string $param) => [$param, $returnValue]),
|
||||
);
|
||||
|
||||
foreach ($definition as $name => $value) {
|
||||
$input->getOption($name)->willReturn($value);
|
||||
}
|
||||
|
||||
return $input->reveal();
|
||||
return $input;
|
||||
};
|
||||
|
||||
yield 'no roles' => [
|
||||
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => false]),
|
||||
$buildInput([Role::DOMAIN_SPECIFIC->paramName() => null, Role::AUTHORED_SHORT_URLS->paramName() => false]),
|
||||
[],
|
||||
0,
|
||||
];
|
||||
yield 'domain role only' => [
|
||||
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => false]),
|
||||
$buildInput(
|
||||
[Role::DOMAIN_SPECIFIC->paramName() => 'example.com', Role::AUTHORED_SHORT_URLS->paramName() => false],
|
||||
),
|
||||
[RoleDefinition::forDomain($domain)],
|
||||
1,
|
||||
];
|
||||
yield 'false domain role' => [
|
||||
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => false]),
|
||||
$buildInput([Role::DOMAIN_SPECIFIC->paramName() => false]),
|
||||
[],
|
||||
0,
|
||||
];
|
||||
yield 'true domain role' => [
|
||||
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => true]),
|
||||
$buildInput([Role::DOMAIN_SPECIFIC->paramName() => true]),
|
||||
[],
|
||||
0,
|
||||
];
|
||||
yield 'string array domain role' => [
|
||||
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => ['foo', 'bar']]),
|
||||
$buildInput([Role::DOMAIN_SPECIFIC->paramName() => ['foo', 'bar']]),
|
||||
[],
|
||||
0,
|
||||
];
|
||||
yield 'author role only' => [
|
||||
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => true]),
|
||||
$buildInput([Role::DOMAIN_SPECIFIC->paramName() => null, Role::AUTHORED_SHORT_URLS->paramName() => true]),
|
||||
[RoleDefinition::forAuthoredShortUrls()],
|
||||
0,
|
||||
];
|
||||
yield 'both roles' => [
|
||||
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => true]),
|
||||
$buildInput(
|
||||
[Role::DOMAIN_SPECIFIC->paramName() => 'example.com', Role::AUTHORED_SHORT_URLS->paramName() => true],
|
||||
),
|
||||
[RoleDefinition::forAuthoredShortUrls(), RoleDefinition::forDomain($domain)],
|
||||
1,
|
||||
];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function exceptionIsThrownWhenTryingToAddDomainOnlyLinkedToDefaultDomain(): void
|
||||
{
|
||||
$input = $this->prophesize(InputInterface::class);
|
||||
$input->getOption(RoleResolver::DOMAIN_ONLY_PARAM)->willReturn('default.com');
|
||||
$input->getOption(RoleResolver::AUTHOR_ONLY_PARAM)->willReturn(null);
|
||||
$input = $this->createStub(InputInterface::class);
|
||||
$input
|
||||
->method('getOption')
|
||||
->willReturnMap([
|
||||
[Role::DOMAIN_SPECIFIC->paramName(), 'default.com'],
|
||||
[Role::AUTHORED_SHORT_URLS->paramName(), null],
|
||||
]);
|
||||
|
||||
$this->expectException(InvalidRoleConfigException::class);
|
||||
|
||||
$this->resolver->determineRoles($input->reveal());
|
||||
$this->resolver->determineRoles($input);
|
||||
}
|
||||
|
||||
private static function domainWithId(Domain $domain): Domain
|
||||
{
|
||||
$domain->setId('1');
|
||||
return $domain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI;
|
||||
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputDefinition;
|
||||
@@ -14,21 +13,14 @@ use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
trait CliTestUtilsTrait
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
/**
|
||||
* @return ObjectProphecy|Command
|
||||
*/
|
||||
private function createCommandMock(string $name): ObjectProphecy
|
||||
private function createCommandMock(string $name): MockObject & Command
|
||||
{
|
||||
$command = $this->prophesize(Command::class);
|
||||
$command->getName()->willReturn($name);
|
||||
$command->getDefinition()->willReturn($name);
|
||||
$command->isEnabled()->willReturn(true);
|
||||
$command->getAliases()->willReturn([]);
|
||||
$command->getDefinition()->willReturn(new InputDefinition());
|
||||
$command->setApplication(Argument::type(Application::class))->willReturn(function (): void {
|
||||
});
|
||||
$command = $this->createMock(Command::class);
|
||||
$command->method('getName')->willReturn($name);
|
||||
$command->method('isEnabled')->willReturn(true);
|
||||
$command->method('getAliases')->willReturn([]);
|
||||
$command->method('getDefinition')->willReturn(new InputDefinition());
|
||||
$command->method('setApplication')->with(Assert::isInstanceOf(Application::class));
|
||||
|
||||
return $command;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Api;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand;
|
||||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
@@ -17,19 +18,19 @@ class DisableKeyCommandTest extends TestCase
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $apiKeyService;
|
||||
private MockObject & ApiKeyServiceInterface $apiKeyService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
|
||||
$this->commandTester = $this->testerForCommand(new DisableKeyCommand($this->apiKeyService->reveal()));
|
||||
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
|
||||
$this->commandTester = $this->testerForCommand(new DisableKeyCommand($this->apiKeyService));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function providedApiKeyIsDisabled(): void
|
||||
{
|
||||
$apiKey = 'abcd1234';
|
||||
$this->apiKeyService->disable($apiKey)->shouldBeCalledOnce();
|
||||
$this->apiKeyService->expects($this->once())->method('disable')->with($apiKey);
|
||||
|
||||
$this->commandTester->execute([
|
||||
'apiKey' => $apiKey,
|
||||
@@ -39,12 +40,14 @@ class DisableKeyCommandTest extends TestCase
|
||||
self::assertStringContainsString('API key "abcd1234" properly disabled', $output);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function errorIsReturnedIfServiceThrowsException(): void
|
||||
{
|
||||
$apiKey = 'abcd1234';
|
||||
$expectedMessage = 'API key "abcd1234" does not exist.';
|
||||
$disable = $this->apiKeyService->disable($apiKey)->willThrow(new InvalidArgumentException($expectedMessage));
|
||||
$this->apiKeyService->expects($this->once())->method('disable')->with($apiKey)->willThrowException(
|
||||
new InvalidArgumentException($expectedMessage),
|
||||
);
|
||||
|
||||
$this->commandTester->execute([
|
||||
'apiKey' => $apiKey,
|
||||
@@ -52,6 +55,5 @@ class DisableKeyCommandTest extends TestCase
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString($expectedMessage, $output);
|
||||
$disable->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Api;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
@@ -21,22 +21,25 @@ class GenerateKeyCommandTest extends TestCase
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $apiKeyService;
|
||||
private MockObject & ApiKeyServiceInterface $apiKeyService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
|
||||
$roleResolver = $this->prophesize(RoleResolverInterface::class);
|
||||
$roleResolver->determineRoles(Argument::type(InputInterface::class))->willReturn([]);
|
||||
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
|
||||
$roleResolver = $this->createMock(RoleResolverInterface::class);
|
||||
$roleResolver->method('determineRoles')->with($this->isInstanceOf(InputInterface::class))->willReturn([]);
|
||||
|
||||
$command = new GenerateKeyCommand($this->apiKeyService->reveal(), $roleResolver->reveal());
|
||||
$command = new GenerateKeyCommand($this->apiKeyService, $roleResolver);
|
||||
$this->commandTester = $this->testerForCommand($command);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function noExpirationDateIsDefinedIfNotProvided(): void
|
||||
{
|
||||
$this->apiKeyService->create(null, null)->shouldBeCalledOnce()->willReturn(ApiKey::create());
|
||||
$this->apiKeyService->expects($this->once())->method('create')->with(
|
||||
$this->isNull(),
|
||||
$this->isNull(),
|
||||
)->willReturn(ApiKey::create());
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
@@ -44,24 +47,26 @@ class GenerateKeyCommandTest extends TestCase
|
||||
self::assertStringContainsString('Generated API key: ', $output);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function expirationDateIsDefinedIfProvided(): void
|
||||
{
|
||||
$this->apiKeyService->create(Argument::type(Chronos::class), null)->shouldBeCalledOnce()->willReturn(
|
||||
ApiKey::create(),
|
||||
);
|
||||
$this->apiKeyService->expects($this->once())->method('create')->with(
|
||||
$this->isInstanceOf(Chronos::class),
|
||||
$this->isNull(),
|
||||
)->willReturn(ApiKey::create());
|
||||
|
||||
$this->commandTester->execute([
|
||||
'--expiration-date' => '2016-01-01',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function nameIsDefinedIfProvided(): void
|
||||
{
|
||||
$this->apiKeyService->create(null, Argument::type('string'))->shouldBeCalledOnce()->willReturn(
|
||||
ApiKey::create(),
|
||||
);
|
||||
$this->apiKeyService->expects($this->once())->method('create')->with(
|
||||
$this->isNull(),
|
||||
$this->isType('string'),
|
||||
)->willReturn(ApiKey::create());
|
||||
|
||||
$this->commandTester->execute([
|
||||
'--name' => 'Alice',
|
||||
|
||||
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Api;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
|
||||
@@ -21,30 +23,26 @@ class ListKeysCommandTest extends TestCase
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $apiKeyService;
|
||||
private MockObject & ApiKeyServiceInterface $apiKeyService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
|
||||
$this->commandTester = $this->testerForCommand(new ListKeysCommand($this->apiKeyService->reveal()));
|
||||
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
|
||||
$this->commandTester = $this->testerForCommand(new ListKeysCommand($this->apiKeyService));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideKeysAndOutputs
|
||||
*/
|
||||
#[Test, DataProvider('provideKeysAndOutputs')]
|
||||
public function returnsExpectedOutput(array $keys, bool $enabledOnly, string $expected): void
|
||||
{
|
||||
$listKeys = $this->apiKeyService->listKeys($enabledOnly)->willReturn($keys);
|
||||
$this->apiKeyService->expects($this->once())->method('listKeys')->with($enabledOnly)->willReturn($keys);
|
||||
|
||||
$this->commandTester->execute(['--enabled-only' => $enabledOnly]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals($expected, $output);
|
||||
$listKeys->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideKeysAndOutputs(): iterable
|
||||
public static function provideKeysAndOutputs(): iterable
|
||||
{
|
||||
$dateInThePast = Chronos::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00');
|
||||
|
||||
@@ -85,14 +83,14 @@ class ListKeysCommandTest extends TestCase
|
||||
yield 'with roles' => [
|
||||
[
|
||||
$apiKey1 = ApiKey::create(),
|
||||
$apiKey2 = $this->apiKeyWithRoles([RoleDefinition::forAuthoredShortUrls()]),
|
||||
$apiKey3 = $this->apiKeyWithRoles(
|
||||
[RoleDefinition::forDomain(Domain::withAuthority('example.com')->setId('1'))],
|
||||
$apiKey2 = self::apiKeyWithRoles([RoleDefinition::forAuthoredShortUrls()]),
|
||||
$apiKey3 = self::apiKeyWithRoles(
|
||||
[RoleDefinition::forDomain(self::domainWithId(Domain::withAuthority('example.com')))],
|
||||
),
|
||||
$apiKey4 = ApiKey::create(),
|
||||
$apiKey5 = $this->apiKeyWithRoles([
|
||||
$apiKey5 = self::apiKeyWithRoles([
|
||||
RoleDefinition::forAuthoredShortUrls(),
|
||||
RoleDefinition::forDomain(Domain::withAuthority('example.com')->setId('1')),
|
||||
RoleDefinition::forDomain(self::domainWithId(Domain::withAuthority('example.com'))),
|
||||
]),
|
||||
$apiKey6 = ApiKey::create(),
|
||||
],
|
||||
@@ -142,7 +140,7 @@ class ListKeysCommandTest extends TestCase
|
||||
];
|
||||
}
|
||||
|
||||
private function apiKeyWithRoles(array $roles): ApiKey
|
||||
private static function apiKeyWithRoles(array $roles): ApiKey
|
||||
{
|
||||
$apiKey = ApiKey::create();
|
||||
foreach ($roles as $role) {
|
||||
@@ -151,4 +149,10 @@ class ListKeysCommandTest extends TestCase
|
||||
|
||||
return $apiKey;
|
||||
}
|
||||
|
||||
private static function domainWithId(Domain $domain): Domain
|
||||
{
|
||||
$domain->setId('1');
|
||||
return $domain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,13 @@ use Doctrine\DBAL\Driver;
|
||||
use Doctrine\DBAL\Platforms\AbstractPlatform;
|
||||
use Doctrine\DBAL\Platforms\SqlitePlatform;
|
||||
use Doctrine\DBAL\Schema\AbstractSchemaManager;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
use Doctrine\Persistence\Mapping\ClassMetadataFactory;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
@@ -21,144 +25,129 @@ use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Lock\LockInterface;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
use const Shlinkio\Shlink\MIGRATIONS_TABLE;
|
||||
|
||||
class CreateDatabaseCommandTest extends TestCase
|
||||
{
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $processHelper;
|
||||
private ObjectProphecy $regularConn;
|
||||
private ObjectProphecy $schemaManager;
|
||||
private ObjectProphecy $driver;
|
||||
private MockObject & ProcessRunnerInterface $processHelper;
|
||||
private MockObject & Connection $regularConn;
|
||||
private MockObject & ClassMetadataFactory $metadataFactory;
|
||||
private MockObject & AbstractSchemaManager $schemaManager;
|
||||
private MockObject & Driver $driver;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$locker = $this->prophesize(LockFactory::class);
|
||||
$lock = $this->prophesize(LockInterface::class);
|
||||
$lock->acquire(Argument::any())->willReturn(true);
|
||||
$lock->release()->will(function (): void {
|
||||
});
|
||||
$locker->createLock(Argument::cetera())->willReturn($lock->reveal());
|
||||
$locker = $this->createMock(LockFactory::class);
|
||||
$lock = $this->createMock(LockInterface::class);
|
||||
$lock->method('acquire')->withAnyParameters()->willReturn(true);
|
||||
$locker->method('createLock')->withAnyParameters()->willReturn($lock);
|
||||
|
||||
$phpExecutableFinder = $this->prophesize(PhpExecutableFinder::class);
|
||||
$phpExecutableFinder->find(false)->willReturn('/usr/local/bin/php');
|
||||
$phpExecutableFinder = $this->createMock(PhpExecutableFinder::class);
|
||||
$phpExecutableFinder->method('find')->with($this->isFalse())->willReturn('/usr/local/bin/php');
|
||||
|
||||
$this->processHelper = $this->prophesize(ProcessRunnerInterface::class);
|
||||
$this->schemaManager = $this->prophesize(AbstractSchemaManager::class);
|
||||
$this->processHelper = $this->createMock(ProcessRunnerInterface::class);
|
||||
$this->schemaManager = $this->createMock(AbstractSchemaManager::class);
|
||||
|
||||
$this->regularConn = $this->prophesize(Connection::class);
|
||||
$this->regularConn->createSchemaManager()->willReturn($this->schemaManager->reveal());
|
||||
$this->driver = $this->prophesize(Driver::class);
|
||||
$this->regularConn->getDriver()->willReturn($this->driver->reveal());
|
||||
$this->driver->getDatabasePlatform()->willReturn($this->prophesize(AbstractPlatform::class)->reveal());
|
||||
$noDbNameConn = $this->prophesize(Connection::class);
|
||||
$noDbNameConn->createSchemaManager()->willReturn($this->schemaManager->reveal());
|
||||
$this->regularConn = $this->createMock(Connection::class);
|
||||
$this->regularConn->method('createSchemaManager')->willReturn($this->schemaManager);
|
||||
$this->driver = $this->createMock(Driver::class);
|
||||
$this->regularConn->method('getDriver')->willReturn($this->driver);
|
||||
|
||||
$command = new CreateDatabaseCommand(
|
||||
$locker->reveal(),
|
||||
$this->processHelper->reveal(),
|
||||
$phpExecutableFinder->reveal(),
|
||||
$this->regularConn->reveal(),
|
||||
$noDbNameConn->reveal(),
|
||||
);
|
||||
$this->metadataFactory = $this->createMock(ClassMetadataFactory::class);
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->method('getConnection')->willReturn($this->regularConn);
|
||||
$em->method('getMetadataFactory')->willReturn($this->metadataFactory);
|
||||
|
||||
$noDbNameConn = $this->createMock(Connection::class);
|
||||
$noDbNameConn->method('createSchemaManager')->withAnyParameters()->willReturn($this->schemaManager);
|
||||
|
||||
$command = new CreateDatabaseCommand($locker, $this->processHelper, $phpExecutableFinder, $em, $noDbNameConn);
|
||||
$this->commandTester = $this->testerForCommand($command);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function successMessageIsPrintedIfDatabaseAlreadyExists(): void
|
||||
{
|
||||
$shlinkDatabase = 'shlink_database';
|
||||
$getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]);
|
||||
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', $shlinkDatabase, 'bar']);
|
||||
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
|
||||
});
|
||||
$listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table']);
|
||||
$this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]);
|
||||
$metadataMock = $this->createMock(ClassMetadata::class);
|
||||
$metadataMock->expects($this->once())->method('getTableName')->willReturn('foo_table');
|
||||
$this->metadataFactory->method('getAllMetadata')->willReturn([$metadataMock]);
|
||||
$this->schemaManager->expects($this->once())->method('listDatabases')->willReturn(
|
||||
['foo', $shlinkDatabase, 'bar'],
|
||||
);
|
||||
$this->schemaManager->expects($this->never())->method('createDatabase');
|
||||
$this->schemaManager->expects($this->once())->method('listTableNames')->willReturn(['foo_table', 'bar_table']);
|
||||
$this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class));
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('Database already exists. Run "db:migrate" command', $output);
|
||||
$getDatabase->shouldHaveBeenCalledOnce();
|
||||
$listDatabases->shouldHaveBeenCalledOnce();
|
||||
$createDatabase->shouldNotHaveBeenCalled();
|
||||
$listTables->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function databaseIsCreatedIfItDoesNotExist(): void
|
||||
{
|
||||
$shlinkDatabase = 'shlink_database';
|
||||
$getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]);
|
||||
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', 'bar']);
|
||||
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
|
||||
});
|
||||
$listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table', MIGRATIONS_TABLE]);
|
||||
$this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]);
|
||||
$this->metadataFactory->method('getAllMetadata')->willReturn([]);
|
||||
$this->schemaManager->expects($this->once())->method('listDatabases')->willReturn(['foo', 'bar']);
|
||||
$this->schemaManager->expects($this->once())->method('createDatabase')->with($shlinkDatabase);
|
||||
$this->schemaManager->expects($this->once())->method('listTableNames')->willReturn(
|
||||
['foo_table', 'bar_table'],
|
||||
);
|
||||
$this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class));
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
|
||||
$getDatabase->shouldHaveBeenCalledOnce();
|
||||
$listDatabases->shouldHaveBeenCalledOnce();
|
||||
$createDatabase->shouldHaveBeenCalledOnce();
|
||||
$listTables->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideEmptyDatabase
|
||||
*/
|
||||
#[Test, DataProvider('provideEmptyDatabase')]
|
||||
public function tablesAreCreatedIfDatabaseIsEmpty(array $tables): void
|
||||
{
|
||||
$shlinkDatabase = 'shlink_database';
|
||||
$getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]);
|
||||
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', $shlinkDatabase, 'bar']);
|
||||
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
|
||||
});
|
||||
$listTables = $this->schemaManager->listTableNames()->willReturn($tables);
|
||||
$runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [
|
||||
$this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]);
|
||||
$metadata = $this->createMock(ClassMetadata::class);
|
||||
$metadata->method('getTableName')->willReturn('shlink_table');
|
||||
$this->metadataFactory->method('getAllMetadata')->willReturn([$metadata]);
|
||||
$this->schemaManager->expects($this->once())->method('listDatabases')->willReturn(
|
||||
['foo', $shlinkDatabase, 'bar'],
|
||||
);
|
||||
$this->schemaManager->expects($this->never())->method('createDatabase');
|
||||
$this->schemaManager->expects($this->once())->method('listTableNames')->willReturn($tables);
|
||||
$this->processHelper->expects($this->once())->method('run')->with($this->isInstanceOf(OutputInterface::class), [
|
||||
'/usr/local/bin/php',
|
||||
CreateDatabaseCommand::DOCTRINE_SCRIPT,
|
||||
CreateDatabaseCommand::DOCTRINE_CREATE_SCHEMA_COMMAND,
|
||||
'--no-interaction',
|
||||
]);
|
||||
$this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class));
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('Creating database tables...', $output);
|
||||
self::assertStringContainsString('Database properly created!', $output);
|
||||
$getDatabase->shouldHaveBeenCalledOnce();
|
||||
$listDatabases->shouldHaveBeenCalledOnce();
|
||||
$createDatabase->shouldNotHaveBeenCalled();
|
||||
$listTables->shouldHaveBeenCalledOnce();
|
||||
$runCommand->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideEmptyDatabase(): iterable
|
||||
public static function provideEmptyDatabase(): iterable
|
||||
{
|
||||
yield 'no tables' => [[]];
|
||||
yield 'migrations table' => [[MIGRATIONS_TABLE]];
|
||||
yield 'migrations table' => [['non_shlink_table']];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function databaseCheckIsSkippedForSqlite(): void
|
||||
{
|
||||
$this->driver->getDatabasePlatform()->willReturn($this->prophesize(SqlitePlatform::class)->reveal());
|
||||
$this->driver->method('getDatabasePlatform')->willReturn($this->createMock(SqlitePlatform::class));
|
||||
|
||||
$shlinkDatabase = 'shlink_database';
|
||||
$getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]);
|
||||
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', 'bar']);
|
||||
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
|
||||
});
|
||||
$listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table']);
|
||||
$this->regularConn->expects($this->never())->method('getParams');
|
||||
$this->metadataFactory->expects($this->once())->method('getAllMetadata')->willReturn([]);
|
||||
$this->schemaManager->expects($this->never())->method('listDatabases');
|
||||
$this->schemaManager->expects($this->never())->method('createDatabase');
|
||||
$this->schemaManager->expects($this->once())->method('listTableNames')->willReturn(['foo_table', 'bar_table']);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
|
||||
$getDatabase->shouldNotHaveBeenCalled();
|
||||
$listDatabases->shouldNotHaveBeenCalled();
|
||||
$createDatabase->shouldNotHaveBeenCalled();
|
||||
$listTables->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Db;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
@@ -21,34 +21,28 @@ class MigrateDatabaseCommandTest extends TestCase
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $processHelper;
|
||||
private MockObject & ProcessRunnerInterface $processHelper;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$locker = $this->prophesize(LockFactory::class);
|
||||
$lock = $this->prophesize(LockInterface::class);
|
||||
$lock->acquire(Argument::any())->willReturn(true);
|
||||
$lock->release()->will(function (): void {
|
||||
});
|
||||
$locker->createLock(Argument::cetera())->willReturn($lock->reveal());
|
||||
$locker = $this->createMock(LockFactory::class);
|
||||
$lock = $this->createMock(LockInterface::class);
|
||||
$lock->method('acquire')->withAnyParameters()->willReturn(true);
|
||||
$locker->method('createLock')->withAnyParameters()->willReturn($lock);
|
||||
|
||||
$phpExecutableFinder = $this->prophesize(PhpExecutableFinder::class);
|
||||
$phpExecutableFinder->find(false)->willReturn('/usr/local/bin/php');
|
||||
$phpExecutableFinder = $this->createMock(PhpExecutableFinder::class);
|
||||
$phpExecutableFinder->method('find')->with($this->isFalse())->willReturn('/usr/local/bin/php');
|
||||
|
||||
$this->processHelper = $this->prophesize(ProcessRunnerInterface::class);
|
||||
$this->processHelper = $this->createMock(ProcessRunnerInterface::class);
|
||||
|
||||
$command = new MigrateDatabaseCommand(
|
||||
$locker->reveal(),
|
||||
$this->processHelper->reveal(),
|
||||
$phpExecutableFinder->reveal(),
|
||||
);
|
||||
$command = new MigrateDatabaseCommand($locker, $this->processHelper, $phpExecutableFinder);
|
||||
$this->commandTester = $this->testerForCommand($command);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function migrationsCommandIsRunWithProperVerbosity(): void
|
||||
{
|
||||
$runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [
|
||||
$this->processHelper->expects($this->once())->method('run')->with($this->isInstanceOf(OutputInterface::class), [
|
||||
'/usr/local/bin/php',
|
||||
MigrateDatabaseCommand::DOCTRINE_MIGRATIONS_SCRIPT,
|
||||
MigrateDatabaseCommand::DOCTRINE_MIGRATE_COMMAND,
|
||||
@@ -60,6 +54,5 @@ class MigrateDatabaseCommandTest extends TestCase
|
||||
|
||||
self::assertStringContainsString('Migrating database...', $output);
|
||||
self::assertStringContainsString('Database properly migrated!', $output);
|
||||
$runCommand->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Domain;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Domain\DomainRedirectsCommand;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
@@ -22,26 +24,26 @@ class DomainRedirectsCommandTest extends TestCase
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $domainService;
|
||||
private MockObject & DomainServiceInterface $domainService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->domainService = $this->prophesize(DomainServiceInterface::class);
|
||||
$this->commandTester = $this->testerForCommand(new DomainRedirectsCommand($this->domainService->reveal()));
|
||||
$this->domainService = $this->createMock(DomainServiceInterface::class);
|
||||
$this->commandTester = $this->testerForCommand(new DomainRedirectsCommand($this->domainService));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideDomains
|
||||
*/
|
||||
#[Test, DataProvider('provideDomains')]
|
||||
public function onlyPlainQuestionsAreAskedForNewDomainsAndDomainsWithNoRedirects(?Domain $domain): void
|
||||
{
|
||||
$domainAuthority = 'my-domain.com';
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn(
|
||||
$domain,
|
||||
);
|
||||
$this->domainService->expects($this->once())->method('configureNotFoundRedirects')->with(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withRedirects('foo.com', null, 'baz.com'),
|
||||
)->willReturn(Domain::withAuthority(''));
|
||||
$this->domainService->expects($this->never())->method('listDomains');
|
||||
|
||||
$this->commandTester->setInputs(['foo.com', '', 'baz.com']);
|
||||
$this->commandTester->execute(['domain' => $domainAuthority]);
|
||||
@@ -55,29 +57,29 @@ class DomainRedirectsCommandTest extends TestCase
|
||||
);
|
||||
self::assertStringContainsString('URL to redirect to when a user hits an invalid short URL', $output);
|
||||
self::assertEquals(3, substr_count($output, '(Leave empty for no redirect)'));
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
$this->domainService->listDomains()->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
public function provideDomains(): iterable
|
||||
public static function provideDomains(): iterable
|
||||
{
|
||||
yield 'no domain' => [null];
|
||||
yield 'domain without redirects' => [Domain::withAuthority('')];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function offersNewOptionsForDomainsWithExistingRedirects(): void
|
||||
{
|
||||
$domainAuthority = 'example.com';
|
||||
$domain = Domain::withAuthority($domainAuthority);
|
||||
$domain->configureNotFoundRedirects(NotFoundRedirects::withRedirects('foo.com', 'bar.com', 'baz.com'));
|
||||
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn(
|
||||
$domain,
|
||||
);
|
||||
$this->domainService->expects($this->once())->method('configureNotFoundRedirects')->with(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withRedirects(null, 'edited.com', 'baz.com'),
|
||||
)->willReturn($domain);
|
||||
$this->domainService->expects($this->never())->method('listDomains');
|
||||
|
||||
$this->commandTester->setInputs(['2', '1', 'edited.com', '0']);
|
||||
$this->commandTester->execute(['domain' => $domainAuthority]);
|
||||
@@ -90,20 +92,19 @@ class DomainRedirectsCommandTest extends TestCase
|
||||
self::assertStringNotContainsStringIgnoringCase('(Leave empty for no redirect)', $output);
|
||||
self::assertEquals(3, substr_count($output, 'Set new redirect URL'));
|
||||
self::assertEquals(3, substr_count($output, 'Remove redirect'));
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
$this->domainService->listDomains()->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function authorityIsRequestedWhenNotProvidedAndNoOtherDomainsExist(): void
|
||||
{
|
||||
$domainAuthority = 'example.com';
|
||||
$domain = Domain::withAuthority($domainAuthority);
|
||||
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([]);
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$this->domainService->expects($this->once())->method('listDomains')->with()->willReturn([]);
|
||||
$this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn(
|
||||
$domain,
|
||||
);
|
||||
$this->domainService->expects($this->once())->method('configureNotFoundRedirects')->with(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withoutRedirects(),
|
||||
)->willReturn($domain);
|
||||
@@ -113,24 +114,23 @@ class DomainRedirectsCommandTest extends TestCase
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('Domain authority for which you want to set specific redirects', $output);
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function oneOfTheExistingDomainsCanBeSelected(): void
|
||||
{
|
||||
$domainAuthority = 'existing-two.com';
|
||||
$domain = Domain::withAuthority($domainAuthority);
|
||||
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([
|
||||
$this->domainService->expects($this->once())->method('listDomains')->with()->willReturn([
|
||||
DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()),
|
||||
DomainItem::forNonDefaultDomain(Domain::withAuthority('existing-one.com')),
|
||||
DomainItem::forNonDefaultDomain(Domain::withAuthority($domainAuthority)),
|
||||
]);
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn(
|
||||
$domain,
|
||||
);
|
||||
$this->domainService->expects($this->once())->method('configureNotFoundRedirects')->with(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withoutRedirects(),
|
||||
)->willReturn($domain);
|
||||
@@ -143,24 +143,23 @@ class DomainRedirectsCommandTest extends TestCase
|
||||
self::assertStringNotContainsString('default-domain.com', $output);
|
||||
self::assertStringContainsString('existing-one.com', $output);
|
||||
self::assertStringContainsString($domainAuthority, $output);
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function aNewDomainCanBeCreatedEvenIfOthersAlreadyExist(): void
|
||||
{
|
||||
$domainAuthority = 'new-domain.com';
|
||||
$domain = Domain::withAuthority($domainAuthority);
|
||||
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([
|
||||
$this->domainService->expects($this->once())->method('listDomains')->with()->willReturn([
|
||||
DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()),
|
||||
DomainItem::forNonDefaultDomain(Domain::withAuthority('existing-one.com')),
|
||||
DomainItem::forNonDefaultDomain(Domain::withAuthority('existing-two.com')),
|
||||
]);
|
||||
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
|
||||
$configureRedirects = $this->domainService->configureNotFoundRedirects(
|
||||
$this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn(
|
||||
$domain,
|
||||
);
|
||||
$this->domainService->expects($this->once())->method('configureNotFoundRedirects')->with(
|
||||
$domainAuthority,
|
||||
NotFoundRedirects::withoutRedirects(),
|
||||
)->willReturn($domain);
|
||||
@@ -173,8 +172,5 @@ class DomainRedirectsCommandTest extends TestCase
|
||||
self::assertStringNotContainsString('default-domain.com', $output);
|
||||
self::assertStringContainsString('existing-one.com', $output);
|
||||
self::assertStringContainsString('existing-two.com', $output);
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$configureRedirects->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Domain;
|
||||
|
||||
use Pagerfanta\Adapter\ArrayAdapter;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Domain\GetDomainVisitsCommand;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
@@ -25,31 +25,34 @@ class GetDomainVisitsCommandTest extends TestCase
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $visitsHelper;
|
||||
private ObjectProphecy $stringifier;
|
||||
private MockObject & VisitsStatsHelperInterface $visitsHelper;
|
||||
private MockObject & ShortUrlStringifierInterface $stringifier;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
|
||||
$this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class);
|
||||
$this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class);
|
||||
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
|
||||
|
||||
$this->commandTester = $this->testerForCommand(
|
||||
new GetDomainVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()),
|
||||
new GetDomainVisitsCommand($this->visitsHelper, $this->stringifier),
|
||||
);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function outputIsProperlyGenerated(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createEmpty();
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
|
||||
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
);
|
||||
$domain = 'doma.in';
|
||||
$getVisits = $this->visitsHelper->visitsForDomain($domain, Argument::any())->willReturn(
|
||||
new Paginator(new ArrayAdapter([$visit])),
|
||||
$domain = 's.test';
|
||||
$this->visitsHelper->expects($this->once())->method('visitsForDomain')->with(
|
||||
$domain,
|
||||
$this->anything(),
|
||||
)->willReturn(new Paginator(new ArrayAdapter([$visit])));
|
||||
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
|
||||
'the_short_url',
|
||||
);
|
||||
$stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url');
|
||||
|
||||
$this->commandTester->execute(['domain' => $domain]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
@@ -65,7 +68,5 @@ class GetDomainVisitsCommandTest extends TestCase
|
||||
OUTPUT,
|
||||
$output,
|
||||
);
|
||||
$getVisits->shouldHaveBeenCalledOnce();
|
||||
$stringify->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Domain;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
@@ -21,18 +23,15 @@ class ListDomainsCommandTest extends TestCase
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $domainService;
|
||||
private MockObject & DomainServiceInterface $domainService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->domainService = $this->prophesize(DomainServiceInterface::class);
|
||||
$this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService->reveal()));
|
||||
$this->domainService = $this->createMock(DomainServiceInterface::class);
|
||||
$this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideInputsAndOutputs
|
||||
*/
|
||||
#[Test, DataProvider('provideInputsAndOutputs')]
|
||||
public function allDomainsAreProperlyPrinted(array $input, string $expectedOutput): void
|
||||
{
|
||||
$bazDomain = Domain::withAuthority('baz.com');
|
||||
@@ -42,7 +41,7 @@ class ListDomainsCommandTest extends TestCase
|
||||
'https://foo.com/baz-domain/invalid',
|
||||
));
|
||||
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([
|
||||
$this->domainService->expects($this->once())->method('listDomains')->with()->willReturn([
|
||||
DomainItem::forDefaultDomain('foo.com', new NotFoundRedirectOptions(
|
||||
invalidShortUrl: 'https://foo.com/default/invalid',
|
||||
baseUrl: 'https://foo.com/default/base',
|
||||
@@ -55,10 +54,9 @@ class ListDomainsCommandTest extends TestCase
|
||||
|
||||
self::assertEquals($expectedOutput, $this->commandTester->getDisplay());
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideInputsAndOutputs(): iterable
|
||||
public static function provideInputsAndOutputs(): iterable
|
||||
{
|
||||
$withoutRedirectsOutput = <<<OUTPUT
|
||||
+---------+------------+
|
||||
|
||||
@@ -4,10 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Laminas\ServiceManager\Exception\ServiceNotFoundException;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
@@ -16,8 +18,10 @@ use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\UrlShortener;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\UrlShorteningResult;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class CreateShortUrlCommandTest extends TestCase
|
||||
@@ -27,48 +31,55 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
private const DEFAULT_DOMAIN = 'default.com';
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $urlShortener;
|
||||
private ObjectProphecy $stringifier;
|
||||
private MockObject & UrlShortenerInterface $urlShortener;
|
||||
private MockObject & ShortUrlStringifierInterface $stringifier;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->urlShortener = $this->prophesize(UrlShortener::class);
|
||||
$this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class);
|
||||
$this->stringifier->stringify(Argument::type(ShortUrl::class))->willReturn('');
|
||||
$this->urlShortener = $this->createMock(UrlShortenerInterface::class);
|
||||
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
|
||||
|
||||
$command = new CreateShortUrlCommand(
|
||||
$this->urlShortener->reveal(),
|
||||
$this->stringifier->reveal(),
|
||||
new UrlShortenerOptions(domain: ['hostname' => self::DEFAULT_DOMAIN], defaultShortCodesLength: 5),
|
||||
$this->urlShortener,
|
||||
$this->stringifier,
|
||||
new UrlShortenerOptions(
|
||||
domain: ['hostname' => self::DEFAULT_DOMAIN, 'schema' => ''],
|
||||
defaultShortCodesLength: 5,
|
||||
),
|
||||
);
|
||||
$this->commandTester = $this->testerForCommand($command);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createEmpty();
|
||||
$urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willReturn($shortUrl);
|
||||
$stringify = $this->stringifier->stringify($shortUrl)->willReturn('stringified_short_url');
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn(
|
||||
UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl),
|
||||
);
|
||||
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
|
||||
'stringified_short_url',
|
||||
);
|
||||
|
||||
$this->commandTester->execute([
|
||||
'longUrl' => 'http://domain.com/foo/bar',
|
||||
'--max-visits' => '3',
|
||||
]);
|
||||
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
self::assertStringContainsString('stringified_short_url', $output);
|
||||
$urlToShortCode->shouldHaveBeenCalledOnce();
|
||||
$stringify->shouldHaveBeenCalledOnce();
|
||||
self::assertStringNotContainsString('but the real-time updates cannot', $output);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function exceptionWhileParsingLongUrlOutputsError(): void
|
||||
{
|
||||
$url = 'http://domain.com/invalid';
|
||||
$this->urlShortener->shorten(Argument::cetera())->willThrow(InvalidUrlException::fromUrl($url))
|
||||
->shouldBeCalledOnce();
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willThrowException(
|
||||
InvalidUrlException::fromUrl($url),
|
||||
);
|
||||
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
|
||||
|
||||
$this->commandTester->execute(['longUrl' => $url]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
@@ -77,33 +88,34 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
self::assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function providingNonUniqueSlugOutputsError(): void
|
||||
{
|
||||
$urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willThrow(
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willThrowException(
|
||||
NonUniqueSlugException::fromSlug('my-slug'),
|
||||
);
|
||||
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
|
||||
|
||||
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
|
||||
self::assertStringContainsString('Provided slug "my-slug" is already in use', $output);
|
||||
$urlToShortCode->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
#[Test]
|
||||
public function properlyProcessesProvidedTags(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createEmpty();
|
||||
$urlToShortCode = $this->urlShortener->shorten(
|
||||
Argument::that(function (ShortUrlCreation $meta) {
|
||||
$tags = $meta->getTags();
|
||||
Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $tags);
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->with(
|
||||
$this->callback(function (ShortUrlCreation $creation) {
|
||||
Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $creation->tags);
|
||||
return true;
|
||||
}),
|
||||
)->willReturn($shortUrl);
|
||||
$stringify = $this->stringifier->stringify($shortUrl)->willReturn('stringified_short_url');
|
||||
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl));
|
||||
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
|
||||
'stringified_short_url',
|
||||
);
|
||||
|
||||
$this->commandTester->execute([
|
||||
'longUrl' => 'http://domain.com/foo/bar',
|
||||
@@ -113,31 +125,26 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
self::assertStringContainsString('stringified_short_url', $output);
|
||||
$urlToShortCode->shouldHaveBeenCalledOnce();
|
||||
$stringify->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideDomains
|
||||
*/
|
||||
#[Test, DataProvider('provideDomains')]
|
||||
public function properlyProcessesProvidedDomain(array $input, ?string $expectedDomain): void
|
||||
{
|
||||
$shorten = $this->urlShortener->shorten(
|
||||
Argument::that(function (ShortUrlCreation $meta) use ($expectedDomain) {
|
||||
Assert::assertEquals($expectedDomain, $meta->getDomain());
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->with(
|
||||
$this->callback(function (ShortUrlCreation $meta) use ($expectedDomain) {
|
||||
Assert::assertEquals($expectedDomain, $meta->domain);
|
||||
return true;
|
||||
}),
|
||||
)->willReturn(ShortUrl::createEmpty());
|
||||
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching(ShortUrl::createFake()));
|
||||
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
|
||||
|
||||
$input['longUrl'] = 'http://domain.com/foo/bar';
|
||||
$this->commandTester->execute($input);
|
||||
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
$shorten->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideDomains(): iterable
|
||||
public static function provideDomains(): iterable
|
||||
{
|
||||
yield 'no domain' => [[], null];
|
||||
yield 'non-default domain foo' => [['--domain' => 'foo.com'], 'foo.com'];
|
||||
@@ -145,29 +152,61 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
yield 'default domain' => [['--domain' => self::DEFAULT_DOMAIN], null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideFlags
|
||||
*/
|
||||
#[Test, DataProvider('provideFlags')]
|
||||
public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedValidateUrl): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createEmpty();
|
||||
$urlToShortCode = $this->urlShortener->shorten(
|
||||
Argument::that(function (ShortUrlCreation $meta) use ($expectedValidateUrl) {
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->with(
|
||||
$this->callback(function (ShortUrlCreation $meta) use ($expectedValidateUrl) {
|
||||
Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl());
|
||||
return $meta;
|
||||
return true;
|
||||
}),
|
||||
)->willReturn($shortUrl);
|
||||
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl));
|
||||
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
|
||||
|
||||
$options['longUrl'] = 'http://domain.com/foo/bar';
|
||||
$this->commandTester->execute($options);
|
||||
|
||||
$urlToShortCode->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideFlags(): iterable
|
||||
public static function provideFlags(): iterable
|
||||
{
|
||||
yield 'no flags' => [[], null];
|
||||
yield 'validate-url' => [['--validate-url' => true], true];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(string $output): void $assert
|
||||
*/
|
||||
#[Test, DataProvider('provideDispatchBehavior')]
|
||||
public function warningIsPrintedInVerboseModeWhenDispatchErrors(int $verbosity, callable $assert): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn(
|
||||
UrlShorteningResult::withErrorOnEventDispatching($shortUrl, new ServiceNotFoundException()),
|
||||
);
|
||||
$this->stringifier->method('stringify')->willReturn('stringified_short_url');
|
||||
|
||||
$this->commandTester->execute(['longUrl' => 'http://domain.com/foo/bar'], ['verbosity' => $verbosity]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$assert($output);
|
||||
}
|
||||
|
||||
public static function provideDispatchBehavior(): iterable
|
||||
{
|
||||
$containsAssertion = static fn (string $output) => self::assertStringContainsString(
|
||||
'but the real-time updates cannot',
|
||||
$output,
|
||||
);
|
||||
$doesNotContainAssertion = static fn (string $output) => self::assertStringNotContainsString(
|
||||
'but the real-time updates cannot',
|
||||
$output,
|
||||
);
|
||||
|
||||
yield 'quiet' => [OutputInterface::VERBOSITY_QUIET, $doesNotContainAssertion];
|
||||
yield 'normal' => [OutputInterface::VERBOSITY_NORMAL, $doesNotContainAssertion];
|
||||
yield 'verbose' => [OutputInterface::VERBOSITY_VERBOSE, $containsAssertion];
|
||||
yield 'very verbose' => [OutputInterface::VERBOSITY_VERY_VERBOSE, $containsAssertion];
|
||||
yield 'debug' => [OutputInterface::VERBOSITY_DEBUG, $containsAssertion];
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user