mirror of
https://github.com/shlinkio/shlink.git
synced 2026-02-28 20:23:12 +08:00
Compare commits
192 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29bb201581 | ||
|
|
006ec7c1d0 | ||
|
|
1bc9e0643d | ||
|
|
d6395a3de8 | ||
|
|
098751d256 | ||
|
|
8577d6bd99 | ||
|
|
fe4e171ecb | ||
|
|
d99ea82761 | ||
|
|
27bc8d4823 | ||
|
|
7c9f572eb1 | ||
|
|
2732b05834 | ||
|
|
97f89bcede | ||
|
|
00255b04eb | ||
|
|
f90ea4bd98 | ||
|
|
0d7fb1163a | ||
|
|
cb340b5867 | ||
|
|
1621f3a943 | ||
|
|
ae636aef5a | ||
|
|
1346d7902e | ||
|
|
544836b986 | ||
|
|
397f7d09e3 | ||
|
|
efa707c676 | ||
|
|
51c8b80489 | ||
|
|
e71fb0ac7f | ||
|
|
681b7c836d | ||
|
|
7c2c90fc49 | ||
|
|
ebe6a5f4aa | ||
|
|
65651e4bbd | ||
|
|
33190c07c7 | ||
|
|
f651b0e5a1 | ||
|
|
c85eb84b4c | ||
|
|
86d428184e | ||
|
|
c1529b7d6c | ||
|
|
7ecc3aacc4 | ||
|
|
b091bd4e2a | ||
|
|
90b4bc9b1a | ||
|
|
de7096010e | ||
|
|
03a9697298 | ||
|
|
fdcf88de67 | ||
|
|
7c343f42c1 | ||
|
|
786e4f642b | ||
|
|
b1a073b1ab | ||
|
|
2256f6a9e7 | ||
|
|
ec3e7212b2 | ||
|
|
554d9b092f | ||
|
|
33d3837795 | ||
|
|
0686ac2fb1 | ||
|
|
ce3d267572 | ||
|
|
4ec90e02c9 | ||
|
|
e7bccb088d | ||
|
|
cbc9f1257d | ||
|
|
c7f15b77fd | ||
|
|
a8b0c46142 | ||
|
|
065d314608 | ||
|
|
d426dbc684 | ||
|
|
c6c78f383f | ||
|
|
450eea64aa | ||
|
|
c8d7413dd4 | ||
|
|
00a96e6215 | ||
|
|
b15e90408f | ||
|
|
34c10c0bc9 | ||
|
|
63a24342e3 | ||
|
|
073e4eeac8 | ||
|
|
06eda073bf | ||
|
|
614e1c37f8 | ||
|
|
24aab5cc0e | ||
|
|
76d6d9a7a9 | ||
|
|
8109ceb7eb | ||
|
|
6163e34327 | ||
|
|
84b291e310 | ||
|
|
20cd5cd752 | ||
|
|
d9d57743e6 | ||
|
|
cc57dcd01a | ||
|
|
10fbf8f8ff | ||
|
|
cfc9a1b772 | ||
|
|
2555424124 | ||
|
|
405369824b | ||
|
|
cdd87f5962 | ||
|
|
d5eac3b1c3 | ||
|
|
1f78f5266a | ||
|
|
aa0124f4e9 | ||
|
|
641f35ae05 | ||
|
|
4e94f07050 | ||
|
|
460ca032d2 | ||
|
|
8d438aa6aa | ||
|
|
504d08101a | ||
|
|
4b7184ac85 | ||
|
|
55d9f2a4a1 | ||
|
|
319b790628 | ||
|
|
ee563978ac | ||
|
|
be71a6eeb4 | ||
|
|
25fbbee883 | ||
|
|
8dbd9ca33d | ||
|
|
cad8c7ed48 | ||
|
|
c11c731bef | ||
|
|
a79362d520 | ||
|
|
c708df2029 | ||
|
|
e0760c371a | ||
|
|
714a58945e | ||
|
|
87e8ae7af6 | ||
|
|
a66dca4f07 | ||
|
|
9853b0916f | ||
|
|
18afd92fc3 | ||
|
|
0474b32c34 | ||
|
|
ca6fb1c656 | ||
|
|
a7a69506a0 | ||
|
|
a32651aab3 | ||
|
|
977af0ee43 | ||
|
|
53bbcd34a6 | ||
|
|
1eb9ef0361 | ||
|
|
1ac05fd3a4 | ||
|
|
4aef0fa728 | ||
|
|
f4da1b0a2e | ||
|
|
163839494b | ||
|
|
8a811c5b33 | ||
|
|
007139e4ff | ||
|
|
6be0310933 | ||
|
|
5f9b629676 | ||
|
|
8e84b0e8ac | ||
|
|
3ff9e101a8 | ||
|
|
71570af7db | ||
|
|
1401dd9156 | ||
|
|
36c12a69b1 | ||
|
|
742e2d724e | ||
|
|
f74851b0d8 | ||
|
|
dd5dcf6ec1 | ||
|
|
a448972e3c | ||
|
|
f784a4f794 | ||
|
|
554a66503f | ||
|
|
73c6c52b2a | ||
|
|
509672f4c7 | ||
|
|
e4f01e4cf8 | ||
|
|
156eae56d0 | ||
|
|
2df6e694ea | ||
|
|
78b838f6b6 | ||
|
|
08950f6433 | ||
|
|
a74e1df55c | ||
|
|
bf1c6e3d43 | ||
|
|
d234e114db | ||
|
|
035743ef6a | ||
|
|
c7c9ab71ff | ||
|
|
e107aa9ed8 | ||
|
|
e9191732bd | ||
|
|
f44540f95e | ||
|
|
6b3fd2ac83 | ||
|
|
eed353fedf | ||
|
|
b4e58cc1bb | ||
|
|
56d690d9a6 | ||
|
|
bffc044bc7 | ||
|
|
58dd1c54f9 | ||
|
|
5c163490c7 | ||
|
|
f2f07be11f | ||
|
|
0bea843e7f | ||
|
|
83cc11030d | ||
|
|
cb70dc5389 | ||
|
|
68db52679b | ||
|
|
186168b26c | ||
|
|
e9c64b46b7 | ||
|
|
f476cfc30f | ||
|
|
3706d6c82d | ||
|
|
248209ab41 | ||
|
|
2867a9b7b0 | ||
|
|
68919c19b8 | ||
|
|
ee1aa42900 | ||
|
|
c3de39d313 | ||
|
|
8ecc9c69a2 | ||
|
|
e814f3afcf | ||
|
|
a4eda9d761 | ||
|
|
f3f3ef5c18 | ||
|
|
296134078c | ||
|
|
527faf27a8 | ||
|
|
9c339b9c4f | ||
|
|
f274cafa7c | ||
|
|
371f246c41 | ||
|
|
95ae540799 | ||
|
|
f340e0e76e | ||
|
|
14e0766f72 | ||
|
|
17f3897746 | ||
|
|
3c3a30cc0e | ||
|
|
726811f91f | ||
|
|
75f5da5846 | ||
|
|
489c739be2 | ||
|
|
9d6f14c81a | ||
|
|
788f9635dd | ||
|
|
09aa4cc977 | ||
|
|
9252cc269b | ||
|
|
65e6676c00 | ||
|
|
135b62a9cc | ||
|
|
2ea58acde2 | ||
|
|
e1085f3ef5 | ||
|
|
f1db195a06 | ||
|
|
fa646b0176 |
5
.github/ISSUE_TEMPLATE.md
vendored
5
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,6 +1,7 @@
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
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.
|
||||
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.
|
||||
-->
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/Bug.md
vendored
5
.github/ISSUE_TEMPLATE/Bug.md
vendored
@@ -5,9 +5,10 @@ labels: bug
|
||||
---
|
||||
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
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.
|
||||
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).
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
5
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
@@ -5,9 +5,10 @@ labels: feature
|
||||
---
|
||||
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
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.
|
||||
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).
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
5
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
@@ -5,9 +5,10 @@ labels: question
|
||||
---
|
||||
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
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.
|
||||
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).
|
||||
|
||||
24
.github/workflows/docker-image-build.yml
vendored
Normal file
24
.github/workflows/docker-image-build.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Build docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Install buildx
|
||||
id: buildx
|
||||
uses: crazy-max/ghaction-docker-buildx@v1
|
||||
with:
|
||||
buildx-version: latest
|
||||
- name: Login to docker hub
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
- name: Build the image
|
||||
run: bash ./docker/build
|
||||
30
.github/workflows/publish-release.yml
vendored
Normal file
30
.github/workflows/publish-release.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Publish release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Use PHP 7.4
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '7.4' # Publish release with lowest supported PHP version
|
||||
tools: composer
|
||||
extensions: swoole-4.5.5
|
||||
- name: Generate release assets
|
||||
run: ./build.sh ${GITHUB_REF#refs/tags/v}
|
||||
- name: Publish release with assets
|
||||
uses: docker://antonyurchenko/git-release:latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ALLOW_TAG_PREFIX: "true"
|
||||
ALLOW_EMPTY_CHANGELOG: "true"
|
||||
with:
|
||||
args: |
|
||||
build/shlink_*_dist.zip
|
||||
55
.travis.yml
55
.travis.yml
@@ -1,12 +1,11 @@
|
||||
dist: bionic
|
||||
|
||||
language: php
|
||||
|
||||
branches:
|
||||
only:
|
||||
- /.*/
|
||||
|
||||
php:
|
||||
- '7.4'
|
||||
|
||||
services:
|
||||
- docker
|
||||
|
||||
@@ -14,50 +13,44 @@ cache:
|
||||
directories:
|
||||
- $HOME/.composer/cache/files
|
||||
|
||||
jobs:
|
||||
fast_finish: true
|
||||
allow_failures:
|
||||
- php: 'nightly'
|
||||
include:
|
||||
- name: "CI - 8.0"
|
||||
php: 'nightly'
|
||||
env:
|
||||
- COMPOSER_FLAGS='--ignore-platform-reqs'
|
||||
- name: "CI - 7.4"
|
||||
php: '7.4'
|
||||
env:
|
||||
- COMPOSER_FLAGS=''
|
||||
|
||||
before_install:
|
||||
- sudo ./data/infra/ci/install-ms-odbc.sh
|
||||
- docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms shlink_db shlink_db_postgres shlink_db_maria
|
||||
- yes | pecl install pdo_sqlsrv swoole-4.4.18
|
||||
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
|
||||
- phpenv config-rm xdebug.ini || return 0
|
||||
- sudo ./data/infra/ci/install-ms-odbc.sh
|
||||
- docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms shlink_db shlink_db_postgres shlink_db_maria
|
||||
- yes | pecl install pdo_sqlsrv-5.9.0preview1 swoole-4.5.5 pcov
|
||||
|
||||
install:
|
||||
- composer self-update
|
||||
- composer install --no-interaction --prefer-dist
|
||||
- composer install --no-interaction --prefer-dist $COMPOSER_FLAGS
|
||||
|
||||
before_script:
|
||||
- docker-compose exec shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
|
||||
- mkdir build
|
||||
- export DOCKERFILE_CHANGED=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep Dockerfile)
|
||||
- export DOCKERFILE_CHANGED=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/main} --name-only | grep Dockerfile)
|
||||
|
||||
script:
|
||||
- composer ci
|
||||
- if [[ ! -z "$DOCKERFILE_CHANGED" && "${TRAVIS_PHP_VERSION}" == "7.4" ]]; then docker build -t shlink-docker-image:temp . ; fi
|
||||
- bin/test/run-api-tests.sh
|
||||
- if [[ ! -z "${DOCKERFILE_CHANGED}" && "${TRAVIS_PHP_VERSION}" == "7.4" ]]; then docker build -t shlink-docker-image:temp . ; fi
|
||||
|
||||
after_success:
|
||||
- rm -f build/clover.xml
|
||||
- wget https://phar.phpunit.de/phpcov-7.0.2.phar
|
||||
- phpdbg -qrr phpcov-7.0.2.phar merge build --clover build/clover.xml
|
||||
- php phpcov-7.0.2.phar merge build --clover build/clover.xml
|
||||
- wget https://scrutinizer-ci.com/ocular.phar
|
||||
- php ocular.phar code-coverage:upload --format=php-clover build/clover.xml
|
||||
|
||||
# Before deploying, build dist file for current travis tag
|
||||
before_deploy:
|
||||
- rm -f ocular.phar
|
||||
- if [[ ! -z $TRAVIS_TAG && "${TRAVIS_PHP_VERSION}" == "7.4" ]]; then ./build.sh ${TRAVIS_TAG#?} ; fi
|
||||
|
||||
deploy:
|
||||
- provider: releases
|
||||
api_key:
|
||||
secure: a9dbZchocqeuOViwUeNH54bQR5Sz7rEYXx5b9WPFtnFn9LGKKUaLbA2U91UQ9QKPrcTpsALubUYbw2CnNmvCwzaY+R8lCD3gkU4ohsEnbpnw3deOeixI74sqBHJAuCH9FSaRDGILoBMtUKx2xlzIymFxkIsgIukkGbdkWHDlRWY3oTUUuw1SQ2Xk9KDsbJQtjIc1+G/O6gHaV4qv/R9W8NPmJExKTNDrAZbC1vIUnxqp4UpVo1hst8qPd1at94CndDYM5rG+7imGbdtxTxzamt819qdTO1OfvtctKawNAm7YXZrrWft6c7gI6j6SI4hxd+ZrrPBqbaRFHkZHjnNssO/yn4SaOHFFzccmu0MzvpPCf0qWZwd3sGHVYer1MnR2mHYqU84QPlW3nrHwJjkrpq3+q0JcBY6GsJs+RskHNtkMTKV05Iz6QUI5YZGwTpuXaRm036SmavjGc4IDlMaYCk/NmbB9BKpthJxLdUpczOHpnjXXHziotWD6cfEnbjU3byfD8HY5WrxSjsNT7SKmXN3hRof7bk985ewQVjGT42O3NbnfnqjQQWr/B7/zFTpLR4f526Bkq12CdCyf5lvrbq+POkLVdJ+uFfR7ds248Ue/jBQy6kM1tWmKF9QiwisFlA84eQ4CW3I93Rp97URv+AQa9zmbD0Ve3Udp+g6nF5I=
|
||||
file: "./build/shlink_${TRAVIS_TAG#?}_dist.zip"
|
||||
skip_cleanup: true
|
||||
on:
|
||||
tags: true
|
||||
php: '7.4'
|
||||
- provider: script
|
||||
script: bash ./docker/build
|
||||
on:
|
||||
all_branches: true
|
||||
condition: $TRAVIS_PULL_REQUEST == 'false'
|
||||
php: '7.4'
|
||||
|
||||
1148
CHANGELOG.md
1148
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
136
CONTRIBUTING.md
Normal file
136
CONTRIBUTING.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Contributing
|
||||
|
||||
This file will guide you through the process of getting to project up and running, in case you want to provide coding contributions.
|
||||
|
||||
You will also see how to ensure the code fulfills the expected code checks, and how to create a pull request.
|
||||
|
||||
## System dependencies
|
||||
|
||||
The project provides all its dependencies as docker containers through a docker-compose configuration.
|
||||
|
||||
Because of this, the only actual dependencies are [docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/).
|
||||
|
||||
## Setting up the project
|
||||
|
||||
The first thing you need to do is fork the repository, and clone it in your local machine.
|
||||
|
||||
Then you will have to follow these steps:
|
||||
|
||||
* Copy all files with `.local.php.dist` extension from `config/autoload` by removing the dist extension.
|
||||
|
||||
For example the `common.local.php.dist` file should be copied as `common.local.php`.
|
||||
|
||||
* Copy the file `docker-compose.override.yml.dist` by also removing the `dist` extension.
|
||||
* Start-up the project by running `docker-compose up`.
|
||||
|
||||
The first time this command is run, it will create several containers that are used during development, so it may take some time.
|
||||
|
||||
It will also create some empty databases and install the project dependencies with composer.
|
||||
|
||||
* Run `./indocker bin/cli db:create` to create the initial database.
|
||||
* Run `./indocker bin/cli db:migrate` to get database migrations up to date.
|
||||
* Run `./indocker bin/cli api-key:generate` to get your first API key generated.
|
||||
|
||||
Once you finish this, you will have the project exposed in ports `8000` through nginx+php-fpm and `8080` through swoole.
|
||||
|
||||
> Note: The `indocker` shell script is a helper used to run commands inside the main docker container.
|
||||
|
||||
## Project structure
|
||||
|
||||
This project is structured as a modular application, using [laminas/laminas-config-aggregator](https://github.com/laminas/laminas-config-aggregator) to merge the configuration provided by every module.
|
||||
|
||||
All modules are inside the `module` folder, and each one has its own `src`, `test` and `config` folders, with the source code, tests and configuration. They also have their own `ConfigProvider` class, which is consumed by the config aggregator.
|
||||
|
||||
This is a simplified version of the project structure:
|
||||
|
||||
```
|
||||
shlink
|
||||
├── bin
|
||||
│ ├── cli
|
||||
│ ├── install
|
||||
│ └── update
|
||||
├── config
|
||||
│ ├── autoload
|
||||
│ ├── params
|
||||
│ ├── config.php
|
||||
│ └── container.php
|
||||
├── data
|
||||
│ ├── cache
|
||||
│ ├── locks
|
||||
│ ├── log
|
||||
│ ├── migrations
|
||||
│ └── proxies
|
||||
├── docs
|
||||
│ ├── async-api
|
||||
│ └── swagger
|
||||
├── module
|
||||
│ ├── CLI
|
||||
│ ├── Core
|
||||
│ └── Rest
|
||||
├── public
|
||||
├── composer.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
The purposes of every folder are:
|
||||
|
||||
* `bin`: It contains the CLI tools. The `cli` one is the main entry point to run shlink from the command line, while `install` and `update` are helper tools used to install and update shlink when not using the docker image.
|
||||
* `config`: Contains application-wide configurations, which are later merged with the ones provided by every module.
|
||||
* `data`: Common runtime-generated git-ignored assets, like logs, caches, etc.
|
||||
* `docs`: Any project documentation is stored here, like API spec definitions.
|
||||
* `module`: Contains a subfolder for every module in the project. Modules contain the source code, tests and configurations for every context in the project.
|
||||
* `public`: Few assets (like `favicon.ico` or `robots.txt`) and the web entry point are stored here. This web entry point is not used when serving the app with swoole.
|
||||
|
||||
## Project tests
|
||||
|
||||
In order to ensure stability and no regressions are introduced while developing new features, this project has different types of tests.
|
||||
|
||||
* **Unit tests**: These are the simplest to run, and usually test individual pieces of code, replacing any external dependency by mocks.
|
||||
|
||||
The code coverage of unit tests is pretty high, and only entity repositories are excluded because of their nature.
|
||||
|
||||
* **Database tests**: These are integration tests that run against a real database, and only cover entity repositories.
|
||||
|
||||
Its purpose is to verify all the database queries behave as expected and return what's expected.
|
||||
|
||||
The project provides some tooling to run them against any of the supported database engines.
|
||||
|
||||
* **API tests**: These are E2E tests that spin up an instance of the app and test it from the outside, by interacting with the REST API.
|
||||
|
||||
These are the best tests to catch regressions, and to verify everything interacts as expected.
|
||||
|
||||
They use MySQL as the database engine, and include some fixtures that ensure the same data exists at the beginning of the execution.
|
||||
|
||||
* **CLI tests**: *TBD. Once included, its purpose will be the same as API tests, but running through the command line*
|
||||
|
||||
Depending on the kind of contribution, maybe not all kinds of tests are needed, but the more you provide, the better.
|
||||
|
||||
## Running code checks
|
||||
|
||||
* Run `./indocker composer cs` to check coding styles are fulfilled.
|
||||
* Run `./indocker composer cs:fix` to fix coding styles (some may not be fixable from the CLI)
|
||||
* Run `./indocker composer stan` to statically analyze the code with [phpstan](https://phpstan.org/). This tool is the closest to "compile" PHP and verify everything would work as expected.
|
||||
* Run `./indocker composer test:unit` to run the unit tests.
|
||||
* Run `./indocker composer test:db` to run the database integration tests.
|
||||
|
||||
This command runs the same test suite against all supported database engines. If you just want to run one of them, you can add one of `:sqlite`, `:mysql`, `:maria`, `:postgres`, `:mssql` at the end of the command.
|
||||
|
||||
For example, `test:db:postgres`.
|
||||
|
||||
* Run `./indocker composer test:api` to run API E2E tests. For these, the MySQL database engine is used.
|
||||
* Run `./indocker composer infect:test` ti run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/).
|
||||
* Run `./indocker composer ci` to run all previous commands together. This command is run during the project's continuous integration.
|
||||
|
||||
> Note: Due to some limitations in the tooling used by shlink, the testing databases need to exist beforehand, both for db and api tests (except sqlite).
|
||||
>
|
||||
> However, they just need to be created empty, with no tables. Also, once created, they are automatically reset before every new execution.
|
||||
>
|
||||
> The testing database is always called `shlink_test`. You can create it using the database client of your choice. [DBeaver](https://dbeaver.io/) is a good multi-platform desktop database client which supports all the engines supported by shlink.
|
||||
|
||||
## Pull request process
|
||||
|
||||
In order to provide pull requests to this project, you should always start by creating a new branch, where you will make all desired changes.
|
||||
|
||||
The base branch should always be `develop`, and the target branch for the pull request should also be `develop`.
|
||||
|
||||
Before your branch can be merged, all the checks described in [Running code checks](#running-code-checks) have to be passing. You can verify that manually by running `./indocker composer ci`, or wait for the build to be run automatically after the pull request is created.
|
||||
40
Dockerfile
40
Dockerfile
@@ -1,12 +1,13 @@
|
||||
FROM php:7.4.5-alpine3.11 as base
|
||||
FROM php:7.4.11-alpine3.12 as base
|
||||
|
||||
ARG SHLINK_VERSION=2.1.4
|
||||
ARG SHLINK_VERSION=2.3.0
|
||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||
ENV SWOOLE_VERSION 4.4.18
|
||||
ENV SWOOLE_VERSION 4.5.5
|
||||
ENV LC_ALL "C"
|
||||
|
||||
WORKDIR /etc/shlink
|
||||
|
||||
# Install required PHP extensions
|
||||
RUN \
|
||||
# Install mysql and calendar
|
||||
docker-php-ext-install -j"$(nproc)" pdo_mysql calendar && \
|
||||
@@ -21,22 +22,33 @@ RUN \
|
||||
docker-php-ext-install -j"$(nproc)" intl && \
|
||||
# Install zip and gd
|
||||
apk add --no-cache libzip-dev zlib-dev libpng-dev && \
|
||||
docker-php-ext-install -j"$(nproc)" zip gd
|
||||
docker-php-ext-install -j"$(nproc)" zip gd && \
|
||||
# Install gmp
|
||||
apk add --no-cache gmp-dev && \
|
||||
docker-php-ext-install -j"$(nproc)" gmp
|
||||
|
||||
# Install swoole and sqlsrv driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \
|
||||
docker-php-ext-enable swoole pdo_sqlsrv && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk
|
||||
# Install sqlsrv driver
|
||||
RUN if [ $(uname -m) == "x86_64" ]; then \
|
||||
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
|
||||
pecl install pdo_sqlsrv && \
|
||||
docker-php-ext-enable pdo_sqlsrv && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk ; \
|
||||
fi
|
||||
|
||||
# Install swoole
|
||||
RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} && \
|
||||
pecl install swoole-${SWOOLE_VERSION} && \
|
||||
docker-php-ext-enable swoole && \
|
||||
apk del .phpize-deps
|
||||
|
||||
|
||||
# Install shlink
|
||||
FROM base as builder
|
||||
COPY . .
|
||||
COPY --from=composer:1.10.1 /usr/bin/composer ./composer.phar
|
||||
COPY --from=composer:2 /usr/bin/composer ./composer.phar
|
||||
RUN apk add --no-cache git && \
|
||||
php composer.phar install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction && \
|
||||
php composer.phar clear-cache && \
|
||||
@@ -51,7 +63,7 @@ LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||
COPY --from=builder /etc/shlink .
|
||||
RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink
|
||||
|
||||
# Expose swoole port
|
||||
# Expose default swoole port
|
||||
EXPOSE 8080
|
||||
|
||||
# Expose params config dir, since the user is expected to provide custom config from there
|
||||
|
||||
16
README.md
16
README.md
@@ -1,17 +1,19 @@
|
||||

|
||||

|
||||
|
||||
[](https://travis-ci.org/shlinkio/shlink)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
|
||||
[](https://travis-ci.com/shlinkio/shlink)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink/)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink/)
|
||||
[](https://packagist.org/packages/shlinkio/shlink)
|
||||
[](https://hub.docker.com/r/shlinkio/shlink/)
|
||||
[](https://github.com/shlinkio/shlink/blob/master/LICENSE)
|
||||
[](https://github.com/shlinkio/shlink/blob/main/LICENSE)
|
||||
[](https://slnk.to/donate)
|
||||
|
||||
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own custom domain.
|
||||
|
||||
> This document references Shlink 2.x. If you are using an older version and want to upgrade, follow the [UPGRADE](UPGRADE.md) doc.
|
||||
|
||||
> If you are trying to find out how to run the project in development mode or how to provide contributions, read the [CONTRIBUTING](CONTRIBUTING.md) doc.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
@@ -36,7 +38,7 @@ A PHP-based self-hosted URL shortener that can be used to serve shortened URLs u
|
||||
|
||||
First, make sure the host where you are going to run shlink fulfills these requirements:
|
||||
|
||||
* PHP 7.4 or greater with JSON, curl, PDO and gd extensions enabled.
|
||||
* PHP 7.4 or greater with JSON, curl, PDO, intl and gd extensions enabled.
|
||||
* MySQL, MariaDB, PostgreSQL, Microsoft SQL Server or SQLite.
|
||||
* The web server of your choice with PHP integration (Apache or Nginx recommended).
|
||||
|
||||
@@ -62,7 +64,7 @@ In order to run Shlink, you will need a built version of the project. There are
|
||||
|
||||
After that, you will have a `shlink_x.x.x_dist.zip` dist file inside the `build` directory, that you need to decompress in the location fo your choice.
|
||||
|
||||
> This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by [travis](https://travis-ci.org/shlinkio/shlink), attaching the generated dist file to it.
|
||||
> This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by [travis](https://travis-ci.com/shlinkio/shlink), attaching the generated dist file to it.
|
||||
|
||||
### Configure
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
export APP_ENV=test
|
||||
export DB_DRIVER=mysql
|
||||
export TEST_ENV=api
|
||||
|
||||
# Try to stop server just in case it hanged in last execution
|
||||
vendor/bin/mezzio-swoole stop
|
||||
@@ -9,7 +10,7 @@ echo 'Starting server...'
|
||||
vendor/bin/mezzio-swoole start -d
|
||||
sleep 2
|
||||
|
||||
phpdbg -qrr vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $*
|
||||
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always --log-junit=build/coverage-api/junit.xml $*
|
||||
testsExitCode=$?
|
||||
|
||||
vendor/bin/mezzio-swoole stop
|
||||
|
||||
@@ -16,23 +16,24 @@
|
||||
"ext-json": "*",
|
||||
"ext-pdo": "*",
|
||||
"akrabat/ip-address-middleware": "^1.0",
|
||||
"cakephp/chronos": "^1.2",
|
||||
"cakephp/chronos": "^2.0",
|
||||
"cocur/slugify": "^4.0",
|
||||
"doctrine/cache": "^1.9",
|
||||
"doctrine/dbal": "^2.10",
|
||||
"doctrine/migrations": "^2.2",
|
||||
"doctrine/migrations": "^3.0.1",
|
||||
"doctrine/orm": "^2.7",
|
||||
"endroid/qr-code": "^3.6",
|
||||
"geoip2/geoip2": "^2.9",
|
||||
"guzzlehttp/guzzle": "^6.5.1",
|
||||
"guzzlehttp/guzzle": "^7.0",
|
||||
"laminas/laminas-config": "^3.3",
|
||||
"laminas/laminas-config-aggregator": "^1.1",
|
||||
"laminas/laminas-dependency-plugin": "^1.0",
|
||||
"laminas/laminas-diactoros": "^2.1.3",
|
||||
"laminas/laminas-inputfilter": "^2.10",
|
||||
"laminas/laminas-paginator": "^2.8",
|
||||
"laminas/laminas-servicemanager": "^3.4",
|
||||
"laminas/laminas-stdlib": "^3.2",
|
||||
"lcobucci/jwt": "^4.0@alpha",
|
||||
"league/uri": "^6.2",
|
||||
"lstrojny/functional-php": "^1.9",
|
||||
"mezzio/mezzio": "^3.2",
|
||||
"mezzio/mezzio-fastroute": "^3.0",
|
||||
@@ -48,28 +49,32 @@
|
||||
"predis/predis": "^1.1",
|
||||
"pugx/shortid-php": "^0.5",
|
||||
"ramsey/uuid": "^3.9",
|
||||
"shlinkio/shlink-common": "^3.1.0",
|
||||
"shlinkio/shlink-common": "^3.3.0",
|
||||
"shlinkio/shlink-config": "^1.0",
|
||||
"shlinkio/shlink-event-dispatcher": "^1.4",
|
||||
"shlinkio/shlink-installer": "^5.0.0",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.4",
|
||||
"symfony/console": "^5.0",
|
||||
"symfony/filesystem": "^5.0",
|
||||
"symfony/lock": "^5.0",
|
||||
"shlinkio/shlink-importer": "^2.0.1",
|
||||
"shlinkio/shlink-installer": "^5.1.0",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.5",
|
||||
"symfony/console": "^5.1",
|
||||
"symfony/filesystem": "^5.1",
|
||||
"symfony/lock": "^5.1",
|
||||
"symfony/mercure": "^0.3.0",
|
||||
"symfony/process": "^5.0"
|
||||
"symfony/process": "^5.1",
|
||||
"symfony/string": "^5.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"devster/ubench": "^2.0",
|
||||
"dms/phpunit-arraysubset-asserts": "^0.2.0",
|
||||
"eaglewu/swoole-ide-helper": "dev-master",
|
||||
"infection/infection": "^0.16.1",
|
||||
"phpstan/phpstan": "^0.12.18",
|
||||
"phpunit/phpunit": "~9.0.1",
|
||||
"infection/infection": "^0.20.0",
|
||||
"phpspec/prophecy-phpunit": "^2.0",
|
||||
"phpstan/phpstan": "^0.12.52",
|
||||
"phpunit/php-code-coverage": "^9.2",
|
||||
"phpunit/phpunit": "^9.4",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"shlinkio/php-coding-standard": "~2.1.0",
|
||||
"shlinkio/shlink-test-utils": "^1.4",
|
||||
"symfony/var-dumper": "^5.0"
|
||||
"shlinkio/php-coding-standard": "~2.1.1",
|
||||
"shlinkio/shlink-test-utils": "^1.5",
|
||||
"symfony/var-dumper": "^5.1"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -90,7 +95,10 @@
|
||||
"module/Core/test",
|
||||
"module/Core/test-db"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"config/test/constants.php"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"ci": [
|
||||
@@ -101,7 +109,7 @@
|
||||
],
|
||||
"cs": "phpcs",
|
||||
"cs:fix": "phpcbf",
|
||||
"stan": "phpstan analyse module/*/src/ module/*/config config docker/config --level=6",
|
||||
"stan": "phpstan analyse module/*/src/ module/*/config config docker/config data/migrations --level=6",
|
||||
"test": [
|
||||
"@test:unit",
|
||||
"@test:db",
|
||||
@@ -109,31 +117,34 @@
|
||||
],
|
||||
"test:ci": [
|
||||
"@test:unit:ci",
|
||||
"@test:db",
|
||||
"@test:api:ci"
|
||||
"@test:db"
|
||||
],
|
||||
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
|
||||
"test:unit:ci": "@test:unit --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml",
|
||||
"test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
|
||||
"test:unit:ci": "@test:unit --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml",
|
||||
"test:db": [
|
||||
"@test:db:sqlite",
|
||||
"@test:db:sqlite:ci",
|
||||
"@test:db:mysql",
|
||||
"@test:db:maria",
|
||||
"@test:db:postgres",
|
||||
"@test:db:ms"
|
||||
],
|
||||
"test:db:sqlite": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-db.cov --testdox -c phpunit-db.xml",
|
||||
"test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
|
||||
"test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml",
|
||||
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
|
||||
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite",
|
||||
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
|
||||
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
|
||||
"test:api": "bin/test/run-api-tests.sh",
|
||||
"test:api:ci": "@test:api --coverage-php build/coverage-api.cov",
|
||||
"test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage",
|
||||
"test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html",
|
||||
"infect": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered",
|
||||
"infect:ci": "@infect --coverage=build --skip-initial-tests",
|
||||
"infect:show": "@infect --show-mutations",
|
||||
"infect:ci:base": "@infect --skip-initial-tests",
|
||||
"infect:ci": [
|
||||
"@infect:ci:base --coverage=build/coverage-unit",
|
||||
"@infect:ci:base --coverage=build/coverage-db --test-framework-options=--configuration=phpunit-db.xml"
|
||||
],
|
||||
"infect:test": [
|
||||
"@test:unit:ci",
|
||||
"@test:db:sqlite:ci",
|
||||
"@infect:ci"
|
||||
],
|
||||
"clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php"
|
||||
@@ -156,11 +167,11 @@
|
||||
"test:unit:pretty": "<fg=blue;options=bold>Runs unit test suites and generates an HTML code coverage report</>",
|
||||
"infect": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing</>",
|
||||
"infect:ci": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing with existing reports and logs</>",
|
||||
"infect:show": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing and shows applied mutators</>",
|
||||
"infect:test": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing</>",
|
||||
"clean:dev": "<fg=blue;options=bold>Deletes artifacts which are gitignored and could affect dev env</>"
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
"sort-packages": true,
|
||||
"platform-check": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use Mezzio\Container;
|
||||
use Psr\Http\Client\ClientInterface;
|
||||
|
||||
return [
|
||||
|
||||
@@ -13,6 +15,10 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'aliases' => [
|
||||
ClientInterface::class => Client::class,
|
||||
],
|
||||
|
||||
'lazy_services' => [
|
||||
'proxies_target_dir' => 'data/proxies',
|
||||
'proxies_namespace' => 'ShlinkProxy',
|
||||
|
||||
@@ -37,6 +37,8 @@ return [
|
||||
Option\Mercure\MercureJwtSecretConfigOption::class,
|
||||
Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class,
|
||||
Option\UrlShortener\IpAnonymizationConfigOption::class,
|
||||
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
|
||||
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
|
||||
],
|
||||
|
||||
'installation_commands' => [
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
return [
|
||||
|
||||
'not_found_redirects' => [
|
||||
'invalid_short_url' => null, // Formerly url_shortener.not_found_short_url.redirect_to
|
||||
'invalid_short_url' => null,
|
||||
'regular_404' => null,
|
||||
'base_url' => null,
|
||||
],
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use Mezzio\Swoole\HotCodeReload\FileWatcher\InotifyFileWatcher;
|
||||
|
||||
return [
|
||||
|
||||
'mezzio-swoole' => [
|
||||
@@ -13,10 +10,4 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
InotifyFileWatcher::class => InvokableFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_STATUS_CODE;
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
||||
|
||||
return [
|
||||
@@ -15,6 +17,8 @@ return [
|
||||
'anonymize_remote_addr' => true,
|
||||
'visits_webhooks' => [],
|
||||
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
|
||||
'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE,
|
||||
'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME,
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -4,11 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\Tools\Console\ConsoleRunner;
|
||||
use Laminas\ServiceManager\ServiceManager;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
return (function () {
|
||||
/** @var ContainerInterface|ServiceManager $container */
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/container.php';
|
||||
$em = $container->get(EntityManager::class);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink;
|
||||
|
||||
use Laminas\ConfigAggregator;
|
||||
use Laminas\Diactoros;
|
||||
use Mezzio;
|
||||
use Mezzio\ProblemDetails;
|
||||
|
||||
@@ -17,8 +18,10 @@ return (new ConfigAggregator\ConfigAggregator([
|
||||
Mezzio\Plates\ConfigProvider::class,
|
||||
Mezzio\Swoole\ConfigProvider::class,
|
||||
ProblemDetails\ConfigProvider::class,
|
||||
Diactoros\ConfigProvider::class,
|
||||
Common\ConfigProvider::class,
|
||||
Config\ConfigProvider::class,
|
||||
Importer\ConfigProvider::class,
|
||||
IpGeolocation\ConfigProvider::class,
|
||||
EventDispatcher\ConfigProvider::class,
|
||||
Core\ConfigProvider::class,
|
||||
|
||||
@@ -7,12 +7,28 @@ namespace Shlinkio\Shlink\TestUtils;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
use function register_shutdown_function;
|
||||
use function sprintf;
|
||||
|
||||
use const ShlinkioTest\Shlink\SWOOLE_TESTING_HOST;
|
||||
use const ShlinkioTest\Shlink\SWOOLE_TESTING_PORT;
|
||||
|
||||
/** @var ContainerInterface $container */
|
||||
$container = require __DIR__ . '/../container.php';
|
||||
$testHelper = $container->get(Helper\TestHelper::class);
|
||||
$config = $container->get('config');
|
||||
$em = $container->get(EntityManager::class);
|
||||
$httpClient = $container->get('shlink_test_api_client');
|
||||
|
||||
// Start code coverage collecting on swoole process, and stop it when process shuts down
|
||||
$httpClient->request('GET', sprintf('http://%s:%s/api-tests/start-coverage', SWOOLE_TESTING_HOST, SWOOLE_TESTING_PORT));
|
||||
register_shutdown_function(function () use ($httpClient): void {
|
||||
$httpClient->request(
|
||||
'GET',
|
||||
sprintf('http://%s:%s/api-tests/stop-coverage', SWOOLE_TESTING_HOST, SWOOLE_TESTING_PORT),
|
||||
);
|
||||
});
|
||||
|
||||
$testHelper->createTestDb();
|
||||
ApiTest\ApiTestCase::setApiClient($container->get('shlink_test_api_client'));
|
||||
ApiTest\ApiTestCase::setApiClient($httpClient);
|
||||
ApiTest\ApiTestCase::setSeedFixturesCallback(fn () => $testHelper->seedFixtures($em, $config['data_fixtures'] ?? []));
|
||||
|
||||
8
config/test/constants.php
Normal file
8
config/test/constants.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink;
|
||||
|
||||
const SWOOLE_TESTING_HOST = '127.0.0.1';
|
||||
const SWOOLE_TESTING_PORT = 9999;
|
||||
@@ -6,15 +6,33 @@ namespace Shlinkio\Shlink;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use Laminas\ConfigAggregator\ConfigAggregator;
|
||||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use Laminas\Stdlib\Glob;
|
||||
use PDO;
|
||||
use PHPUnit\Runner\Version;
|
||||
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
||||
use SebastianBergmann\CodeCoverage\Driver\Selector;
|
||||
use SebastianBergmann\CodeCoverage\Filter;
|
||||
use SebastianBergmann\CodeCoverage\Report\PHP;
|
||||
use SebastianBergmann\CodeCoverage\Report\Xml\Facade as Xml;
|
||||
|
||||
use function Laminas\Stratigility\middleware;
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
use function sprintf;
|
||||
use function sys_get_temp_dir;
|
||||
|
||||
$swooleTestingHost = '127.0.0.1';
|
||||
$swooleTestingPort = 9999;
|
||||
use const ShlinkioTest\Shlink\SWOOLE_TESTING_HOST;
|
||||
use const ShlinkioTest\Shlink\SWOOLE_TESTING_PORT;
|
||||
|
||||
$isApiTest = env('TEST_ENV') === 'api';
|
||||
if ($isApiTest) {
|
||||
$filter = new Filter();
|
||||
foreach (Glob::glob(__DIR__ . '/../../module/*/src') as $item) {
|
||||
$filter->includeDirectory($item);
|
||||
}
|
||||
$coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter);
|
||||
}
|
||||
|
||||
$buildDbConnection = function (): array {
|
||||
$driver = env('DB_DRIVER', 'sqlite');
|
||||
@@ -78,8 +96,8 @@ return [
|
||||
'mezzio-swoole' => [
|
||||
'enable_coroutine' => false,
|
||||
'swoole-http-server' => [
|
||||
'host' => $swooleTestingHost,
|
||||
'port' => $swooleTestingPort,
|
||||
'host' => SWOOLE_TESTING_HOST,
|
||||
'port' => SWOOLE_TESTING_PORT,
|
||||
'process-name' => 'shlink_test',
|
||||
'options' => [
|
||||
'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid',
|
||||
@@ -88,6 +106,35 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'routes' => !$isApiTest ? [] : [
|
||||
[
|
||||
'name' => 'start_collecting_coverage',
|
||||
'path' => '/api-tests/start-coverage',
|
||||
'middleware' => middleware(static function () use (&$coverage) {
|
||||
if ($coverage) {
|
||||
$coverage->start('API tests');
|
||||
}
|
||||
return new EmptyResponse();
|
||||
}),
|
||||
'allowed_methods' => ['GET'],
|
||||
],
|
||||
[
|
||||
'name' => 'dump_coverage',
|
||||
'path' => '/api-tests/stop-coverage',
|
||||
'middleware' => middleware(static function () use (&$coverage) {
|
||||
if ($coverage) {
|
||||
$basePath = __DIR__ . '/../../build/coverage-api';
|
||||
$coverage->stop();
|
||||
(new PHP())->process($coverage, $basePath . '.cov');
|
||||
(new Xml(Version::getVersionString()))->process($coverage, $basePath . '/coverage-xml');
|
||||
}
|
||||
|
||||
return new EmptyResponse();
|
||||
}),
|
||||
'allowed_methods' => ['GET'],
|
||||
],
|
||||
],
|
||||
|
||||
'mercure' => [
|
||||
'public_hub_url' => null,
|
||||
'internal_hub_url' => null,
|
||||
@@ -97,7 +144,7 @@ return [
|
||||
'dependencies' => [
|
||||
'services' => [
|
||||
'shlink_test_api_client' => new Client([
|
||||
'base_uri' => sprintf('http://%s:%s/', $swooleTestingHost, $swooleTestingPort),
|
||||
'base_uri' => sprintf('http://%s:%s/', SWOOLE_TESTING_HOST, SWOOLE_TESTING_PORT),
|
||||
'http_errors' => false,
|
||||
]),
|
||||
],
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
FROM php:7.4.5-fpm-alpine3.11
|
||||
FROM php:7.4.11-alpine3.12
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.18
|
||||
ENV APCU_BC_VERSION 1.0.5
|
||||
ENV XDEBUG_VERSION 2.9.0
|
||||
|
||||
RUN apk update
|
||||
|
||||
@@ -31,6 +30,9 @@ RUN docker-php-ext-install gd
|
||||
RUN apk add --no-cache postgresql-dev
|
||||
RUN docker-php-ext-install pdo_pgsql
|
||||
|
||||
RUN apk add --no-cache gmp-dev
|
||||
RUN docker-php-ext-install gmp
|
||||
|
||||
# Install APCu extension
|
||||
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
|
||||
RUN mkdir -p /usr/src/php/ext/apcu\
|
||||
@@ -55,29 +57,17 @@ RUN rm /tmp/apcu_bc.tar.gz
|
||||
RUN rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini
|
||||
RUN echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
|
||||
|
||||
# Install xdebug
|
||||
ADD https://pecl.php.net/get/xdebug-$XDEBUG_VERSION /tmp/xdebug.tar.gz
|
||||
RUN mkdir -p /usr/src/php/ext/xdebug\
|
||||
&& tar xf /tmp/xdebug.tar.gz -C /usr/src/php/ext/xdebug --strip-components=1
|
||||
# configure and install
|
||||
RUN docker-php-ext-configure xdebug\
|
||||
&& docker-php-ext-install xdebug
|
||||
# cleanup
|
||||
RUN rm /tmp/xdebug.tar.gz
|
||||
|
||||
# Install sqlsrv driver
|
||||
# Install pcov and sqlsrv driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install pdo_sqlsrv && \
|
||||
docker-php-ext-enable pdo_sqlsrv && \
|
||||
pecl install pdo_sqlsrv pcov && \
|
||||
docker-php-ext-enable pdo_sqlsrv pcov && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk
|
||||
|
||||
# Install composer
|
||||
RUN php -r "readfile('https://getcomposer.org/installer');" | php
|
||||
RUN chmod +x composer.phar
|
||||
RUN mv composer.phar /usr/local/bin/composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
|
||||
# Make home directory writable by anyone
|
||||
RUN chmod 777 /home
|
||||
|
||||
@@ -4,3 +4,5 @@ memory_limit=-1
|
||||
log_errors_max_len=0
|
||||
zend.assertions=1
|
||||
assert.exception=1
|
||||
pcov.enabled=1
|
||||
pcov.directory=module
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
FROM php:7.4.5-alpine3.11
|
||||
FROM php:7.4.11-alpine3.12
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.18
|
||||
ENV APCU_BC_VERSION 1.0.5
|
||||
ENV INOTIFY_VERSION 2.0.0
|
||||
ENV SWOOLE_VERSION 4.4.18
|
||||
ENV SWOOLE_VERSION 4.5.5
|
||||
|
||||
RUN apk update
|
||||
|
||||
@@ -32,6 +32,9 @@ RUN docker-php-ext-install gd
|
||||
RUN apk add --no-cache postgresql-dev
|
||||
RUN docker-php-ext-install pdo_pgsql
|
||||
|
||||
RUN apk add --no-cache gmp-dev
|
||||
RUN docker-php-ext-install gmp
|
||||
|
||||
# Install APCu extension
|
||||
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
|
||||
RUN mkdir -p /usr/src/php/ext/apcu\
|
||||
@@ -66,19 +69,17 @@ RUN docker-php-ext-configure inotify\
|
||||
# cleanup
|
||||
RUN rm /tmp/inotify.tar.gz
|
||||
|
||||
# Install swoole and mssql driver
|
||||
# Install swoole, pcov and mssql driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \
|
||||
docker-php-ext-enable swoole pdo_sqlsrv && \
|
||||
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv pcov && \
|
||||
docker-php-ext-enable swoole pdo_sqlsrv pcov && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk
|
||||
|
||||
# Install composer
|
||||
RUN php -r "readfile('https://getcomposer.org/installer');" | php
|
||||
RUN chmod +x composer.phar
|
||||
RUN mv composer.phar /usr/local/bin/composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
|
||||
# Make home directory writable by anyone
|
||||
RUN chmod 777 /home
|
||||
|
||||
@@ -24,10 +24,10 @@ class Version20171021093246 extends AbstractMigration
|
||||
return;
|
||||
}
|
||||
|
||||
$shortUrls->addColumn('valid_since', Types::DATETIME, [
|
||||
$shortUrls->addColumn('valid_since', Types::DATETIME_MUTABLE, [
|
||||
'notnull' => false,
|
||||
]);
|
||||
$shortUrls->addColumn('valid_until', Types::DATETIME, [
|
||||
$shortUrls->addColumn('valid_until', Types::DATETIME_MUTABLE, [
|
||||
'notnull' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
44
data/migrations/Version20201023090929.php
Normal file
44
data/migrations/Version20201023090929.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20201023090929 extends AbstractMigration
|
||||
{
|
||||
private const IMPORT_SOURCE_COLUMN = 'import_source';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$shortUrls = $schema->getTable('short_urls');
|
||||
$this->skipIf($shortUrls->hasColumn(self::IMPORT_SOURCE_COLUMN));
|
||||
|
||||
$shortUrls->addColumn(self::IMPORT_SOURCE_COLUMN, Types::STRING, [
|
||||
'length' => 255,
|
||||
'notnull' => false,
|
||||
]);
|
||||
$shortUrls->addColumn('import_original_short_code', Types::STRING, [
|
||||
'length' => 255,
|
||||
'notnull' => false,
|
||||
]);
|
||||
|
||||
$shortUrls->addUniqueIndex(
|
||||
[self::IMPORT_SOURCE_COLUMN, 'import_original_short_code', 'domain_id'],
|
||||
'unique_imports',
|
||||
);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$shortUrls = $schema->getTable('short_urls');
|
||||
$this->skipIf(! $shortUrls->hasColumn(self::IMPORT_SOURCE_COLUMN));
|
||||
|
||||
$shortUrls->dropColumn(self::IMPORT_SOURCE_COLUMN);
|
||||
$shortUrls->dropColumn('import_original_short_code');
|
||||
$shortUrls->dropIndex('unique_imports');
|
||||
}
|
||||
}
|
||||
86
data/migrations/Version20201102113208.php
Normal file
86
data/migrations/Version20201102113208.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Doctrine\DBAL\Driver\Result;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20201102113208 extends AbstractMigration
|
||||
{
|
||||
private const API_KEY_COLUMN = 'author_api_key_id';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$shortUrls = $schema->getTable('short_urls');
|
||||
$this->skipIf($shortUrls->hasColumn(self::API_KEY_COLUMN));
|
||||
|
||||
$shortUrls->addColumn(self::API_KEY_COLUMN, Types::BIGINT, [
|
||||
'unsigned' => true,
|
||||
'notnull' => false,
|
||||
]);
|
||||
|
||||
$shortUrls->addForeignKeyConstraint('api_keys', [self::API_KEY_COLUMN], ['id'], [
|
||||
'onDelete' => 'SET NULL',
|
||||
'onUpdate' => 'RESTRICT',
|
||||
], 'FK_' . self::API_KEY_COLUMN);
|
||||
}
|
||||
|
||||
public function postUp(Schema $schema): void
|
||||
{
|
||||
// If there's only one API key and it's active, link all existing URLs with it
|
||||
$qb = $this->connection->createQueryBuilder();
|
||||
$qb->select('id')
|
||||
->from('api_keys')
|
||||
->where($qb->expr()->eq('enabled', ':enabled'))
|
||||
->andWhere($qb->expr()->or(
|
||||
$qb->expr()->isNull('expiration_date'),
|
||||
$qb->expr()->gt('expiration_date', ':expiration'),
|
||||
))
|
||||
->setParameters([
|
||||
'enabled' => true,
|
||||
'expiration' => Chronos::now()->toDateTimeString(),
|
||||
]);
|
||||
|
||||
/** @var Result $result */
|
||||
$result = $qb->execute();
|
||||
$id = $this->resolveOneApiKeyId($result);
|
||||
if ($id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$qb = $this->connection->createQueryBuilder();
|
||||
$qb->update('short_urls')
|
||||
->set(self::API_KEY_COLUMN, ':apiKeyId')
|
||||
->setParameter('apiKeyId', $id)
|
||||
->execute();
|
||||
}
|
||||
|
||||
private function resolveOneApiKeyId(Result $result): ?string
|
||||
{
|
||||
$results = [];
|
||||
while ($row = $result->fetchAssociative()) {
|
||||
// As soon as we have to iterate more than once, then we cannot resolve a single API key
|
||||
if (! empty($results)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$results[] = $row['id'] ?? null;
|
||||
}
|
||||
|
||||
return $results[0] ?? null;
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$shortUrls = $schema->getTable('short_urls');
|
||||
$this->skipIf(! $shortUrls->hasColumn(self::API_KEY_COLUMN));
|
||||
|
||||
$shortUrls->removeForeignKey('FK_' . self::API_KEY_COLUMN);
|
||||
$shortUrls->dropColumn(self::API_KEY_COLUMN);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ namespace <namespace>;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version<version> extends AbstractMigration
|
||||
final class <className> extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
|
||||
@@ -174,15 +174,19 @@ This is the complete list of supported env vars:
|
||||
* `MERCURE_INTERNAL_HUB_URL`: An internal URL for a mercure hub. Will be used only when publishing updates to mercure, and does not need to be public. If this is not provided but `MERCURE_PUBLIC_HUB_URL` was, the former one will be used to publish updates.
|
||||
* `MERCURE_JWT_SECRET`: The secret key that was provided to the mercure hub server, in order to be able to generate valid JWTs for publishing/subscribing to that server.
|
||||
* `ANONYMIZE_REMOTE_ADDR`: Tells if IP addresses from visitors should be obfuscated before storing them in the database. Default value is `true`. **Careful!** Setting this to `false` will make your Shlink instance no longer be in compliance with the GDPR and other similar data protection regulations.
|
||||
* `REDIRECT_STATUS_CODE`: Either **301** or **302**. Used to determine if redirects from short to long URLs should be done with a 301 or 302 status. Defaults to 302.
|
||||
* `REDIRECT_CACHE_LIFETIME`: Allows to set the amount of seconds that redirects should be cached when redirect status is 301. Default values is 30.
|
||||
* `PORT`: Can be used to set the port in which shlink listens. Defaults to 8080 (Some cloud providers, like Google cloud or Heroku, expect to be able to customize exposed port by providing this env var).
|
||||
|
||||
An example using all env vars could look like this:
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
--name shlink \
|
||||
-p 8080:8080 \
|
||||
-p 8080:8888 \
|
||||
-e SHORT_DOMAIN_HOST=doma.in \
|
||||
-e SHORT_DOMAIN_SCHEMA=https \
|
||||
-e PORT=8888 \
|
||||
-e DB_DRIVER=mysql \
|
||||
-e DB_NAME=shlink \
|
||||
-e DB_USER=root \
|
||||
@@ -206,6 +210,8 @@ docker run \
|
||||
-e "MERCURE_INTERNAL_HUB_URL=http://my-mercure-hub.prod.svc.cluster.local" \
|
||||
-e MERCURE_JWT_SECRET=super_secret_key \
|
||||
-e ANONYMIZE_REMOTE_ADDR=false \
|
||||
-e REDIRECT_STATUS_CODE=301 \
|
||||
-e REDIRECT_CACHE_LIFETIME=90 \
|
||||
shlinkio/shlink:stable
|
||||
```
|
||||
|
||||
@@ -251,7 +257,10 @@ The whole configuration should have this format, but it can be split into multip
|
||||
"mercure_public_hub_url": "https://example.com",
|
||||
"mercure_internal_hub_url": "http://my-mercure-hub.prod.svc.cluster.local",
|
||||
"mercure_jwt_secret": "super_secret_key",
|
||||
"anonymize_remote_addr": false
|
||||
"anonymize_remote_addr": false,
|
||||
"redirect_status_code": 301,
|
||||
"redirect_cache_lifetime": 90,
|
||||
"port": 8888
|
||||
}
|
||||
```
|
||||
|
||||
@@ -263,7 +272,13 @@ Once created just run shlink with the volume:
|
||||
docker run --name shlink -p 8080:8080 -v ${PWD}/my/config/dir:/etc/shlink/config/params shlinkio/shlink:stable
|
||||
```
|
||||
|
||||
## Multi instance considerations
|
||||
## Multi-architecture
|
||||
|
||||
Starting on v2.3.0, Shlink's docker image is built for multiple architectures.
|
||||
|
||||
The only limitation is that images for architectures other than `amd64` will not have support for Microsoft SQL databases, since there are no official binaries.
|
||||
|
||||
## Multi-instance considerations
|
||||
|
||||
These are some considerations to take into account when running multiple instances of shlink.
|
||||
|
||||
@@ -279,6 +294,6 @@ Versioning on this docker image works as follows:
|
||||
|
||||
* `X.X.X`: when providing a specific version number, the image version will match the shlink version it contains. For example, installing `shlinkio/shlink:1.15.0`, you will get an image containing shlink v1.15.0.
|
||||
* `stable`: always holds the latest stable tag. For example, if latest shlink version is 2.0.0, installing `shlinkio/shlink:stable`, you will get an image containing shlink v2.0.0
|
||||
* `latest`: always holds the latest contents in master, and it's considered unstable and not suitable for production.
|
||||
* `latest`: always holds the latest contents, and it's considered unstable and not suitable for production.
|
||||
|
||||
> **Important**: The docker image was introduced with shlink v1.15.0, so there are no official images previous to that versions.
|
||||
|
||||
30
docker/build
30
docker/build
@@ -1,17 +1,25 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
|
||||
set -ex
|
||||
|
||||
# If there is a tag, regardless the branch, build that docker tag and also "stable"
|
||||
if [[ ! -z $TRAVIS_TAG ]]; then
|
||||
docker build --build-arg SHLINK_VERSION=${TRAVIS_TAG#?} -t shlinkio/shlink:${TRAVIS_TAG#?} -t shlinkio/shlink:stable .
|
||||
docker push shlinkio/shlink:${TRAVIS_TAG#?}
|
||||
PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64"
|
||||
DOCKER_IMAGE="shlinkio/shlink"
|
||||
|
||||
# If ref is not develop, then this is a tag. Build that docker tag and also "stable"
|
||||
if [[ "$GITHUB_REF" != *"develop"* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
TAGS="-t ${DOCKER_IMAGE}:${VERSION}"
|
||||
# Push stable tag only if this is not an alpha or beta tag
|
||||
[[ $TRAVIS_TAG != *"alpha"* && $TRAVIS_TAG != *"beta"* ]] && docker push shlinkio/shlink:stable
|
||||
# If build branch is develop, build latest (on master, when there's no tag, do not build anything)
|
||||
elif [[ "$TRAVIS_BRANCH" == 'develop' ]]; then
|
||||
docker build -t shlinkio/shlink:latest .
|
||||
docker push shlinkio/shlink:latest
|
||||
[[ $GITHUB_REF != *"alpha"* && $GITHUB_REF != *"beta"* ]] && TAGS="${TAGS} -t ${DOCKER_IMAGE}:stable"
|
||||
|
||||
docker buildx build --push \
|
||||
--build-arg SHLINK_VERSION=${VERSION} \
|
||||
--platform ${PLATFORMS} \
|
||||
${TAGS} .
|
||||
|
||||
# If build branch is develop, build latest
|
||||
elif [[ "$GITHUB_REF" == *"develop"* ]]; then
|
||||
docker buildx build --push \
|
||||
--platform ${PLATFORMS} \
|
||||
-t ${DOCKER_IMAGE}:latest .
|
||||
fi
|
||||
|
||||
@@ -11,6 +11,9 @@ use function explode;
|
||||
use function Functional\contains;
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_DELETE_SHORT_URL_THRESHOLD;
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_STATUS_CODE;
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
||||
use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH;
|
||||
|
||||
@@ -104,7 +107,7 @@ return [
|
||||
|
||||
'delete_short_urls' => [
|
||||
'check_visits_threshold' => true,
|
||||
'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', 15),
|
||||
'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', DEFAULT_DELETE_SHORT_URL_THRESHOLD),
|
||||
],
|
||||
|
||||
'entity_manager' => [
|
||||
@@ -120,6 +123,8 @@ return [
|
||||
'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true),
|
||||
'visits_webhooks' => $helper->getVisitsWebhooks(),
|
||||
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
|
||||
'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
|
||||
'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
|
||||
],
|
||||
|
||||
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),
|
||||
@@ -154,6 +159,7 @@ return [
|
||||
|
||||
'mezzio-swoole' => [
|
||||
'swoole-http-server' => [
|
||||
'port' => (int) env('PORT', 8080),
|
||||
'options' => [
|
||||
'worker_num' => (int) env('WEB_WORKER_NUM', 16),
|
||||
'task_worker_num' => (int) env('TASK_WORKER_NUM', 16),
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
{
|
||||
"name": "tags[]",
|
||||
"in": "query",
|
||||
"description": "A list of tags used to filter the resultset. Only short URLs tagged with at least one of the provided tags will be returned. (Since v1.3.0)",
|
||||
"description": "A list of tags used to filter the result set. Only short URLs tagged with at least one of the provided tags will be returned. (Since v1.3.0)",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "array",
|
||||
@@ -48,10 +48,14 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"longUrl",
|
||||
"shortCode",
|
||||
"dateCreated",
|
||||
"visits"
|
||||
"longUrl-ASC",
|
||||
"longUrl-DESC",
|
||||
"shortCode-ASC",
|
||||
"shortCode-DESC",
|
||||
"dateCreated-ASC",
|
||||
"dateCreated-DESC",
|
||||
"visits-ASC",
|
||||
"visits-DESC"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -247,6 +251,10 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"maxVisits": {
|
||||
"description": "The maximum number of allowed visits for this short code",
|
||||
"type": "number"
|
||||
},
|
||||
"validateUrl": {
|
||||
"description": "Tells if the long URL (if provided) should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,12 +87,13 @@
|
||||
},
|
||||
|
||||
"post": {
|
||||
"deprecated": true,
|
||||
"operationId": "createTags",
|
||||
"tags": [
|
||||
"Tags"
|
||||
],
|
||||
"summary": "Create tags",
|
||||
"description": "Provided a list of tags, creates all that do not yet exist",
|
||||
"description": "Provided a list of tags, creates all that do not yet exist<br />This endpoint is deprecated, as tags are automatically created while creating a short URL",
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
|
||||
86
docs/swagger/paths/v2_domains.json
Normal file
86
docs/swagger/paths/v2_domains.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"get": {
|
||||
"operationId": "listDomains",
|
||||
"tags": [
|
||||
"Domains"
|
||||
],
|
||||
"summary": "List existing domains",
|
||||
"description": "Returns the list of all domains ever used, with a flag that tells if they are the default domain",
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The list of tags",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["domains"],
|
||||
"properties": {
|
||||
"domains": {
|
||||
"type": "object",
|
||||
"required": ["data"],
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["domain", "isDefault"],
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"isDefault": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"application/json": {
|
||||
"domains": {
|
||||
"data": [
|
||||
{
|
||||
"domain": "example.com",
|
||||
"isDefault": true
|
||||
},
|
||||
{
|
||||
"domain": "aaa.com",
|
||||
"isDefault": false
|
||||
},
|
||||
{
|
||||
"domain": "bbb.com",
|
||||
"isDefault": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Unexpected error.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,19 @@
|
||||
"maximum": 1000,
|
||||
"default": 300
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "format",
|
||||
"in": "query",
|
||||
"description": "The format for the QR code image, being valid values png and svg. Not providing the param or providing any other value will fall back to png.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"png",
|
||||
"svg"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -38,6 +51,12 @@
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
},
|
||||
"image/svg+xml": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,10 @@
|
||||
"$ref": "paths/v2_tags_{tag}_visits.json"
|
||||
},
|
||||
|
||||
"/rest/v{version}/domains": {
|
||||
"$ref": "paths/v2_domains.json"
|
||||
},
|
||||
|
||||
"/rest/v{version}/mercure-info": {
|
||||
"$ref": "paths/v2_mercure-info.json"
|
||||
},
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'name' => 'ShlinkMigrations',
|
||||
'migrations_namespace' => 'ShlinkMigrations',
|
||||
'table_name' => 'migrations',
|
||||
'migrations_directory' => 'data/migrations',
|
||||
|
||||
'migrations_paths' => [
|
||||
'ShlinkMigrations' => 'data/migrations',
|
||||
],
|
||||
'table_storage' => [
|
||||
'table_name' => 'migrations',
|
||||
],
|
||||
'custom_template' => 'data/migrations_template.txt',
|
||||
|
||||
];
|
||||
|
||||
@@ -25,6 +25,8 @@ return [
|
||||
Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class,
|
||||
Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class,
|
||||
|
||||
Command\Domain\ListDomainsCommand::NAME => Command\Domain\ListDomainsCommand::class,
|
||||
|
||||
Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class,
|
||||
Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class,
|
||||
],
|
||||
|
||||
@@ -10,6 +10,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
|
||||
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainService;
|
||||
use Shlinkio\Shlink\Core\Service;
|
||||
use Shlinkio\Shlink\Core\Tag\TagService;
|
||||
use Shlinkio\Shlink\Core\Visit;
|
||||
@@ -52,6 +53,8 @@ return [
|
||||
|
||||
Command\Db\CreateDatabaseCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Db\MigrateDatabaseCommand::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -84,6 +87,8 @@ return [
|
||||
Command\Tag\RenameTagCommand::class => [TagService::class],
|
||||
Command\Tag\DeleteTagsCommand::class => [TagService::class],
|
||||
|
||||
Command\Domain\ListDomainsCommand::class => [DomainService::class, 'config.url_shortener.domain.hostname'],
|
||||
|
||||
Command\Db\CreateDatabaseCommand::class => [
|
||||
LockFactory::class,
|
||||
SymfonyCli\Helper\ProcessHelper::class,
|
||||
|
||||
49
module/CLI/src/Command/Domain/ListDomainsCommand.php
Normal file
49
module/CLI/src/Command/Domain/ListDomainsCommand.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Domain;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
use function Functional\map;
|
||||
|
||||
class ListDomainsCommand extends Command
|
||||
{
|
||||
public const NAME = 'domain:list';
|
||||
|
||||
private DomainServiceInterface $domainService;
|
||||
private string $defaultDomain;
|
||||
|
||||
public function __construct(DomainServiceInterface $domainService, string $defaultDomain)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->domainService = $domainService;
|
||||
$this->defaultDomain = $defaultDomain;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('List all domains that have been ever used for some short URL');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$regularDomains = $this->domainService->listDomainsWithout($this->defaultDomain);
|
||||
|
||||
ShlinkTable::fromOutput($output)->render(['Domain', 'Is default'], [
|
||||
[$this->defaultDomain, 'Yes'],
|
||||
...map($regularDomains, fn (Domain $domain) => [$domain->getAuthority(), 'No']),
|
||||
]);
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Laminas\Diactoros\Uri;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
@@ -22,7 +21,9 @@ use function array_map;
|
||||
use function Functional\curry;
|
||||
use function Functional\flatten;
|
||||
use function Functional\unique;
|
||||
use function method_exists;
|
||||
use function sprintf;
|
||||
use function strpos;
|
||||
|
||||
class GenerateShortUrlCommand extends Command
|
||||
{
|
||||
@@ -95,6 +96,18 @@ class GenerateShortUrlCommand extends Command
|
||||
'l',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The length for generated short code (it will be ignored if --customSlug was provided).',
|
||||
)
|
||||
->addOption(
|
||||
'validate-url',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Forces the long URL to be validated, regardless what is globally configured.',
|
||||
)
|
||||
->addOption(
|
||||
'no-validate-url',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Forces the long URL to not be validated, regardless what is globally configured.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,21 +139,19 @@ class GenerateShortUrlCommand extends Command
|
||||
$customSlug = $input->getOption('customSlug');
|
||||
$maxVisits = $input->getOption('maxVisits');
|
||||
$shortCodeLength = $input->getOption('shortCodeLength') ?? $this->defaultShortCodeLength;
|
||||
$doValidateUrl = $this->doValidateUrl($input);
|
||||
|
||||
try {
|
||||
$shortUrl = $this->urlShortener->urlToShortCode(
|
||||
new Uri($longUrl),
|
||||
$tags,
|
||||
ShortUrlMeta::fromRawData([
|
||||
ShortUrlMetaInputFilter::VALID_SINCE => $input->getOption('validSince'),
|
||||
ShortUrlMetaInputFilter::VALID_UNTIL => $input->getOption('validUntil'),
|
||||
ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug,
|
||||
ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
|
||||
ShortUrlMetaInputFilter::FIND_IF_EXISTS => $input->getOption('findIfExists'),
|
||||
ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'),
|
||||
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
|
||||
]),
|
||||
);
|
||||
$shortUrl = $this->urlShortener->shorten($longUrl, $tags, ShortUrlMeta::fromRawData([
|
||||
ShortUrlMetaInputFilter::VALID_SINCE => $input->getOption('validSince'),
|
||||
ShortUrlMetaInputFilter::VALID_UNTIL => $input->getOption('validUntil'),
|
||||
ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug,
|
||||
ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
|
||||
ShortUrlMetaInputFilter::FIND_IF_EXISTS => $input->getOption('findIfExists'),
|
||||
ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'),
|
||||
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
|
||||
ShortUrlMetaInputFilter::VALIDATE_URL => $doValidateUrl,
|
||||
]));
|
||||
|
||||
$io->writeln([
|
||||
sprintf('Processed long URL: <info>%s</info>', $longUrl),
|
||||
@@ -152,4 +163,18 @@ class GenerateShortUrlCommand extends Command
|
||||
return ExitCodes::EXIT_FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function doValidateUrl(InputInterface $input): ?bool
|
||||
{
|
||||
$rawInput = method_exists($input, '__toString') ? $input->__toString() : '';
|
||||
|
||||
if (strpos($rawInput, '--no-validate-url') !== false) {
|
||||
return false;
|
||||
}
|
||||
if (strpos($rawInput, '--validate-url') !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
|
||||
@@ -61,7 +60,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
'page',
|
||||
'p',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
sprintf('The first page to list (%s items per page)', ShortUrlRepositoryAdapter::ITEMS_PER_PAGE),
|
||||
'The first page to list (10 items per page unless "--all" is provided)',
|
||||
'1',
|
||||
)
|
||||
->addOption(
|
||||
@@ -82,7 +81,14 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The field from which we want to order by. Pass ASC or DESC separated by a comma',
|
||||
)
|
||||
->addOption('showTags', null, InputOption::VALUE_NONE, 'Whether to display the tags or not');
|
||||
->addOption('showTags', null, InputOption::VALUE_NONE, 'Whether to display the tags or not')
|
||||
->addOption(
|
||||
'all',
|
||||
'a',
|
||||
InputOption::VALUE_NONE,
|
||||
'Disables pagination and just displays all existing URLs. Caution! If the amount of short URLs is big,'
|
||||
. ' this may end up failing due to memory usage.',
|
||||
);
|
||||
}
|
||||
|
||||
protected function getStartDateDesc(): string
|
||||
@@ -104,24 +110,32 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
$tags = $input->getOption('tags');
|
||||
$tags = ! empty($tags) ? explode(',', $tags) : [];
|
||||
$showTags = (bool) $input->getOption('showTags');
|
||||
$all = (bool) $input->getOption('all');
|
||||
$startDate = $this->getDateOption($input, $output, 'startDate');
|
||||
$endDate = $this->getDateOption($input, $output, 'endDate');
|
||||
$orderBy = $this->processOrderBy($input);
|
||||
|
||||
$data = [
|
||||
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
|
||||
ShortUrlsParamsInputFilter::TAGS => $tags,
|
||||
ShortUrlsOrdering::ORDER_BY => $orderBy,
|
||||
ShortUrlsParamsInputFilter::START_DATE => $startDate !== null ? $startDate->toAtomString() : null,
|
||||
ShortUrlsParamsInputFilter::END_DATE => $endDate !== null ? $endDate->toAtomString() : null,
|
||||
];
|
||||
|
||||
if ($all) {
|
||||
$data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = -1;
|
||||
}
|
||||
|
||||
do {
|
||||
$result = $this->renderPage($output, $showTags, ShortUrlsParams::fromRawData([
|
||||
ShortUrlsParamsInputFilter::PAGE => $page,
|
||||
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
|
||||
ShortUrlsParamsInputFilter::TAGS => $tags,
|
||||
ShortUrlsOrdering::ORDER_BY => $orderBy,
|
||||
ShortUrlsParamsInputFilter::START_DATE => $startDate !== null ? $startDate->toAtomString() : null,
|
||||
ShortUrlsParamsInputFilter::END_DATE => $endDate !== null ? $endDate->toAtomString() : null,
|
||||
]));
|
||||
$data[ShortUrlsParamsInputFilter::PAGE] = $page;
|
||||
$result = $this->renderPage($output, $showTags, ShortUrlsParams::fromRawData($data), $all);
|
||||
$page++;
|
||||
|
||||
$continue = $this->isLastPage($result)
|
||||
? false
|
||||
: $io->confirm(sprintf('Continue with page <options=bold>%s</>?', $page), false);
|
||||
$continue = ! $this->isLastPage($result) && $io->confirm(
|
||||
sprintf('Continue with page <options=bold>%s</>?', $page),
|
||||
false,
|
||||
);
|
||||
} while ($continue);
|
||||
|
||||
$io->newLine();
|
||||
@@ -130,7 +144,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
private function renderPage(OutputInterface $output, bool $showTags, ShortUrlsParams $params): Paginator
|
||||
private function renderPage(OutputInterface $output, bool $showTags, ShortUrlsParams $params, bool $all): Paginator
|
||||
{
|
||||
$result = $this->shortUrlService->listShortUrls($params);
|
||||
|
||||
@@ -151,7 +165,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
$rows[] = array_values(array_intersect_key($shortUrl, array_flip(self::COLUMNS_WHITELIST)));
|
||||
}
|
||||
|
||||
ShlinkTable::fromOutput($output)->render($headers, $rows, $this->formatCurrentPageMessage(
|
||||
ShlinkTable::fromOutput($output)->render($headers, $rows, $all ? null : $this->formatCurrentPageMessage(
|
||||
$result,
|
||||
'Page %s of %s',
|
||||
));
|
||||
|
||||
@@ -12,6 +12,7 @@ use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/** @deprecated */
|
||||
class CreateTagCommand extends Command
|
||||
{
|
||||
public const NAME = 'tag:create';
|
||||
@@ -28,7 +29,7 @@ class CreateTagCommand extends Command
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Creates one or more tags.')
|
||||
->setDescription('[Deprecated] Creates one or more tags.')
|
||||
->addOption(
|
||||
'name',
|
||||
't',
|
||||
|
||||
@@ -52,7 +52,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||
}
|
||||
|
||||
$meta = $this->geoLiteDbReader->metadata();
|
||||
if ($this->buildIsTooOld($meta->__get('buildEpoch'))) {
|
||||
if ($this->buildIsTooOld($meta->buildEpoch)) {
|
||||
$this->downloadNewDb(true, $mustBeUpdated, $handleProgress);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Api;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand;
|
||||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||
@@ -14,6 +15,8 @@ use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class DisableKeyCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $apiKeyService;
|
||||
|
||||
@@ -37,7 +40,7 @@ class DisableKeyCommandTest extends TestCase
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('API key "abcd1234" properly disabled', $output);
|
||||
self::assertStringContainsString('API key "abcd1234" properly disabled', $output);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -52,7 +55,7 @@ class DisableKeyCommandTest extends TestCase
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString($expectedMessage, $output);
|
||||
self::assertStringContainsString($expectedMessage, $output);
|
||||
$disable->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Api;
|
||||
use Cake\Chronos\Chronos;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
@@ -16,6 +17,8 @@ use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class GenerateKeyCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $apiKeyService;
|
||||
|
||||
@@ -36,7 +39,7 @@ class GenerateKeyCommandTest extends TestCase
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('Generated API key: ', $output);
|
||||
self::assertStringContainsString('Generated API key: ', $output);
|
||||
$create->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Api;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
@@ -14,6 +15,8 @@ use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class ListKeysCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $apiKeyService;
|
||||
|
||||
@@ -38,11 +41,11 @@ class ListKeysCommandTest extends TestCase
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('Key', $output);
|
||||
$this->assertStringContainsString('Is enabled', $output);
|
||||
$this->assertStringContainsString(' +++ ', $output);
|
||||
$this->assertStringNotContainsString(' --- ', $output);
|
||||
$this->assertStringContainsString('Expiration date', $output);
|
||||
self::assertStringContainsString('Key', $output);
|
||||
self::assertStringContainsString('Is enabled', $output);
|
||||
self::assertStringContainsString(' +++ ', $output);
|
||||
self::assertStringNotContainsString(' --- ', $output);
|
||||
self::assertStringContainsString('Expiration date', $output);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -58,10 +61,10 @@ class ListKeysCommandTest extends TestCase
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('Key', $output);
|
||||
$this->assertStringNotContainsString('Is enabled', $output);
|
||||
$this->assertStringNotContainsString(' +++ ', $output);
|
||||
$this->assertStringNotContainsString(' --- ', $output);
|
||||
$this->assertStringContainsString('Expiration date', $output);
|
||||
self::assertStringContainsString('Key', $output);
|
||||
self::assertStringNotContainsString('Is enabled', $output);
|
||||
self::assertStringNotContainsString(' +++ ', $output);
|
||||
self::assertStringNotContainsString(' --- ', $output);
|
||||
self::assertStringContainsString('Expiration date', $output);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Doctrine\DBAL\Platforms\AbstractPlatform;
|
||||
use Doctrine\DBAL\Schema\AbstractSchemaManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand;
|
||||
use Symfony\Component\Console\Application;
|
||||
@@ -22,10 +23,11 @@ use Symfony\Component\Process\Process;
|
||||
|
||||
class CreateDatabaseCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $processHelper;
|
||||
private ObjectProphecy $regularConn;
|
||||
private ObjectProphecy $noDbNameConn;
|
||||
private ObjectProphecy $schemaManager;
|
||||
private ObjectProphecy $databasePlatform;
|
||||
|
||||
@@ -48,15 +50,15 @@ class CreateDatabaseCommandTest extends TestCase
|
||||
$this->regularConn = $this->prophesize(Connection::class);
|
||||
$this->regularConn->getSchemaManager()->willReturn($this->schemaManager->reveal());
|
||||
$this->regularConn->getDatabasePlatform()->willReturn($this->databasePlatform->reveal());
|
||||
$this->noDbNameConn = $this->prophesize(Connection::class);
|
||||
$this->noDbNameConn->getSchemaManager()->willReturn($this->schemaManager->reveal());
|
||||
$noDbNameConn = $this->prophesize(Connection::class);
|
||||
$noDbNameConn->getSchemaManager()->willReturn($this->schemaManager->reveal());
|
||||
|
||||
$command = new CreateDatabaseCommand(
|
||||
$locker->reveal(),
|
||||
$this->processHelper->reveal(),
|
||||
$phpExecutableFinder->reveal(),
|
||||
$this->regularConn->reveal(),
|
||||
$this->noDbNameConn->reveal(),
|
||||
$noDbNameConn->reveal(),
|
||||
);
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
@@ -77,7 +79,7 @@ class CreateDatabaseCommandTest extends TestCase
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('Database already exists. Run "db:migrate" command', $output);
|
||||
self::assertStringContainsString('Database already exists. Run "db:migrate" command', $output);
|
||||
$getDatabase->shouldHaveBeenCalledOnce();
|
||||
$listDatabases->shouldHaveBeenCalledOnce();
|
||||
$createDatabase->shouldNotHaveBeenCalled();
|
||||
@@ -121,8 +123,8 @@ class CreateDatabaseCommandTest extends TestCase
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('Creating database tables...', $output);
|
||||
$this->assertStringContainsString('Database properly created!', $output);
|
||||
self::assertStringContainsString('Creating database tables...', $output);
|
||||
self::assertStringContainsString('Database properly created!', $output);
|
||||
$getDatabase->shouldHaveBeenCalledOnce();
|
||||
$listDatabases->shouldHaveBeenCalledOnce();
|
||||
$createDatabase->shouldNotHaveBeenCalled();
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Db;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand;
|
||||
use Symfony\Component\Console\Application;
|
||||
@@ -19,6 +20,8 @@ use Symfony\Component\Process\Process;
|
||||
|
||||
class MigrateDatabaseCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $processHelper;
|
||||
|
||||
@@ -60,8 +63,8 @@ class MigrateDatabaseCommandTest extends TestCase
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('Migrating database...', $output);
|
||||
$this->assertStringContainsString('Database properly migrated!', $output);
|
||||
self::assertStringContainsString('Migrating database...', $output);
|
||||
self::assertStringContainsString('Database properly migrated!', $output);
|
||||
$runCommand->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
|
||||
59
module/CLI/test/Command/Domain/ListDomainsCommandTest.php
Normal file
59
module/CLI/test/Command/Domain/ListDomainsCommandTest.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Domain;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class ListDomainsCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $domainService;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->domainService = $this->prophesize(DomainServiceInterface::class);
|
||||
|
||||
$command = new ListDomainsCommand($this->domainService->reveal(), 'foo.com');
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
|
||||
$this->commandTester = new CommandTester($command);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function allDomainsAreProperlyPrinted(): void
|
||||
{
|
||||
$expectedOutput = <<<OUTPUT
|
||||
+---------+------------+
|
||||
| Domain | Is default |
|
||||
+---------+------------+
|
||||
| foo.com | Yes |
|
||||
| bar.com | No |
|
||||
| baz.com | No |
|
||||
+---------+------------+
|
||||
|
||||
OUTPUT;
|
||||
$listDomains = $this->domainService->listDomainsWithout('foo.com')->willReturn([
|
||||
new Domain('bar.com'),
|
||||
new Domain('baz.com'),
|
||||
]);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
|
||||
self::assertEquals($expectedOutput, $this->commandTester->getDisplay());
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
$listDomains->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand;
|
||||
use Shlinkio\Shlink\Core\Exception;
|
||||
@@ -21,6 +22,8 @@ use const PHP_EOL;
|
||||
|
||||
class DeleteShortUrlCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $service;
|
||||
|
||||
@@ -47,7 +50,7 @@ class DeleteShortUrlCommandTest extends TestCase
|
||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString(
|
||||
self::assertStringContainsString(
|
||||
sprintf('Short URL with short code "%s" successfully deleted.', $shortCode),
|
||||
$output,
|
||||
);
|
||||
@@ -66,7 +69,7 @@ class DeleteShortUrlCommandTest extends TestCase
|
||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
|
||||
self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
|
||||
$deleteByShortCode->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
@@ -95,11 +98,11 @@ class DeleteShortUrlCommandTest extends TestCase
|
||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString(sprintf(
|
||||
self::assertStringContainsString(sprintf(
|
||||
'Impossible to delete short URL with short code "%s" since it has more than "10" visits.',
|
||||
$shortCode,
|
||||
), $output);
|
||||
$this->assertStringContainsString($expectedMessage, $output);
|
||||
self::assertStringContainsString($expectedMessage, $output);
|
||||
$deleteByShortCode->shouldHaveBeenCalledTimes($expectedDeleteCalls);
|
||||
}
|
||||
|
||||
@@ -122,11 +125,11 @@ class DeleteShortUrlCommandTest extends TestCase
|
||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString(sprintf(
|
||||
self::assertStringContainsString(sprintf(
|
||||
'Impossible to delete short URL with short code "%s" since it has more than "10" visits.',
|
||||
$shortCode,
|
||||
), $output);
|
||||
$this->assertStringContainsString('Short URL was not deleted.', $output);
|
||||
self::assertStringContainsString('Short URL was not deleted.', $output);
|
||||
$deleteByShortCode->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,19 +7,22 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\GenerateShortUrlCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortener;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class GenerateShortUrlCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private const DOMAIN_CONFIG = [
|
||||
'schema' => 'http',
|
||||
'hostname' => 'foo.com',
|
||||
@@ -41,7 +44,7 @@ class GenerateShortUrlCommandTest extends TestCase
|
||||
public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
$urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willReturn($shortUrl);
|
||||
$urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willReturn($shortUrl);
|
||||
|
||||
$this->commandTester->execute([
|
||||
'longUrl' => 'http://domain.com/foo/bar',
|
||||
@@ -49,8 +52,8 @@ class GenerateShortUrlCommandTest extends TestCase
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
$this->assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output);
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
self::assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output);
|
||||
$urlToShortCode->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
@@ -58,28 +61,28 @@ class GenerateShortUrlCommandTest extends TestCase
|
||||
public function exceptionWhileParsingLongUrlOutputsError(): void
|
||||
{
|
||||
$url = 'http://domain.com/invalid';
|
||||
$this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(InvalidUrlException::fromUrl($url))
|
||||
$this->urlShortener->shorten(Argument::cetera())->willThrow(InvalidUrlException::fromUrl($url))
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
$this->commandTester->execute(['longUrl' => $url]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
|
||||
$this->assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output);
|
||||
self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
|
||||
self::assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function providingNonUniqueSlugOutputsError(): void
|
||||
{
|
||||
$urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(
|
||||
$urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willThrow(
|
||||
NonUniqueSlugException::fromSlug('my-slug'),
|
||||
);
|
||||
|
||||
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--customSlug' => 'my-slug']);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
|
||||
$this->assertStringContainsString('Provided slug "my-slug" is already in use', $output);
|
||||
self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
|
||||
self::assertStringContainsString('Provided slug "my-slug" is already in use', $output);
|
||||
$urlToShortCode->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
@@ -87,8 +90,8 @@ class GenerateShortUrlCommandTest extends TestCase
|
||||
public function properlyProcessesProvidedTags(): void
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
$urlToShortCode = $this->urlShortener->urlToShortCode(
|
||||
Argument::type(UriInterface::class),
|
||||
$urlToShortCode = $this->urlShortener->shorten(
|
||||
Argument::type('string'),
|
||||
Argument::that(function (array $tags) {
|
||||
Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $tags);
|
||||
return $tags;
|
||||
@@ -102,8 +105,38 @@ class GenerateShortUrlCommandTest extends TestCase
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
$this->assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output);
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
self::assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output);
|
||||
$urlToShortCode->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideFlags
|
||||
*/
|
||||
public function urlValidationHasExpectedValueBasedOnProvidedTags(array $options, ?bool $expectedValidateUrl): void
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
$urlToShortCode = $this->urlShortener->shorten(
|
||||
Argument::type('string'),
|
||||
Argument::type('array'),
|
||||
Argument::that(function (ShortUrlMeta $meta) use ($expectedValidateUrl) {
|
||||
Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl());
|
||||
return $meta;
|
||||
}),
|
||||
)->willReturn($shortUrl);
|
||||
|
||||
$options['longUrl'] = 'http://domain.com/foo/bar';
|
||||
$this->commandTester->execute($options);
|
||||
|
||||
$urlToShortCode->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideFlags(): iterable
|
||||
{
|
||||
yield 'no flags' => [[], null];
|
||||
yield 'no-validate-url only' => [['--no-validate-url' => true], false];
|
||||
yield 'validate-url' => [['--validate-url' => true], true];
|
||||
yield 'both flags' => [['--validate-url' => true, '--no-validate-url' => true], false];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Laminas\Paginator\Adapter\ArrayAdapter;
|
||||
use Laminas\Paginator\Paginator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
@@ -27,6 +28,8 @@ use function sprintf;
|
||||
|
||||
class GetVisitsCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $visitsTracker;
|
||||
|
||||
@@ -88,7 +91,7 @@ class GetVisitsCommandTest extends TestCase
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$info->shouldHaveBeenCalledOnce();
|
||||
$this->assertStringContainsString(
|
||||
self::assertStringContainsString(
|
||||
sprintf('Ignored provided "startDate" since its value "%s" is not a valid date', $startDate),
|
||||
$output,
|
||||
);
|
||||
@@ -108,8 +111,8 @@ class GetVisitsCommandTest extends TestCase
|
||||
|
||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('foo', $output);
|
||||
$this->assertStringContainsString('Spain', $output);
|
||||
$this->assertStringContainsString('bar', $output);
|
||||
self::assertStringContainsString('foo', $output);
|
||||
self::assertStringContainsString('Spain', $output);
|
||||
self::assertStringContainsString('bar', $output);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Laminas\Paginator\Adapter\ArrayAdapter;
|
||||
use Laminas\Paginator\Paginator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
@@ -21,6 +22,8 @@ use function explode;
|
||||
|
||||
class ListShortUrlsCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $shortUrlService;
|
||||
|
||||
@@ -50,9 +53,9 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('Continue with page 2?', $output);
|
||||
$this->assertStringContainsString('Continue with page 3?', $output);
|
||||
$this->assertStringContainsString('Continue with page 4?', $output);
|
||||
self::assertStringContainsString('Continue with page 2?', $output);
|
||||
self::assertStringContainsString('Continue with page 3?', $output);
|
||||
self::assertStringContainsString('Continue with page 4?', $output);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -72,13 +75,13 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('url_1', $output);
|
||||
$this->assertStringContainsString('url_9', $output);
|
||||
$this->assertStringNotContainsString('url_10', $output);
|
||||
$this->assertStringNotContainsString('url_20', $output);
|
||||
$this->assertStringNotContainsString('url_30', $output);
|
||||
$this->assertStringContainsString('Continue with page 2?', $output);
|
||||
$this->assertStringNotContainsString('Continue with page 3?', $output);
|
||||
self::assertStringContainsString('url_1', $output);
|
||||
self::assertStringContainsString('url_9', $output);
|
||||
self::assertStringNotContainsString('url_10', $output);
|
||||
self::assertStringNotContainsString('url_20', $output);
|
||||
self::assertStringNotContainsString('url_30', $output);
|
||||
self::assertStringContainsString('Continue with page 2?', $output);
|
||||
self::assertStringNotContainsString('Continue with page 3?', $output);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -103,7 +106,7 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
$this->commandTester->setInputs(['y']);
|
||||
$this->commandTester->execute(['--showTags' => true]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('Tags', $output);
|
||||
self::assertStringContainsString('Tags', $output);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,4 +195,22 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
yield [['--orderBy' => 'foo,ASC'], ['foo' => 'ASC']];
|
||||
yield [['--orderBy' => 'bar,DESC'], ['bar' => 'DESC']];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function requestingAllElementsWillSetItemsPerPage(): void
|
||||
{
|
||||
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
|
||||
'page' => 1,
|
||||
'searchTerm' => null,
|
||||
'tags' => [],
|
||||
'startDate' => null,
|
||||
'endDate' => null,
|
||||
'orderBy' => null,
|
||||
'itemsPerPage' => -1,
|
||||
]))->willReturn(new Paginator(new ArrayAdapter()));
|
||||
|
||||
$this->commandTester->execute(['--all' => true]);
|
||||
|
||||
$listShortUrls->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
@@ -20,6 +21,8 @@ use const PHP_EOL;
|
||||
|
||||
class ResolveUrlCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $urlResolver;
|
||||
|
||||
@@ -44,7 +47,7 @@ class ResolveUrlCommandTest extends TestCase
|
||||
|
||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output);
|
||||
self::assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -59,6 +62,6 @@ class ResolveUrlCommandTest extends TestCase
|
||||
|
||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
|
||||
self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Tag;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Tag\CreateTagCommand;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
@@ -14,6 +15,8 @@ use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class CreateTagCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $tagService;
|
||||
|
||||
@@ -34,7 +37,7 @@ class CreateTagCommandTest extends TestCase
|
||||
$this->commandTester->execute([]);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('You have to provide at least one tag name', $output);
|
||||
self::assertStringContainsString('You have to provide at least one tag name', $output);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -48,7 +51,7 @@ class CreateTagCommandTest extends TestCase
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('Tags properly created', $output);
|
||||
self::assertStringContainsString('Tags properly created', $output);
|
||||
$createTags->shouldHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
@@ -13,6 +14,8 @@ use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class DeleteTagsCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $tagService;
|
||||
|
||||
@@ -33,7 +36,7 @@ class DeleteTagsCommandTest extends TestCase
|
||||
$this->commandTester->execute([]);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('You have to provide at least one tag name', $output);
|
||||
self::assertStringContainsString('You have to provide at least one tag name', $output);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -48,7 +51,7 @@ class DeleteTagsCommandTest extends TestCase
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('Tags properly deleted', $output);
|
||||
self::assertStringContainsString('Tags properly deleted', $output);
|
||||
$deleteTags->shouldHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
@@ -15,6 +16,8 @@ use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class ListTagsCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $tagService;
|
||||
|
||||
@@ -37,7 +40,7 @@ class ListTagsCommandTest extends TestCase
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('No tags found', $output);
|
||||
self::assertStringContainsString('No tags found', $output);
|
||||
$tagsInfo->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
@@ -52,12 +55,12 @@ class ListTagsCommandTest extends TestCase
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('| foo', $output);
|
||||
$this->assertStringContainsString('| bar', $output);
|
||||
$this->assertStringContainsString('| 10 ', $output);
|
||||
$this->assertStringContainsString('| 2 ', $output);
|
||||
$this->assertStringContainsString('| 7 ', $output);
|
||||
$this->assertStringContainsString('| 32 ', $output);
|
||||
self::assertStringContainsString('| foo', $output);
|
||||
self::assertStringContainsString('| bar', $output);
|
||||
self::assertStringContainsString('| 10 ', $output);
|
||||
self::assertStringContainsString('| 2 ', $output);
|
||||
self::assertStringContainsString('| 7 ', $output);
|
||||
self::assertStringContainsString('| 32 ', $output);
|
||||
$tagsInfo->shouldHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
@@ -15,6 +16,8 @@ use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class RenameTagCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $tagService;
|
||||
|
||||
@@ -42,7 +45,7 @@ class RenameTagCommandTest extends TestCase
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('Tag with name "foo" could not be found', $output);
|
||||
self::assertStringContainsString('Tag with name "foo" could not be found', $output);
|
||||
$renameTag->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
@@ -59,7 +62,7 @@ class RenameTagCommandTest extends TestCase
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('Tag properly renamed', $output);
|
||||
self::assertStringContainsString('Tag properly renamed', $output);
|
||||
$renameTag->shouldHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Visit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
@@ -32,10 +33,11 @@ use const PHP_EOL;
|
||||
|
||||
class LocateVisitsCommandTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $visitService;
|
||||
private ObjectProphecy $ipResolver;
|
||||
private ObjectProphecy $locker;
|
||||
private ObjectProphecy $lock;
|
||||
private ObjectProphecy $dbUpdater;
|
||||
|
||||
@@ -45,17 +47,17 @@ class LocateVisitsCommandTest extends TestCase
|
||||
$this->ipResolver = $this->prophesize(IpLocationResolverInterface::class);
|
||||
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
|
||||
|
||||
$this->locker = $this->prophesize(Lock\LockFactory::class);
|
||||
$locker = $this->prophesize(Lock\LockFactory::class);
|
||||
$this->lock = $this->prophesize(Lock\LockInterface::class);
|
||||
$this->lock->acquire(false)->willReturn(true);
|
||||
$this->lock->release()->will(function (): void {
|
||||
});
|
||||
$this->locker->createLock(Argument::type('string'), 90.0, false)->willReturn($this->lock->reveal());
|
||||
$locker->createLock(Argument::type('string'), 90.0, false)->willReturn($this->lock->reveal());
|
||||
|
||||
$command = new LocateVisitsCommand(
|
||||
$this->visitService->reveal(),
|
||||
$this->ipResolver->reveal(),
|
||||
$this->locker->reveal(),
|
||||
$locker->reveal(),
|
||||
$this->dbUpdater->reveal(),
|
||||
);
|
||||
$app = new Application();
|
||||
@@ -92,11 +94,11 @@ class LocateVisitsCommandTest extends TestCase
|
||||
$this->commandTester->execute($args);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('Processing IP 1.2.3.0', $output);
|
||||
self::assertStringContainsString('Processing IP 1.2.3.0', $output);
|
||||
if ($expectWarningPrint) {
|
||||
$this->assertStringContainsString('Continue at your own risk', $output);
|
||||
self::assertStringContainsString('Continue at your own', $output);
|
||||
} else {
|
||||
$this->assertStringNotContainsString('Continue at your own risk', $output);
|
||||
self::assertStringNotContainsString('Continue at your own', $output);
|
||||
}
|
||||
$locateVisits->shouldHaveBeenCalledTimes($expectedUnlocatedCalls);
|
||||
$locateEmptyVisits->shouldHaveBeenCalledTimes($expectedEmptyCalls);
|
||||
@@ -132,11 +134,11 @@ class LocateVisitsCommandTest extends TestCase
|
||||
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString($message, $output);
|
||||
self::assertStringContainsString($message, $output);
|
||||
if (empty($address)) {
|
||||
$this->assertStringNotContainsString('Processing IP', $output);
|
||||
self::assertStringNotContainsString('Processing IP', $output);
|
||||
} else {
|
||||
$this->assertStringContainsString('Processing IP', $output);
|
||||
self::assertStringContainsString('Processing IP', $output);
|
||||
}
|
||||
$locateVisits->shouldHaveBeenCalledOnce();
|
||||
$resolveIpLocation->shouldNotHaveBeenCalled();
|
||||
@@ -164,7 +166,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('An error occurred while locating IP. Skipped', $output);
|
||||
self::assertStringContainsString('An error occurred while locating IP. Skipped', $output);
|
||||
$locateVisits->shouldHaveBeenCalledOnce();
|
||||
$resolveIpLocation->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
@@ -192,7 +194,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString(
|
||||
self::assertStringContainsString(
|
||||
sprintf('Command "%s" is already in progress. Skipping.', LocateVisitsCommand::NAME),
|
||||
$output,
|
||||
);
|
||||
@@ -222,11 +224,11 @@ class LocateVisitsCommandTest extends TestCase
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString(
|
||||
self::assertStringContainsString(
|
||||
sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'),
|
||||
$output,
|
||||
);
|
||||
$this->assertStringContainsString($expectedMessage, $output);
|
||||
self::assertStringContainsString($expectedMessage, $output);
|
||||
$locateVisits->shouldHaveBeenCalledTimes((int) $olderDbExists);
|
||||
$checkDbUpdate->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
@@ -243,7 +245,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
$this->commandTester->execute(['--all' => true]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('The --all flag has no effect on its own', $output);
|
||||
self::assertStringContainsString('The --all flag has no effect on its own', $output);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,11 +17,11 @@ class ConfigProviderTest extends TestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function confiIsProperlyReturned(): void
|
||||
public function configIsProperlyReturned(): void
|
||||
{
|
||||
$config = ($this->configProvider)();
|
||||
|
||||
$this->assertArrayHasKey('cli', $config);
|
||||
$this->assertArrayHasKey('dependencies', $config);
|
||||
self::assertArrayHasKey('cli', $config);
|
||||
self::assertArrayHasKey('dependencies', $config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,13 +20,13 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase
|
||||
{
|
||||
$e = GeolocationDbUpdateFailedException::create($olderDbExists, $prev);
|
||||
|
||||
$this->assertEquals($olderDbExists, $e->olderDbExists());
|
||||
$this->assertEquals(
|
||||
self::assertEquals($olderDbExists, $e->olderDbExists());
|
||||
self::assertEquals(
|
||||
'An error occurred while updating geolocation database, and an older version could not be found',
|
||||
$e->getMessage(),
|
||||
);
|
||||
$this->assertEquals(0, $e->getCode());
|
||||
$this->assertEquals($prev, $e->getPrevious());
|
||||
self::assertEquals(0, $e->getCode());
|
||||
self::assertEquals($prev, $e->getPrevious());
|
||||
}
|
||||
|
||||
public function provideCreateArgs(): iterable
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\CLI\Factory;
|
||||
use Laminas\ServiceManager\ServiceManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Factory\ApplicationFactory;
|
||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||
@@ -15,6 +16,8 @@ use Symfony\Component\Console\Command\Command;
|
||||
|
||||
class ApplicationFactoryTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private ApplicationFactory $factory;
|
||||
|
||||
public function setUp(): void
|
||||
@@ -37,9 +40,9 @@ class ApplicationFactoryTest extends TestCase
|
||||
|
||||
$instance = ($this->factory)($sm);
|
||||
|
||||
$this->assertTrue($instance->has('foo'));
|
||||
$this->assertTrue($instance->has('bar'));
|
||||
$this->assertFalse($instance->has('baz'));
|
||||
self::assertTrue($instance->has('foo'));
|
||||
self::assertTrue($instance->has('bar'));
|
||||
self::assertFalse($instance->has('baz'));
|
||||
}
|
||||
|
||||
private function createServiceManager(array $config = []): ServiceManager
|
||||
|
||||
@@ -9,6 +9,7 @@ use GeoIp2\Database\Reader;
|
||||
use MaxMind\Db\Reader\Metadata;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
|
||||
@@ -22,35 +23,35 @@ use function range;
|
||||
|
||||
class GeolocationDbUpdaterTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private GeolocationDbUpdater $geolocationDbUpdater;
|
||||
private ObjectProphecy $dbUpdater;
|
||||
private ObjectProphecy $geoLiteDbReader;
|
||||
private ObjectProphecy $locker;
|
||||
private ObjectProphecy $lock;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->dbUpdater = $this->prophesize(DbUpdaterInterface::class);
|
||||
$this->geoLiteDbReader = $this->prophesize(Reader::class);
|
||||
|
||||
$this->locker = $this->prophesize(Lock\LockFactory::class);
|
||||
$this->lock = $this->prophesize(Lock\LockInterface::class);
|
||||
$this->lock->acquire(true)->willReturn(true);
|
||||
$this->lock->release()->will(function (): void {
|
||||
$locker = $this->prophesize(Lock\LockFactory::class);
|
||||
$lock = $this->prophesize(Lock\LockInterface::class);
|
||||
$lock->acquire(true)->willReturn(true);
|
||||
$lock->release()->will(function (): void {
|
||||
});
|
||||
$this->locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal());
|
||||
$locker->createLock(Argument::type('string'))->willReturn($lock->reveal());
|
||||
|
||||
$this->geolocationDbUpdater = new GeolocationDbUpdater(
|
||||
$this->dbUpdater->reveal(),
|
||||
$this->geoLiteDbReader->reveal(),
|
||||
$this->locker->reveal(),
|
||||
$locker->reveal(),
|
||||
);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function exceptionIsThrownWhenOlderDbDoesNotExistAndDownloadFails(): void
|
||||
{
|
||||
$mustBeUpdated = fn () => $this->assertTrue(true);
|
||||
$mustBeUpdated = fn () => self::assertTrue(true);
|
||||
$prev = new RuntimeException('');
|
||||
|
||||
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(false);
|
||||
@@ -59,12 +60,12 @@ class GeolocationDbUpdaterTest extends TestCase
|
||||
|
||||
try {
|
||||
$this->geolocationDbUpdater->checkDbUpdate($mustBeUpdated);
|
||||
$this->assertTrue(false); // If this is reached, the test will fail
|
||||
self::assertTrue(false); // If this is reached, the test will fail
|
||||
} catch (Throwable $e) {
|
||||
/** @var GeolocationDbUpdateFailedException $e */
|
||||
$this->assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
|
||||
$this->assertSame($prev, $e->getPrevious());
|
||||
$this->assertFalse($e->olderDbExists());
|
||||
self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
|
||||
self::assertSame($prev, $e->getPrevious());
|
||||
self::assertFalse($e->olderDbExists());
|
||||
}
|
||||
|
||||
$fileExists->shouldHaveBeenCalledOnce();
|
||||
@@ -95,12 +96,12 @@ class GeolocationDbUpdaterTest extends TestCase
|
||||
|
||||
try {
|
||||
$this->geolocationDbUpdater->checkDbUpdate();
|
||||
$this->assertTrue(false); // If this is reached, the test will fail
|
||||
self::assertTrue(false); // If this is reached, the test will fail
|
||||
} catch (Throwable $e) {
|
||||
/** @var GeolocationDbUpdateFailedException $e */
|
||||
$this->assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
|
||||
$this->assertSame($prev, $e->getPrevious());
|
||||
$this->assertTrue($e->olderDbExists());
|
||||
self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
|
||||
self::assertSame($prev, $e->getPrevious());
|
||||
self::assertTrue($e->olderDbExists());
|
||||
}
|
||||
|
||||
$fileExists->shouldHaveBeenCalledOnce();
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Util;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use ReflectionObject;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
@@ -15,6 +16,8 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class ShlinkTableTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private ShlinkTable $shlinkTable;
|
||||
private ObjectProphecy $baseTable;
|
||||
|
||||
@@ -60,6 +63,6 @@ class ShlinkTableTest extends TestCase
|
||||
$baseTable = $ref->getProperty('baseTable');
|
||||
$baseTable->setAccessible(true);
|
||||
|
||||
$this->assertInstanceOf(Table::class, $baseTable->getValue($instance));
|
||||
self::assertInstanceOf(Table::class, $baseTable->getValue($instance));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ namespace Shlinkio\Shlink\Core;
|
||||
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Mezzio\Template\TemplateRendererInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler;
|
||||
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
||||
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
|
||||
|
||||
return [
|
||||
|
||||
@@ -31,16 +31,25 @@ return [
|
||||
Tag\TagService::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrl\ShortCodeHelper::class => ConfigAbstractFactory::class,
|
||||
Domain\DomainService::class => ConfigAbstractFactory::class,
|
||||
|
||||
Util\UrlValidator::class => ConfigAbstractFactory::class,
|
||||
Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class,
|
||||
|
||||
Action\RedirectAction::class => ConfigAbstractFactory::class,
|
||||
Action\PixelAction::class => ConfigAbstractFactory::class,
|
||||
Action\QrCodeAction::class => ConfigAbstractFactory::class,
|
||||
|
||||
Resolver\PersistenceDomainResolver::class => ConfigAbstractFactory::class,
|
||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ConfigAbstractFactory::class,
|
||||
|
||||
Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class,
|
||||
|
||||
Importer\ImportedLinksProcessor::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
|
||||
'aliases' => [
|
||||
ImportedLinksProcessorInterface::class => Importer\ImportedLinksProcessor::class,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -53,7 +62,12 @@ return [
|
||||
Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'],
|
||||
Options\UrlShortenerOptions::class => ['config.url_shortener'],
|
||||
|
||||
Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Resolver\PersistenceDomainResolver::class],
|
||||
Service\UrlShortener::class => [
|
||||
Util\UrlValidator::class,
|
||||
'em',
|
||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
|
||||
Service\ShortUrl\ShortCodeHelper::class,
|
||||
],
|
||||
Service\VisitsTracker::class => [
|
||||
'em',
|
||||
EventDispatcherInterface::class,
|
||||
@@ -69,13 +83,17 @@ return [
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
],
|
||||
Service\ShortUrl\ShortUrlResolver::class => ['em'],
|
||||
Service\ShortUrl\ShortCodeHelper::class => ['em'],
|
||||
Domain\DomainService::class => ['em'],
|
||||
|
||||
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
|
||||
Util\DoctrineBatchHelper::class => ['em'],
|
||||
|
||||
Action\RedirectAction::class => [
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
Service\VisitsTracker::class,
|
||||
Options\AppOptions::class,
|
||||
Options\UrlShortenerOptions::class,
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\PixelAction::class => [
|
||||
@@ -90,9 +108,16 @@ return [
|
||||
'Logger_Shlink',
|
||||
],
|
||||
|
||||
Resolver\PersistenceDomainResolver::class => ['em'],
|
||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em'],
|
||||
|
||||
Mercure\MercureUpdatesGenerator::class => ['config.url_shortener.domain'],
|
||||
|
||||
Importer\ImportedLinksProcessor::class => [
|
||||
'em',
|
||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
|
||||
Service\ShortUrl\ShortCodeHelper::class,
|
||||
Util\DoctrineBatchHelper::class,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -11,7 +11,8 @@ use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
|
||||
$builder->setTable(determineTableName('domains', $emConfig));
|
||||
$builder->setTable(determineTableName('domains', $emConfig))
|
||||
->setCustomRepositoryClass(Domain\Repository\DomainRepository::class);
|
||||
|
||||
$builder->createField('id', Types::BIGINT)
|
||||
->columnName('id')
|
||||
|
||||
@@ -8,6 +8,7 @@ use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
@@ -51,6 +52,16 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
->nullable()
|
||||
->build();
|
||||
|
||||
$builder->createField('importSource', Types::STRING)
|
||||
->columnName('import_source')
|
||||
->nullable()
|
||||
->build();
|
||||
|
||||
$builder->createField('importOriginalShortCode', Types::STRING)
|
||||
->columnName('import_original_short_code')
|
||||
->nullable()
|
||||
->build();
|
||||
|
||||
$builder->createOneToMany('visits', Entity\Visit::class)
|
||||
->mappedBy('shortUrl')
|
||||
->fetchExtraLazy()
|
||||
@@ -68,5 +79,9 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
->cascadePersist()
|
||||
->build();
|
||||
|
||||
$builder->createManyToOne('authorApiKey', ApiKey::class)
|
||||
->addJoinColumn('author_api_key_id', 'id', true, false, 'SET NULL')
|
||||
->build();
|
||||
|
||||
$builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain');
|
||||
};
|
||||
|
||||
@@ -6,13 +6,19 @@ namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use DateTimeInterface;
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Laminas\InputFilter\InputFilter;
|
||||
use PUGX\Shortid\Factory as ShortIdFactory;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15;
|
||||
const DEFAULT_SHORT_CODES_LENGTH = 5;
|
||||
const MIN_SHORT_CODES_LENGTH = 4;
|
||||
const DEFAULT_REDIRECT_STATUS_CODE = StatusCodeInterface::STATUS_FOUND;
|
||||
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
|
||||
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
|
||||
const CUSTOM_SLUGS_REGEXP = '/[^A-Za-z0-9._~]+/';
|
||||
|
||||
function generateRandomShortCode(int $length): string
|
||||
{
|
||||
@@ -57,3 +63,15 @@ function determineTableName(string $tableName, array $emConfig = []): string
|
||||
|
||||
return sprintf('%s.%s', $schema, $tableName);
|
||||
}
|
||||
|
||||
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
|
||||
{
|
||||
$value = $inputFilter->getValue($fieldName);
|
||||
return $value !== null ? (int) $value : null;
|
||||
}
|
||||
|
||||
function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldName): ?bool
|
||||
{
|
||||
$value = $inputFilter->getValue($fieldName);
|
||||
return $value !== null ? (bool) $value : null;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Action;
|
||||
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use Laminas\Diactoros\Uri;
|
||||
use League\Uri\Uri;
|
||||
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
@@ -67,14 +67,14 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet
|
||||
|
||||
private function buildUrlToRedirectTo(ShortUrl $shortUrl, array $currentQuery, ?string $disableTrackParam): string
|
||||
{
|
||||
$uri = new Uri($shortUrl->getLongUrl());
|
||||
$hardcodedQuery = parse_query($uri->getQuery());
|
||||
$uri = Uri::createFromString($shortUrl->getLongUrl());
|
||||
$hardcodedQuery = parse_query($uri->getQuery() ?? '');
|
||||
if ($disableTrackParam !== null) {
|
||||
unset($currentQuery[$disableTrackParam]);
|
||||
}
|
||||
$mergedQuery = array_merge($hardcodedQuery, $currentQuery);
|
||||
|
||||
return (string) $uri->withQuery(build_query($mergedQuery));
|
||||
return (string) (empty($mergedQuery) ? $uri : $uri->withQuery(build_query($mergedQuery)));
|
||||
}
|
||||
|
||||
private function shouldTrackRequest(ServerRequestInterface $request, array $query, ?string $disableTrackParam): bool
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Action;
|
||||
|
||||
use Endroid\QrCode\QrCode;
|
||||
use Endroid\QrCode\Writer\SvgWriter;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
@@ -51,6 +52,11 @@ class QrCodeAction implements MiddlewareInterface
|
||||
$qrCode->setSize($this->getSizeParam($request));
|
||||
$qrCode->setMargin(0);
|
||||
|
||||
$format = $request->getQueryParams()['format'] ?? 'png';
|
||||
if ($format === 'svg') {
|
||||
$qrCode->setWriter(new SvgWriter());
|
||||
}
|
||||
|
||||
return new QrCodeResponse($qrCode);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,18 +4,41 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Action;
|
||||
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Laminas\Diactoros\Response\RedirectResponse;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Options;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
|
||||
class RedirectAction extends AbstractTrackingAction
|
||||
use function sprintf;
|
||||
|
||||
class RedirectAction extends AbstractTrackingAction implements StatusCodeInterface
|
||||
{
|
||||
private Options\UrlShortenerOptions $urlShortenerOptions;
|
||||
|
||||
public function __construct(
|
||||
ShortUrlResolverInterface $urlResolver,
|
||||
VisitsTrackerInterface $visitTracker,
|
||||
Options\AppOptions $appOptions,
|
||||
Options\UrlShortenerOptions $urlShortenerOptions,
|
||||
?LoggerInterface $logger = null
|
||||
) {
|
||||
parent::__construct($urlResolver, $visitTracker, $appOptions, $logger);
|
||||
$this->urlShortenerOptions = $urlShortenerOptions;
|
||||
}
|
||||
|
||||
protected function createSuccessResp(string $longUrl): Response
|
||||
{
|
||||
// Return a redirect response to the long URL.
|
||||
// Use a temporary redirect to make sure browsers always hit the server for analytics purposes
|
||||
return new RedirectResponse($longUrl);
|
||||
$statusCode = $this->urlShortenerOptions->redirectStatusCode();
|
||||
$headers = $statusCode === self::STATUS_FOUND ? [] : [
|
||||
'Cache-Control' => sprintf('private,max-age=%s', $this->urlShortenerOptions->redirectCacheLifetime()),
|
||||
];
|
||||
|
||||
return new RedirectResponse($longUrl, $statusCode, $headers);
|
||||
}
|
||||
|
||||
protected function createErrorResp(ServerRequestInterface $request, RequestHandlerInterface $handler): Response
|
||||
|
||||
@@ -38,6 +38,9 @@ class SimplifiedConfigParser
|
||||
'mercure_internal_hub_url' => ['mercure', 'internal_hub_url'],
|
||||
'mercure_jwt_secret' => ['mercure', 'jwt_secret'],
|
||||
'anonymize_remote_addr' => ['url_shortener', 'anonymize_remote_addr'],
|
||||
'redirect_status_code' => ['url_shortener', 'redirect_status_code'],
|
||||
'redirect_cache_lifetime' => ['url_shortener', 'redirect_cache_lifetime'],
|
||||
'port' => ['mezzio-swoole', 'swoole-http-server', 'port'],
|
||||
];
|
||||
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
|
||||
'delete_short_url_threshold' => [
|
||||
|
||||
29
module/Core/src/Domain/DomainService.php
Normal file
29
module/Core/src/Domain/DomainService.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
|
||||
class DomainService implements DomainServiceInterface
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
public function __construct(EntityManagerInterface $em)
|
||||
{
|
||||
$this->em = $em;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Domain[]
|
||||
*/
|
||||
public function listDomainsWithout(?string $excludeDomain = null): array
|
||||
{
|
||||
/** @var DomainRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Domain::class);
|
||||
return $repo->findDomainsWithout($excludeDomain);
|
||||
}
|
||||
}
|
||||
15
module/Core/src/Domain/DomainServiceInterface.php
Normal file
15
module/Core/src/Domain/DomainServiceInterface.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
|
||||
interface DomainServiceInterface
|
||||
{
|
||||
/**
|
||||
* @return Domain[]
|
||||
*/
|
||||
public function listDomainsWithout(?string $excludeDomain = null): array;
|
||||
}
|
||||
26
module/Core/src/Domain/Repository/DomainRepository.php
Normal file
26
module/Core/src/Domain/Repository/DomainRepository.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain\Repository;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
|
||||
class DomainRepository extends EntityRepository implements DomainRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @return Domain[]
|
||||
*/
|
||||
public function findDomainsWithout(?string $excludedAuthority = null): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('d')->orderBy('d.authority', 'ASC');
|
||||
|
||||
if ($excludedAuthority !== null) {
|
||||
$qb->where($qb->expr()->neq('d.authority', ':excludedAuthority'))
|
||||
->setParameter('excludedAuthority', $excludedAuthority);
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain\Repository;
|
||||
|
||||
use Doctrine\Persistence\ObjectRepository;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
|
||||
interface DomainRepositoryInterface extends ObjectRepository
|
||||
{
|
||||
/**
|
||||
* @return Domain[]
|
||||
*/
|
||||
public function findDomainsWithout(?string $excludedAuthority = null): array;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain\Resolver;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
|
||||
interface DomainResolverInterface
|
||||
{
|
||||
public function resolveDomain(?string $domain): ?Domain;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain\Resolver;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
|
||||
class SimpleDomainResolver implements DomainResolverInterface
|
||||
{
|
||||
public function resolveDomain(?string $domain): ?Domain
|
||||
{
|
||||
return $domain !== null ? new Domain($domain) : null;
|
||||
}
|
||||
}
|
||||
@@ -9,16 +9,16 @@ use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Laminas\Diactoros\Uri;
|
||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver\SimpleDomainResolver;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
use function array_reduce;
|
||||
use function count;
|
||||
use function Functional\contains;
|
||||
use function Functional\invoke;
|
||||
use function Shlinkio\Shlink\Core\generateRandomShortCode;
|
||||
|
||||
class ShortUrl extends AbstractEntity
|
||||
@@ -36,13 +36,17 @@ class ShortUrl extends AbstractEntity
|
||||
private ?Domain $domain = null;
|
||||
private bool $customSlugWasProvided;
|
||||
private int $shortCodeLength;
|
||||
private ?string $importSource = null;
|
||||
private ?string $importOriginalShortCode = null;
|
||||
private ?ApiKey $authorApiKey = null;
|
||||
|
||||
public function __construct(
|
||||
string $longUrl,
|
||||
?ShortUrlMeta $meta = null,
|
||||
?DomainResolverInterface $domainResolver = null
|
||||
?ShortUrlRelationResolverInterface $relationResolver = null
|
||||
) {
|
||||
$meta = $meta ?? ShortUrlMeta::createEmpty();
|
||||
$relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver();
|
||||
|
||||
$this->longUrl = $longUrl;
|
||||
$this->dateCreated = Chronos::now();
|
||||
@@ -54,7 +58,29 @@ class ShortUrl extends AbstractEntity
|
||||
$this->customSlugWasProvided = $meta->hasCustomSlug();
|
||||
$this->shortCodeLength = $meta->getShortCodeLength();
|
||||
$this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($this->shortCodeLength);
|
||||
$this->domain = ($domainResolver ?? new SimpleDomainResolver())->resolveDomain($meta->getDomain());
|
||||
$this->domain = $relationResolver->resolveDomain($meta->getDomain());
|
||||
$this->authorApiKey = $relationResolver->resolveApiKey($meta->getApiKey());
|
||||
}
|
||||
|
||||
public static function fromImport(
|
||||
ImportedShlinkUrl $url,
|
||||
bool $importShortCode,
|
||||
?ShortUrlRelationResolverInterface $relationResolver = null
|
||||
): self {
|
||||
$meta = [
|
||||
ShortUrlMetaInputFilter::DOMAIN => $url->domain(),
|
||||
ShortUrlMetaInputFilter::VALIDATE_URL => false,
|
||||
];
|
||||
if ($importShortCode) {
|
||||
$meta[ShortUrlMetaInputFilter::CUSTOM_SLUG] = $url->shortCode();
|
||||
}
|
||||
|
||||
$instance = new self($url->longUrl(), ShortUrlMeta::fromRawData($meta), $relationResolver);
|
||||
$instance->importSource = $url->source();
|
||||
$instance->importOriginalShortCode = $url->shortCode();
|
||||
$instance->dateCreated = Chronos::instance($url->createdAt());
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public function getLongUrl(): string
|
||||
@@ -113,10 +139,10 @@ class ShortUrl extends AbstractEntity
|
||||
/**
|
||||
* @throws ShortCodeCannotBeRegeneratedException
|
||||
*/
|
||||
public function regenerateShortCode(): self
|
||||
public function regenerateShortCode(): void
|
||||
{
|
||||
// In ShortUrls where a custom slug was provided, do nothing
|
||||
if ($this->customSlugWasProvided) {
|
||||
// In ShortUrls where a custom slug was provided, throw error, unless it is an imported one
|
||||
if ($this->customSlugWasProvided && $this->importSource === null) {
|
||||
throw ShortCodeCannotBeRegeneratedException::forShortUrlWithCustomSlug();
|
||||
}
|
||||
|
||||
@@ -126,7 +152,6 @@ class ShortUrl extends AbstractEntity
|
||||
}
|
||||
|
||||
$this->shortCode = generateRandomShortCode($this->shortCodeLength);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getValidSince(): ?Chronos
|
||||
@@ -195,27 +220,4 @@ class ShortUrl extends AbstractEntity
|
||||
|
||||
return $this->domain->getAuthority();
|
||||
}
|
||||
|
||||
public function matchesCriteria(ShortUrlMeta $meta, array $tags): bool
|
||||
{
|
||||
if ($meta->hasMaxVisits() && $meta->getMaxVisits() !== $this->maxVisits) {
|
||||
return false;
|
||||
}
|
||||
if ($meta->hasDomain() && $meta->getDomain() !== $this->resolveDomain()) {
|
||||
return false;
|
||||
}
|
||||
if ($meta->hasValidSince() && ($this->validSince === null || ! $meta->getValidSince()->eq($this->validSince))) {
|
||||
return false;
|
||||
}
|
||||
if ($meta->hasValidUntil() && ($this->validUntil === null || ! $meta->getValidUntil()->eq($this->validUntil))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$shortUrlTags = invoke($this->getTags(), '__toString');
|
||||
return count($shortUrlTags) === count($tags) && array_reduce(
|
||||
$tags,
|
||||
fn (bool $hasAllTags, string $tag) => $hasAllTags && contains($shortUrlTags, $tag),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
98
module/Core/src/Importer/ImportedLinksProcessor.php
Normal file
98
module/Core/src/Importer/ImportedLinksProcessor.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Importer;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface;
|
||||
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
|
||||
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
||||
use Symfony\Component\Console\Style\StyleInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class ImportedLinksProcessor implements ImportedLinksProcessorInterface
|
||||
{
|
||||
use TagManagerTrait;
|
||||
|
||||
private EntityManagerInterface $em;
|
||||
private ShortUrlRelationResolverInterface $relationResolver;
|
||||
private ShortCodeHelperInterface $shortCodeHelper;
|
||||
private DoctrineBatchHelperInterface $batchHelper;
|
||||
|
||||
public function __construct(
|
||||
EntityManagerInterface $em,
|
||||
ShortUrlRelationResolverInterface $relationResolver,
|
||||
ShortCodeHelperInterface $shortCodeHelper,
|
||||
DoctrineBatchHelperInterface $batchHelper
|
||||
) {
|
||||
$this->em = $em;
|
||||
$this->relationResolver = $relationResolver;
|
||||
$this->shortCodeHelper = $shortCodeHelper;
|
||||
$this->batchHelper = $batchHelper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable|ImportedShlinkUrl[] $shlinkUrls
|
||||
*/
|
||||
public function process(StyleInterface $io, iterable $shlinkUrls, array $params): void
|
||||
{
|
||||
/** @var ShortUrlRepositoryInterface $shortUrlRepo */
|
||||
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
|
||||
$importShortCodes = $params['import_short_codes'];
|
||||
$iterable = $this->batchHelper->wrapIterable($shlinkUrls, 100);
|
||||
|
||||
/** @var ImportedShlinkUrl $url */
|
||||
foreach ($iterable as $url) {
|
||||
$longUrl = $url->longUrl();
|
||||
|
||||
// Skip already imported URLs
|
||||
if ($shortUrlRepo->importedUrlExists($url)) {
|
||||
$io->text(sprintf('%s: <comment>Skipped</comment>', $longUrl));
|
||||
continue;
|
||||
}
|
||||
|
||||
$shortUrl = ShortUrl::fromImport($url, $importShortCodes, $this->relationResolver);
|
||||
$shortUrl->setTags($this->tagNamesToEntities($this->em, $url->tags()));
|
||||
|
||||
if (! $this->handleShortCodeUniqueness($url, $shortUrl, $io, $importShortCodes)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->em->persist($shortUrl);
|
||||
$io->text(sprintf('%s: <info>Imported</info>', $longUrl));
|
||||
}
|
||||
}
|
||||
|
||||
private function handleShortCodeUniqueness(
|
||||
ImportedShlinkUrl $url,
|
||||
ShortUrl $shortUrl,
|
||||
StyleInterface $io,
|
||||
bool $importShortCodes
|
||||
): bool {
|
||||
if ($this->shortCodeHelper->ensureShortCodeUniqueness($shortUrl, $importShortCodes)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$longUrl = $url->longUrl();
|
||||
$action = $io->choice(sprintf(
|
||||
'Failed to import URL "%s" because its short-code "%s" is already in use. Do you want to generate a new '
|
||||
. 'one or skip it?',
|
||||
$longUrl,
|
||||
$url->shortCode(),
|
||||
), ['Generate new short-code', 'Skip'], 1);
|
||||
|
||||
if ($action === 'Skip') {
|
||||
$io->text(sprintf('%s: <comment>Skipped</comment>', $longUrl));
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->shortCodeHelper->ensureShortCodeUniqueness($shortUrl, false);
|
||||
}
|
||||
}
|
||||
@@ -4,41 +4,32 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Psr\Http\Message\UriInterface;
|
||||
|
||||
final class CreateShortUrlData
|
||||
{
|
||||
private UriInterface $longUrl;
|
||||
private string $longUrl;
|
||||
private array $tags;
|
||||
private ShortUrlMeta $meta;
|
||||
|
||||
public function __construct(
|
||||
UriInterface $longUrl,
|
||||
array $tags = [],
|
||||
?ShortUrlMeta $meta = null
|
||||
) {
|
||||
public function __construct(string $longUrl, array $tags = [], ?ShortUrlMeta $meta = null)
|
||||
{
|
||||
$this->longUrl = $longUrl;
|
||||
$this->tags = $tags;
|
||||
$this->meta = $meta ?? ShortUrlMeta::createEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
public function getLongUrl(): UriInterface
|
||||
public function getLongUrl(): string
|
||||
{
|
||||
return $this->longUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
* @return string[]
|
||||
*/
|
||||
public function getTags(): array
|
||||
{
|
||||
return $this->tags;
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
public function getMeta(): ShortUrlMeta
|
||||
{
|
||||
return $this->meta;
|
||||
|
||||
@@ -9,6 +9,8 @@ use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||
|
||||
use function array_key_exists;
|
||||
use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
|
||||
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
|
||||
use function Shlinkio\Shlink\Core\parseDateField;
|
||||
|
||||
final class ShortUrlEdit
|
||||
@@ -21,6 +23,7 @@ final class ShortUrlEdit
|
||||
private ?Chronos $validUntil = null;
|
||||
private bool $maxVisitsPropWasProvided = false;
|
||||
private ?int $maxVisits = null;
|
||||
private ?bool $validateUrl = null;
|
||||
|
||||
// Enforce named constructors
|
||||
private function __construct()
|
||||
@@ -55,13 +58,8 @@ final class ShortUrlEdit
|
||||
$this->longUrl = $inputFilter->getValue(ShortUrlMetaInputFilter::LONG_URL);
|
||||
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
|
||||
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
|
||||
$this->maxVisits = $this->getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS);
|
||||
}
|
||||
|
||||
private function getOptionalIntFromInputFilter(ShortUrlMetaInputFilter $inputFilter, string $fieldName): ?int
|
||||
{
|
||||
$value = $inputFilter->getValue($fieldName);
|
||||
return $value !== null ? (int) $value : null;
|
||||
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS);
|
||||
$this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlMetaInputFilter::VALIDATE_URL);
|
||||
}
|
||||
|
||||
public function longUrl(): ?string
|
||||
@@ -103,4 +101,9 @@ final class ShortUrlEdit
|
||||
{
|
||||
return $this->maxVisitsPropWasProvided;
|
||||
}
|
||||
|
||||
public function doValidateUrl(): ?bool
|
||||
{
|
||||
return $this->validateUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||
|
||||
use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
|
||||
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
|
||||
use function Shlinkio\Shlink\Core\parseDateField;
|
||||
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
||||
@@ -21,6 +23,8 @@ final class ShortUrlMeta
|
||||
private ?bool $findIfExists = null;
|
||||
private ?string $domain = null;
|
||||
private int $shortCodeLength = 5;
|
||||
private ?bool $validateUrl = null;
|
||||
private ?string $apiKey = null;
|
||||
|
||||
// Enforce named constructors
|
||||
private function __construct()
|
||||
@@ -55,19 +59,15 @@ final class ShortUrlMeta
|
||||
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
|
||||
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
|
||||
$this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
|
||||
$this->maxVisits = $this->getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS);
|
||||
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS);
|
||||
$this->findIfExists = $inputFilter->getValue(ShortUrlMetaInputFilter::FIND_IF_EXISTS);
|
||||
$this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlMetaInputFilter::VALIDATE_URL);
|
||||
$this->domain = $inputFilter->getValue(ShortUrlMetaInputFilter::DOMAIN);
|
||||
$this->shortCodeLength = $this->getOptionalIntFromInputFilter(
|
||||
$this->shortCodeLength = getOptionalIntFromInputFilter(
|
||||
$inputFilter,
|
||||
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH,
|
||||
) ?? DEFAULT_SHORT_CODES_LENGTH;
|
||||
}
|
||||
|
||||
private function getOptionalIntFromInputFilter(ShortUrlMetaInputFilter $inputFilter, string $fieldName): ?int
|
||||
{
|
||||
$value = $inputFilter->getValue($fieldName);
|
||||
return $value !== null ? (int) $value : null;
|
||||
$this->apiKey = $inputFilter->getValue(ShortUrlMetaInputFilter::API_KEY);
|
||||
}
|
||||
|
||||
public function getValidSince(): ?Chronos
|
||||
@@ -129,4 +129,14 @@ final class ShortUrlMeta
|
||||
{
|
||||
return $this->shortCodeLength;
|
||||
}
|
||||
|
||||
public function doValidateUrl(): ?bool
|
||||
{
|
||||
return $this->validateUrl;
|
||||
}
|
||||
|
||||
public function getApiKey(): ?string
|
||||
{
|
||||
return $this->apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
|
||||
use function explode;
|
||||
use function is_array;
|
||||
use function is_string;
|
||||
use function key;
|
||||
@@ -40,15 +41,22 @@ final class ShortUrlsOrdering
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXME Providing the ordering as array is considered deprecated. To be removed in v3.0.0
|
||||
$isArray = is_array($orderBy);
|
||||
if (! $isArray && $orderBy !== null && ! is_string($orderBy)) {
|
||||
if (! $isArray && ! is_string($orderBy)) {
|
||||
throw ValidationException::fromArray([
|
||||
'orderBy' => '"Order by" must be an array, string or null',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->orderField = $isArray ? key($orderBy) : $orderBy;
|
||||
$this->orderDirection = $isArray ? $orderBy[$this->orderField] : self::DEFAULT_ORDER_DIRECTION;
|
||||
if (! $isArray) {
|
||||
$parts = explode('-', $orderBy);
|
||||
$this->orderField = $parts[0];
|
||||
$this->orderDirection = $parts[1] ?? self::DEFAULT_ORDER_DIRECTION;
|
||||
} else {
|
||||
$this->orderField = key($orderBy);
|
||||
$this->orderDirection = $orderBy[$this->orderField];
|
||||
}
|
||||
}
|
||||
|
||||
public function orderField(): ?string
|
||||
|
||||
@@ -12,11 +12,14 @@ use function Shlinkio\Shlink\Core\parseDateField;
|
||||
|
||||
final class ShortUrlsParams
|
||||
{
|
||||
public const DEFAULT_ITEMS_PER_PAGE = 10;
|
||||
|
||||
private int $page;
|
||||
private ?string $searchTerm;
|
||||
private array $tags;
|
||||
private ShortUrlsOrdering $orderBy;
|
||||
private ?DateRange $dateRange;
|
||||
private ?int $itemsPerPage = null;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
@@ -56,6 +59,9 @@ final class ShortUrlsParams
|
||||
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
|
||||
);
|
||||
$this->orderBy = ShortUrlsOrdering::fromRawData($query);
|
||||
$this->itemsPerPage = (int) (
|
||||
$inputFilter->getValue(ShortUrlsParamsInputFilter::ITEMS_PER_PAGE) ?? self::DEFAULT_ITEMS_PER_PAGE
|
||||
);
|
||||
}
|
||||
|
||||
public function page(): int
|
||||
@@ -63,6 +69,11 @@ final class ShortUrlsParams
|
||||
return $this->page;
|
||||
}
|
||||
|
||||
public function itemsPerPage(): int
|
||||
{
|
||||
return $this->itemsPerPage;
|
||||
}
|
||||
|
||||
public function searchTerm(): ?string
|
||||
{
|
||||
return $this->searchTerm;
|
||||
|
||||
@@ -6,9 +6,11 @@ namespace Shlinkio\Shlink\Core\Options;
|
||||
|
||||
use Laminas\Stdlib\AbstractOptions;
|
||||
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_DELETE_SHORT_URL_THRESHOLD;
|
||||
|
||||
class DeleteShortUrlsOptions extends AbstractOptions
|
||||
{
|
||||
private int $visitsThreshold = 15;
|
||||
private int $visitsThreshold = DEFAULT_DELETE_SHORT_URL_THRESHOLD;
|
||||
private bool $checkVisitsThreshold = true;
|
||||
|
||||
public function getVisitsThreshold(): int
|
||||
|
||||
@@ -6,20 +6,53 @@ namespace Shlinkio\Shlink\Core\Options;
|
||||
|
||||
use Laminas\Stdlib\AbstractOptions;
|
||||
|
||||
use function Functional\contains;
|
||||
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_STATUS_CODE;
|
||||
|
||||
class UrlShortenerOptions extends AbstractOptions
|
||||
{
|
||||
protected $__strictMode__ = false; // phpcs:ignore
|
||||
|
||||
private bool $validateUrl = true;
|
||||
private int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE;
|
||||
private int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||
|
||||
public function isUrlValidationEnabled(): bool
|
||||
{
|
||||
return $this->validateUrl;
|
||||
}
|
||||
|
||||
protected function setValidateUrl(bool $validateUrl): self
|
||||
protected function setValidateUrl(bool $validateUrl): void
|
||||
{
|
||||
$this->validateUrl = $validateUrl;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function redirectStatusCode(): int
|
||||
{
|
||||
return $this->redirectStatusCode;
|
||||
}
|
||||
|
||||
protected function setRedirectStatusCode(int $redirectStatusCode): void
|
||||
{
|
||||
$this->redirectStatusCode = $this->normalizeRedirectStatusCode($redirectStatusCode);
|
||||
}
|
||||
|
||||
private function normalizeRedirectStatusCode(int $statusCode): int
|
||||
{
|
||||
return contains([301, 302], $statusCode) ? $statusCode : DEFAULT_REDIRECT_STATUS_CODE;
|
||||
}
|
||||
|
||||
public function redirectCacheLifetime(): int
|
||||
{
|
||||
return $this->redirectCacheLifetime;
|
||||
}
|
||||
|
||||
protected function setRedirectCacheLifetime(int $redirectCacheLifetime): void
|
||||
{
|
||||
$this->redirectCacheLifetime = $redirectCacheLifetime > 0
|
||||
? $redirectCacheLifetime
|
||||
: DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
|
||||
class ShortUrlRepositoryAdapter implements AdapterInterface
|
||||
{
|
||||
public const ITEMS_PER_PAGE = 10;
|
||||
|
||||
private ShortUrlRepositoryInterface $repository;
|
||||
private ShortUrlsParams $params;
|
||||
|
||||
|
||||
@@ -5,13 +5,17 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Repository;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\Query\Expr\Join;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
||||
|
||||
use function array_column;
|
||||
use function array_key_exists;
|
||||
use function count;
|
||||
use function Functional\contains;
|
||||
|
||||
class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface
|
||||
@@ -186,6 +190,85 @@ DQL;
|
||||
->setParameter('slug', $slug)
|
||||
->setMaxResults(1);
|
||||
|
||||
$this->whereDomainIs($qb, $domain);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
public function findOneMatching(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
|
||||
$qb->select('s')
|
||||
->from(ShortUrl::class, 's')
|
||||
->where($qb->expr()->eq('s.longUrl', ':longUrl'))
|
||||
->setParameter('longUrl', $url)
|
||||
->setMaxResults(1)
|
||||
->orderBy('s.id');
|
||||
|
||||
if ($meta->hasCustomSlug()) {
|
||||
$qb->andWhere($qb->expr()->eq('s.shortCode', ':slug'))
|
||||
->setParameter('slug', $meta->getCustomSlug());
|
||||
}
|
||||
if ($meta->hasMaxVisits()) {
|
||||
$qb->andWhere($qb->expr()->eq('s.maxVisits', ':maxVisits'))
|
||||
->setParameter('maxVisits', $meta->getMaxVisits());
|
||||
}
|
||||
if ($meta->hasValidSince()) {
|
||||
$qb->andWhere($qb->expr()->eq('s.validSince', ':validSince'))
|
||||
->setParameter('validSince', $meta->getValidSince());
|
||||
}
|
||||
if ($meta->hasValidUntil()) {
|
||||
$qb->andWhere($qb->expr()->eq('s.validUntil', ':validUntil'))
|
||||
->setParameter('validUntil', $meta->getValidUntil());
|
||||
}
|
||||
|
||||
if ($meta->hasDomain()) {
|
||||
$qb->join('s.domain', 'd')
|
||||
->andWhere($qb->expr()->eq('d.authority', ':domain'))
|
||||
->setParameter('domain', $meta->getDomain());
|
||||
}
|
||||
|
||||
$tagsAmount = count($tags);
|
||||
if ($tagsAmount === 0) {
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
foreach ($tags as $index => $tag) {
|
||||
$alias = 't_' . $index;
|
||||
$qb->join('s.tags', $alias, Join::WITH, $alias . '.name = :tag' . $index)
|
||||
->setParameter('tag' . $index, $tag);
|
||||
}
|
||||
|
||||
// If tags where provided, we need an extra join to see the amount of tags that every short URL has, so that we
|
||||
// can discard those that also have more tags, making sure only those fully matching are included.
|
||||
$qb->join('s.tags', 't')
|
||||
->groupBy('s')
|
||||
->having($qb->expr()->eq('COUNT(t.id)', ':tagsAmount'))
|
||||
->setParameter('tagsAmount', $tagsAmount);
|
||||
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function importedUrlExists(ImportedShlinkUrl $url): bool
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->select('COUNT(DISTINCT s.id)')
|
||||
->from(ShortUrl::class, 's')
|
||||
->andWhere($qb->expr()->eq('s.importOriginalShortCode', ':shortCode'))
|
||||
->setParameter('shortCode', $url->shortCode())
|
||||
->andWhere($qb->expr()->eq('s.importSource', ':importSource'))
|
||||
->setParameter('importSource', $url->source())
|
||||
->setMaxResults(1);
|
||||
|
||||
$this->whereDomainIs($qb, $url->domain());
|
||||
|
||||
$result = (int) $qb->getQuery()->getSingleScalarResult();
|
||||
return $result > 0;
|
||||
}
|
||||
|
||||
private function whereDomainIs(QueryBuilder $qb, ?string $domain): void
|
||||
{
|
||||
if ($domain !== null) {
|
||||
$qb->join('s.domain', 'd')
|
||||
->andWhere($qb->expr()->eq('d.authority', ':authority'))
|
||||
@@ -193,7 +276,5 @@ DQL;
|
||||
} else {
|
||||
$qb->andWhere($qb->expr()->isNull('s.domain'));
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ namespace Shlinkio\Shlink\Core\Repository;
|
||||
use Doctrine\Persistence\ObjectRepository;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
||||
|
||||
interface ShortUrlRepositoryInterface extends ObjectRepository
|
||||
{
|
||||
@@ -27,4 +29,8 @@ interface ShortUrlRepositoryInterface extends ObjectRepository
|
||||
public function findOne(string $shortCode, ?string $domain = null): ?ShortUrl;
|
||||
|
||||
public function shortCodeIsInUse(string $slug, ?string $domain): bool;
|
||||
|
||||
public function findOneMatching(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl;
|
||||
|
||||
public function importedUrlExists(ImportedShlinkUrl $url): bool;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
|
||||
use function array_column;
|
||||
|
||||
use const PHP_INT_MAX;
|
||||
|
||||
class VisitRepository extends EntityRepository implements VisitRepositoryInterface
|
||||
@@ -142,26 +140,18 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
|
||||
|
||||
private function createVisitsByTagQueryBuilder(string $tag, ?DateRange $dateRange = null): QueryBuilder
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->select('s.id')
|
||||
->from(ShortUrl::class, 's')
|
||||
->join('s.tags', 't')
|
||||
->where($qb->expr()->eq('t.name', ':tag'))
|
||||
->setParameter('tag', $tag);
|
||||
|
||||
$shortUrlIds = array_column($qb->getQuery()->getArrayResult(), 'id');
|
||||
$shortUrlIds[] = '-1'; // Add an invalid ID, in case the list is empty
|
||||
|
||||
// Parameters in this query need to be part of the query itself, as we need to use it a sub-query later
|
||||
// Since they are not strictly provided by the caller, it's reasonably safe
|
||||
$qb2 = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb2->from(Visit::class, 'v')
|
||||
->where($qb2->expr()->in('v.shortUrl', $shortUrlIds));
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->from(Visit::class, 'v')
|
||||
->join('v.shortUrl', 's')
|
||||
->join('s.tags', 't')
|
||||
->where($qb->expr()->eq('t.name', '\'' . $tag . '\''));
|
||||
|
||||
// Apply date range filtering
|
||||
$this->applyDatesInline($qb2, $dateRange);
|
||||
$this->applyDatesInline($qb, $dateRange);
|
||||
|
||||
return $qb2;
|
||||
return $qb;
|
||||
}
|
||||
|
||||
private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void
|
||||
|
||||
41
module/Core/src/Service/ShortUrl/ShortCodeHelper.php
Normal file
41
module/Core/src/Service/ShortUrl/ShortCodeHelper.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
|
||||
|
||||
class ShortCodeHelper implements ShortCodeHelperInterface
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
public function __construct(EntityManagerInterface $em)
|
||||
{
|
||||
$this->em = $em;
|
||||
}
|
||||
|
||||
public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool
|
||||
{
|
||||
$shortCode = $shortUrlToBeCreated->getShortCode();
|
||||
$domain = $shortUrlToBeCreated->getDomain();
|
||||
$domainAuthority = $domain !== null ? $domain->getAuthority() : null;
|
||||
|
||||
/** @var ShortUrlRepository $repo */
|
||||
$repo = $this->em->getRepository(ShortUrl::class);
|
||||
$otherShortUrlsExist = $repo->shortCodeIsInUse($shortCode, $domainAuthority);
|
||||
|
||||
if (! $otherShortUrlsExist) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($hasCustomSlug) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$shortUrlToBeCreated->regenerateShortCode();
|
||||
return $this->ensureShortCodeUniqueness($shortUrlToBeCreated, $hasCustomSlug);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
|
||||
interface ShortCodeHelperInterface
|
||||
{
|
||||
public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool;
|
||||
}
|
||||
@@ -44,7 +44,7 @@ class ShortUrlService implements ShortUrlServiceInterface
|
||||
/** @var ShortUrlRepository $repo */
|
||||
$repo = $this->em->getRepository(ShortUrl::class);
|
||||
$paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $params));
|
||||
$paginator->setItemCountPerPage(ShortUrlRepositoryAdapter::ITEMS_PER_PAGE)
|
||||
$paginator->setItemCountPerPage($params->itemsPerPage())
|
||||
->setCurrentPageNumber($params->page());
|
||||
|
||||
return $paginator;
|
||||
@@ -71,7 +71,7 @@ class ShortUrlService implements ShortUrlServiceInterface
|
||||
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit): ShortUrl
|
||||
{
|
||||
if ($shortUrlEdit->hasLongUrl()) {
|
||||
$this->urlValidator->validateUrl($shortUrlEdit->longUrl());
|
||||
$this->urlValidator->validateUrl($shortUrlEdit->longUrl(), $shortUrlEdit->doValidateUrl());
|
||||
}
|
||||
|
||||
$shortUrl = $this->urlResolver->resolveShortUrl($identifier);
|
||||
|
||||
@@ -5,35 +5,36 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
|
||||
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
|
||||
use Throwable;
|
||||
|
||||
use function array_reduce;
|
||||
|
||||
class UrlShortener implements UrlShortenerInterface
|
||||
{
|
||||
use TagManagerTrait;
|
||||
|
||||
private EntityManagerInterface $em;
|
||||
private UrlValidatorInterface $urlValidator;
|
||||
private DomainResolverInterface $domainResolver;
|
||||
private ShortUrlRelationResolverInterface $relationResolver;
|
||||
private ShortCodeHelperInterface $shortCodeHelper;
|
||||
|
||||
public function __construct(
|
||||
UrlValidatorInterface $urlValidator,
|
||||
EntityManagerInterface $em,
|
||||
DomainResolverInterface $domainResolver
|
||||
ShortUrlRelationResolverInterface $relationResolver,
|
||||
ShortCodeHelperInterface $shortCodeHelper
|
||||
) {
|
||||
$this->urlValidator = $urlValidator;
|
||||
$this->em = $em;
|
||||
$this->domainResolver = $domainResolver;
|
||||
$this->relationResolver = $relationResolver;
|
||||
$this->shortCodeHelper = $shortCodeHelper;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,36 +43,25 @@ class UrlShortener implements UrlShortenerInterface
|
||||
* @throws InvalidUrlException
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl
|
||||
public function shorten(string $url, array $tags, ShortUrlMeta $meta): ShortUrl
|
||||
{
|
||||
$url = (string) $url;
|
||||
|
||||
// First, check if a short URL exists for all provided params
|
||||
$existingShortUrl = $this->findExistingShortUrlIfExists($url, $tags, $meta);
|
||||
if ($existingShortUrl !== null) {
|
||||
return $existingShortUrl;
|
||||
}
|
||||
|
||||
$this->urlValidator->validateUrl($url);
|
||||
$this->em->beginTransaction();
|
||||
$shortUrl = new ShortUrl($url, $meta, $this->domainResolver);
|
||||
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
|
||||
$this->urlValidator->validateUrl($url, $meta->doValidateUrl());
|
||||
|
||||
return $this->em->transactional(function () use ($url, $tags, $meta) {
|
||||
$shortUrl = new ShortUrl($url, $meta, $this->relationResolver);
|
||||
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
|
||||
|
||||
try {
|
||||
$this->verifyShortCodeUniqueness($meta, $shortUrl);
|
||||
$this->em->persist($shortUrl);
|
||||
$this->em->flush();
|
||||
$this->em->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($this->em->getConnection()->isTransactionActive()) {
|
||||
$this->em->rollback();
|
||||
$this->em->close();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $shortUrl;
|
||||
return $shortUrl;
|
||||
});
|
||||
}
|
||||
|
||||
private function findExistingShortUrlIfExists(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl
|
||||
@@ -80,42 +70,23 @@ class UrlShortener implements UrlShortenerInterface
|
||||
return null;
|
||||
}
|
||||
|
||||
$criteria = ['longUrl' => $url];
|
||||
if ($meta->hasCustomSlug()) {
|
||||
$criteria['shortCode'] = $meta->getCustomSlug();
|
||||
}
|
||||
/** @var ShortUrl[] $shortUrls */
|
||||
$shortUrls = $this->em->getRepository(ShortUrl::class)->findBy($criteria);
|
||||
if (empty($shortUrls)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Iterate short URLs until one that matches is found, or return null otherwise
|
||||
return array_reduce($shortUrls, function (?ShortUrl $found, ShortUrl $shortUrl) use ($tags, $meta) {
|
||||
if ($found !== null) {
|
||||
return $found;
|
||||
}
|
||||
|
||||
return $shortUrl->matchesCriteria($meta, $tags) ? $shortUrl : null;
|
||||
});
|
||||
/** @var ShortUrlRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(ShortUrl::class);
|
||||
return $repo->findOneMatching($url, $tags, $meta);
|
||||
}
|
||||
|
||||
private function verifyShortCodeUniqueness(ShortUrlMeta $meta, ShortUrl $shortUrlToBeCreated): void
|
||||
{
|
||||
$shortCode = $shortUrlToBeCreated->getShortCode();
|
||||
$domain = $meta->getDomain();
|
||||
$couldBeMadeUnique = $this->shortCodeHelper->ensureShortCodeUniqueness(
|
||||
$shortUrlToBeCreated,
|
||||
$meta->hasCustomSlug(),
|
||||
);
|
||||
|
||||
/** @var ShortUrlRepository $repo */
|
||||
$repo = $this->em->getRepository(ShortUrl::class);
|
||||
$otherShortUrlsExist = $repo->shortCodeIsInUse($shortCode, $domain);
|
||||
if (! $couldBeMadeUnique) {
|
||||
$domain = $shortUrlToBeCreated->getDomain();
|
||||
$domainAuthority = $domain !== null ? $domain->getAuthority() : null;
|
||||
|
||||
if ($otherShortUrlsExist && $meta->hasCustomSlug()) {
|
||||
throw NonUniqueSlugException::fromSlug($shortCode, $domain);
|
||||
}
|
||||
|
||||
if ($otherShortUrlsExist) {
|
||||
$shortUrlToBeCreated->regenerateShortCode();
|
||||
$this->verifyShortCodeUniqueness($meta, $shortUrlToBeCreated);
|
||||
throw NonUniqueSlugException::fromSlug($shortUrlToBeCreated->getShortCode(), $domainAuthority);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
use Psr\Http\Message\UriInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
@@ -17,5 +16,5 @@ interface UrlShortenerInterface
|
||||
* @throws NonUniqueSlugException
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl;
|
||||
public function shorten(string $url, array $tags, ShortUrlMeta $meta): ShortUrl;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user