mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-04 06:13:12 +08:00
Compare commits
133 Commits
v2.4.1
...
v2.5.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da9e9df4ba | ||
|
|
1c75519f9b | ||
|
|
fca19f265b | ||
|
|
75dab92225 | ||
|
|
9e9d213f20 | ||
|
|
c49a0ca040 | ||
|
|
1f2e16184c | ||
|
|
7a19b8765d | ||
|
|
a639a4eb94 | ||
|
|
c9ff2b3834 | ||
|
|
95e51665b1 | ||
|
|
91da241434 | ||
|
|
5bec9f5b65 | ||
|
|
34bb023b7d | ||
|
|
2be0050f3d | ||
|
|
ff1af82ffd | ||
|
|
13cc70e6d4 | ||
|
|
fa5934b8b6 | ||
|
|
c8eb956778 | ||
|
|
5283ee2c6b | ||
|
|
c56d56d38c | ||
|
|
ea05259bbe | ||
|
|
f17873b527 | ||
|
|
f827186c77 | ||
|
|
380915948b | ||
|
|
14eeb91c58 | ||
|
|
01dceca9ef | ||
|
|
ba32366b0b | ||
|
|
bef1b13a33 | ||
|
|
caa1ae0de8 | ||
|
|
b0c4582f3f | ||
|
|
a8b68f07b5 | ||
|
|
b5710f87e2 | ||
|
|
041f231ff2 | ||
|
|
01b3c504f8 | ||
|
|
f821dea06c | ||
|
|
4b67d41362 | ||
|
|
19834f6715 | ||
|
|
262a06f624 | ||
|
|
a01e0ba337 | ||
|
|
364be2420b | ||
|
|
29cdfaed39 | ||
|
|
24f7fb9c4f | ||
|
|
68c601a5a8 | ||
|
|
8aa6bdb934 | ||
|
|
4a1e7b761a | ||
|
|
25ee9b5daf | ||
|
|
fff10ebee4 | ||
|
|
65797b61a0 | ||
|
|
3e565d3830 | ||
|
|
dc08286a72 | ||
|
|
940383646b | ||
|
|
6e1d6ab795 | ||
|
|
df53e6c6f2 | ||
|
|
7e6882960e | ||
|
|
ecf22ae4b6 | ||
|
|
90551ff3bc | ||
|
|
598f2d8622 | ||
|
|
f3b4e94def | ||
|
|
6eb3dae8c3 | ||
|
|
09029dff37 | ||
|
|
9e7f2aea0d | ||
|
|
850a5b412c | ||
|
|
84331135f7 | ||
|
|
202a7327d3 | ||
|
|
f42e2d87b3 | ||
|
|
22124aced7 | ||
|
|
40676f2167 | ||
|
|
d7b4720327 | ||
|
|
3a4a2e4483 | ||
|
|
71a83aa384 | ||
|
|
291393eeeb | ||
|
|
ea06c369b0 | ||
|
|
625c870417 | ||
|
|
a9e9f89799 | ||
|
|
f2210ca0cb | ||
|
|
1a42ca9239 | ||
|
|
53726bc679 | ||
|
|
d8a7f3e08c | ||
|
|
ac5a22a3d0 | ||
|
|
5dc2c1640a | ||
|
|
7fe7354a27 | ||
|
|
ac85b913c2 | ||
|
|
0e58d1a242 | ||
|
|
5040f5b177 | ||
|
|
77deb9c111 | ||
|
|
74bafefa68 | ||
|
|
d564404bfe | ||
|
|
b2658073b3 | ||
|
|
63bd95a123 | ||
|
|
40105d7aaf | ||
|
|
c78991761f | ||
|
|
b7a0d319b3 | ||
|
|
55bfa9776a | ||
|
|
d3a4ed607c | ||
|
|
8c79619ff2 | ||
|
|
6bedca4ee6 | ||
|
|
9857f105ec | ||
|
|
7ac1c32ad6 | ||
|
|
6e9fa6553d | ||
|
|
55ea8a6912 | ||
|
|
179ddc5bd7 | ||
|
|
bfd886604e | ||
|
|
f34033aa9c | ||
|
|
e54745b250 | ||
|
|
1975a35837 | ||
|
|
5db66dcf0e | ||
|
|
cfdf2f9480 | ||
|
|
c13adb04ef | ||
|
|
4f1ab977a1 | ||
|
|
fe59a5ad86 | ||
|
|
a72dc16d85 | ||
|
|
74108a19e5 | ||
|
|
abe0fc16df | ||
|
|
39bda5113b | ||
|
|
49ea5cc78b | ||
|
|
8acde332b2 | ||
|
|
600f7a7388 | ||
|
|
fd007ea4a9 | ||
|
|
b66922b3d5 | ||
|
|
7d981434e1 | ||
|
|
c672d35b4a | ||
|
|
6259c73b33 | ||
|
|
e4b00e832a | ||
|
|
a452aeaf7e | ||
|
|
6e83b90028 | ||
|
|
45ffdce312 | ||
|
|
5485efc9ae | ||
|
|
850360dd2b | ||
|
|
8d3ceaf462 | ||
|
|
bb6c5de697 | ||
|
|
ca4c1b00dc | ||
|
|
dda6d30c12 |
@@ -17,7 +17,7 @@ indocker
|
||||
docker-*
|
||||
phpstan.neon
|
||||
php*xml*
|
||||
infection.json
|
||||
infection*
|
||||
**/test*
|
||||
build*
|
||||
**/.*
|
||||
|
||||
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -10,7 +10,6 @@
|
||||
.gitattributes export-ignore
|
||||
.gitignore export-ignore
|
||||
.phpstorm.meta.php export-ignore
|
||||
.scrutinizer.yml export-ignore
|
||||
.travis.yml export-ignore
|
||||
build.sh export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
|
||||
319
.github/workflows/ci.yml
vendored
Normal file
319
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,319 @@
|
||||
name: Continuous integration
|
||||
|
||||
on:
|
||||
pull_request: null
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Use PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
coverage: none
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer cs
|
||||
|
||||
static-analysis:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Use PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
coverage: none
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer stan
|
||||
|
||||
unit-tests:
|
||||
runs-on: ubuntu-20.04
|
||||
continue-on-error: ${{ matrix.php-version == '8.0' }}
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Use PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
coverage: pcov
|
||||
ini-values: pcov.directory=module
|
||||
- if: ${{ matrix.php-version == '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||
- if: ${{ matrix.php-version != '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- run: composer test:unit:ci
|
||||
- uses: actions/upload-artifact@v2
|
||||
if: ${{ matrix.php-version == '7.4' }}
|
||||
with:
|
||||
name: coverage-unit
|
||||
path: |
|
||||
build/coverage-unit
|
||||
build/coverage-unit.cov
|
||||
|
||||
db-tests-sqlite:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Use PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
coverage: pcov
|
||||
ini-values: pcov.directory=module
|
||||
- if: ${{ matrix.php-version == '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||
- if: ${{ matrix.php-version != '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- run: composer test:db:sqlite:ci
|
||||
- uses: actions/upload-artifact@v2
|
||||
if: ${{ matrix.php-version == '7.4' }}
|
||||
with:
|
||||
name: coverage-db
|
||||
path: |
|
||||
build/coverage-db
|
||||
build/coverage-db.cov
|
||||
|
||||
db-tests-mysql:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Start database server
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db
|
||||
- name: Use PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
coverage: none
|
||||
- if: ${{ matrix.php-version == '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||
- if: ${{ matrix.php-version != '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- run: composer test:db:mysql
|
||||
|
||||
db-tests-maria:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Start database server
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_maria
|
||||
- name: Use PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
coverage: none
|
||||
- if: ${{ matrix.php-version == '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||
- if: ${{ matrix.php-version != '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- run: composer test:db:maria
|
||||
|
||||
db-tests-postgres:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Start database server
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
|
||||
- name: Use PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
coverage: none
|
||||
- if: ${{ matrix.php-version == '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||
- if: ${{ matrix.php-version != '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- run: composer test:db:postgres
|
||||
|
||||
db-tests-ms:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Install MSSQL ODBC
|
||||
run: sudo ./data/infra/ci/install-ms-odbc.sh
|
||||
- name: Start database server
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms
|
||||
- name: Use PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9, pdo_sqlsrv-5.9.0beta2
|
||||
coverage: none
|
||||
- if: ${{ matrix.php-version == '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||
- if: ${{ matrix.php-version != '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- name: Create test database
|
||||
run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
|
||||
- run: composer test:db:ms
|
||||
|
||||
api-tests:
|
||||
runs-on: ubuntu-20.04
|
||||
continue-on-error: ${{ matrix.php-version == '8.0' }}
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Start database server
|
||||
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db
|
||||
- name: Use PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
coverage: pcov
|
||||
ini-values: pcov.directory=module
|
||||
- if: ${{ matrix.php-version == '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||
- if: ${{ matrix.php-version != '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- run: bin/test/run-api-tests.sh
|
||||
- uses: actions/upload-artifact@v2
|
||||
if: ${{ matrix.php-version == '7.4' }}
|
||||
with:
|
||||
name: coverage-api
|
||||
path: |
|
||||
build/coverage-api
|
||||
build/coverage-api.cov
|
||||
|
||||
mutation-tests:
|
||||
needs:
|
||||
- unit-tests
|
||||
- db-tests-sqlite
|
||||
- api-tests
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4', '8.0']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Use PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
extensions: swoole-4.5.9
|
||||
coverage: pcov
|
||||
ini-values: pcov.directory=module
|
||||
- if: ${{ matrix.php-version == '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
|
||||
- if: ${{ matrix.php-version != '8.0' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
path: build
|
||||
- run: composer infect:ci
|
||||
|
||||
upload-coverage:
|
||||
needs:
|
||||
- unit-tests
|
||||
- db-tests-sqlite
|
||||
- api-tests
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['7.4']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Use PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
coverage: pcov
|
||||
ini-values: pcov.directory=module
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
path: build
|
||||
- run: mv build/coverage-unit/coverage-unit.cov build/coverage-unit.cov
|
||||
- run: mv build/coverage-db/coverage-db.cov build/coverage-db.cov
|
||||
- run: mv build/coverage-api/coverage-api.cov build/coverage-api.cov
|
||||
- run: wget https://phar.phpunit.de/phpcov-8.2.0.phar
|
||||
- run: php phpcov-8.2.0.phar merge build --clover build/clover.xml
|
||||
- name: Publish coverage
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
file: ./build/clover.xml
|
||||
|
||||
delete-artifacts:
|
||||
needs:
|
||||
- mutation-tests
|
||||
- upload-coverage
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: geekyeggo/delete-artifact@v1
|
||||
with:
|
||||
name: |
|
||||
coverage-unit
|
||||
coverage-db
|
||||
coverage-api
|
||||
|
||||
build-docker-image:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- uses: marceloprado/has-changed-path@v1
|
||||
id: changed-dockerfile
|
||||
with:
|
||||
paths: ./Dockerfile
|
||||
- if: ${{ steps.changed-dockerfile.outputs.changed == 'true' }}
|
||||
run: docker build -t shlink-docker-image:temp .
|
||||
- if: ${{ steps.changed-dockerfile.outputs.changed != 'true' }}
|
||||
run: echo "Dockerfile didn't change. Skipped"
|
||||
2
.github/workflows/publish-release.yml
vendored
2
.github/workflows/publish-release.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
with:
|
||||
php-version: '7.4' # Publish release with lowest supported PHP version
|
||||
tools: composer
|
||||
extensions: swoole-4.5.5
|
||||
extensions: swoole-4.5.9
|
||||
- name: Generate release assets
|
||||
run: ./build.sh ${GITHUB_REF#refs/tags/v}
|
||||
- name: Publish release with assets
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
tools:
|
||||
external_code_coverage:
|
||||
timeout: 600
|
||||
checks:
|
||||
php:
|
||||
code_rating: true
|
||||
duplication: true
|
||||
build:
|
||||
dependencies:
|
||||
override:
|
||||
- composer install --no-interaction --no-scripts --ignore-platform-reqs
|
||||
nodes:
|
||||
analysis:
|
||||
tests:
|
||||
override:
|
||||
- php-scrutinizer-run
|
||||
56
.travis.yml
56
.travis.yml
@@ -1,56 +0,0 @@
|
||||
dist: bionic
|
||||
|
||||
language: php
|
||||
|
||||
branches:
|
||||
only:
|
||||
- /.*/
|
||||
|
||||
services:
|
||||
- docker
|
||||
|
||||
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:
|
||||
- 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_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/main} --name-only | grep Dockerfile)
|
||||
|
||||
script:
|
||||
- composer ci
|
||||
- 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
|
||||
- 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
|
||||
52
CHANGELOG.md
52
CHANGELOG.md
@@ -4,6 +4,58 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
* [#795](https://github.com/shlinkio/shlink/issues/795) and [#882](https://github.com/shlinkio/shlink/issues/882) Added new roles system to API keys.
|
||||
|
||||
API keys can have any combinations of these two roles now, allowing to limit their interactions:
|
||||
|
||||
* Can interact only with short URLs created with that API key.
|
||||
* Can interact only with short URLs for a specific domain.
|
||||
|
||||
* [#833](https://github.com/shlinkio/shlink/issues/833) Added support to connect through unix socket when using an external MySQL, MariaDB or Postgres database.
|
||||
|
||||
It can be provided during the installation, or as the `DB_UNIX_SOCKET` env var for the docker image.
|
||||
|
||||
* [#869](https://github.com/shlinkio/shlink/issues/869) Added support for Mercure Hub 0.10.
|
||||
* [#896](https://github.com/shlinkio/shlink/issues/896) Added support for unicode characters in custom slugs.
|
||||
* [#930](https://github.com/shlinkio/shlink/issues/930) Added new `bin/set-option` script that allows changing individual configuration options on existing shlink instances.
|
||||
* [#877](https://github.com/shlinkio/shlink/issues/877) Improved API tests on CORS, and "refined" middleware handling it.
|
||||
|
||||
### Changed
|
||||
* [#912](https://github.com/shlinkio/shlink/issues/912) Changed error templates to be plain html files, removing the dependency on `league/plates` package.
|
||||
|
||||
### Deprecated
|
||||
* [#917](https://github.com/shlinkio/shlink/issues/917) Deprecated `/{shortCode}/qr-code/{size}` URL, in favor of providing the size in the query instead, `/{shortCode}/qr-code?size={size}`.
|
||||
* [#924](https://github.com/shlinkio/shlink/issues/924) Deprecated mechanism to provide config options to the docker image through volumes. Use the env vars instead as a direct replacement.
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## [2.4.2] - 2020-11-22
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#904](https://github.com/shlinkio/shlink/issues/904) Explicitly added missing "Domains" and "Integrations" tags to swagger docs.
|
||||
* [#901](https://github.com/shlinkio/shlink/issues/901) Ensured domains which are not in use on any short URL are not returned on the list of domains.
|
||||
* [#899](https://github.com/shlinkio/shlink/issues/899) Avoided filesystem errors produced while downloading geolite DB files on several shlink instances that share the same filesystem.
|
||||
* [#827](https://github.com/shlinkio/shlink/issues/827) Fixed swoole config getting loaded in config cache if a console command is run before any web execution, when swoole extension is enabled, making subsequent non-swoole web requests fail.
|
||||
|
||||
|
||||
## [2.4.1] - 2020-11-10
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
@@ -2,7 +2,7 @@ FROM php:7.4.11-alpine3.12 as base
|
||||
|
||||
ARG SHLINK_VERSION=2.4.0
|
||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||
ENV SWOOLE_VERSION 4.5.5
|
||||
ENV SWOOLE_VERSION 4.5.9
|
||||
ENV LC_ALL "C"
|
||||
|
||||
WORKDIR /etc/shlink
|
||||
|
||||
18
README.md
18
README.md
@@ -1,10 +1,9 @@
|
||||

|
||||
|
||||
[](https://travis-ci.com/shlinkio/shlink)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink/)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink/)
|
||||
[](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Continuous+integration%22)
|
||||
[](https://app.codecov.io/gh/shlinkio/shlink)
|
||||
[](https://packagist.org/packages/shlinkio/shlink)
|
||||
[](https://hub.docker.com/r/shlinkio/shlink/)
|
||||
[](https://hub.docker.com/r/shlinkio/shlink/)
|
||||
[](https://github.com/shlinkio/shlink/blob/main/LICENSE)
|
||||
[](https://slnk.to/donate)
|
||||
|
||||
@@ -22,6 +21,7 @@ A PHP-based self-hosted URL shortener that can be used to serve shortened URLs u
|
||||
- [Serve](#serve)
|
||||
- [Bonus](#bonus)
|
||||
- [Update to new version](#update-to-new-version)
|
||||
- [Update a configuration option](#update-a-configuration-option)
|
||||
- [Using a docker image](#using-a-docker-image)
|
||||
- [Using shlink](#using-shlink)
|
||||
- [Shlink CLI Help](#shlink-cli-help)
|
||||
@@ -225,6 +225,16 @@ The `bin/update` will use the location from previous shlink version to import th
|
||||
|
||||
**Important!** It is recommended that you don't skip any version when using this process. The update tool gets better on every version, but older versions might make assumptions.
|
||||
|
||||
### Update a configuration option
|
||||
|
||||
Sometimes you need to update the configuration on your shlink instance. Maybe you want to change the GeoLite2 license key, or move from http to https.
|
||||
|
||||
In order to do that, run `bin/set-option` and follow the instructions. You will be asked to select the option to change, and then you will be asked to provide the new value.
|
||||
|
||||
This script will take care of updating that value without changing anything else, and it will also delete the configuration cache so that the new value is applied.
|
||||
|
||||
> This script will fail if you didn't run `bin/install` at least once.
|
||||
|
||||
## Using a docker image
|
||||
|
||||
Starting with version 1.15.0, an official docker image is provided. You can learn how to use it by reading [the docs](docker/README.md).
|
||||
|
||||
14
bin/set-option
Executable file
14
bin/set-option
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink;
|
||||
|
||||
use Shlinkio\Shlink\Installer\Command\SetOptionCommand;
|
||||
|
||||
use function chdir;
|
||||
use function dirname;
|
||||
|
||||
chdir(dirname(__DIR__));
|
||||
[,, $installer] = require __DIR__ . '/../vendor/shlinkio/shlink-installer/bin/run.php';
|
||||
$installer(SetOptionCommand::NAME);
|
||||
@@ -15,66 +15,65 @@
|
||||
"php": "^7.4",
|
||||
"ext-json": "*",
|
||||
"ext-pdo": "*",
|
||||
"akrabat/ip-address-middleware": "^1.0",
|
||||
"akrabat/ip-address-middleware": "^2.0",
|
||||
"cakephp/chronos": "^2.0",
|
||||
"cocur/slugify": "^4.0",
|
||||
"doctrine/cache": "^1.9",
|
||||
"doctrine/dbal": "^2.10",
|
||||
"doctrine/migrations": "^3.0.1",
|
||||
"doctrine/orm": "^2.7",
|
||||
"doctrine/migrations": "^3.0.2",
|
||||
"doctrine/orm": "^2.8",
|
||||
"endroid/qr-code": "^3.6",
|
||||
"geoip2/geoip2": "^2.9",
|
||||
"guzzlehttp/guzzle": "^7.0",
|
||||
"happyr/doctrine-specification": "2.0.x-dev#cb116d3 as 2.0",
|
||||
"laminas/laminas-config": "^3.3",
|
||||
"laminas/laminas-config-aggregator": "^1.1",
|
||||
"laminas/laminas-diactoros": "^2.1.3",
|
||||
"laminas/laminas-inputfilter": "^2.10",
|
||||
"laminas/laminas-paginator": "^2.8",
|
||||
"laminas/laminas-servicemanager": "^3.4",
|
||||
"laminas/laminas-servicemanager": "^3.6",
|
||||
"laminas/laminas-stdlib": "^3.2",
|
||||
"lcobucci/jwt": "^4.0@alpha",
|
||||
"lcobucci/jwt": "^4.0",
|
||||
"league/uri": "^6.2",
|
||||
"lstrojny/functional-php": "^1.9",
|
||||
"lstrojny/functional-php": "^1.15",
|
||||
"mezzio/mezzio": "^3.2",
|
||||
"mezzio/mezzio-fastroute": "^3.0",
|
||||
"mezzio/mezzio-fastroute": "^3.1",
|
||||
"mezzio/mezzio-helpers": "^5.3",
|
||||
"mezzio/mezzio-platesrenderer": "^2.1",
|
||||
"mezzio/mezzio-problem-details": "^1.1",
|
||||
"mezzio/mezzio-swoole": "^2.6.4",
|
||||
"monolog/monolog": "^2.0",
|
||||
"nikolaposa/monolog-factory": "^3.0",
|
||||
"ocramius/proxy-manager": "^2.7.0",
|
||||
"phly/phly-event-dispatcher": "^1.0",
|
||||
"php-middleware/request-id": "^4.0",
|
||||
"nikolaposa/monolog-factory": "^3.1",
|
||||
"ocramius/proxy-manager": "^2.11",
|
||||
"php-middleware/request-id": "^4.1",
|
||||
"predis/predis": "^1.1",
|
||||
"pugx/shortid-php": "^0.5",
|
||||
"pugx/shortid-php": "^0.7",
|
||||
"ramsey/uuid": "^3.9",
|
||||
"shlinkio/shlink-common": "^3.3.0",
|
||||
"shlinkio/shlink-common": "dev-main#1311861 as 3.4",
|
||||
"shlinkio/shlink-config": "^1.0",
|
||||
"shlinkio/shlink-event-dispatcher": "^1.4",
|
||||
"shlinkio/shlink-importer": "^2.0.1",
|
||||
"shlinkio/shlink-installer": "^5.1.0",
|
||||
"shlinkio/shlink-event-dispatcher": "^1.6",
|
||||
"shlinkio/shlink-importer": "^2.1",
|
||||
"shlinkio/shlink-installer": "^5.3",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.5",
|
||||
"symfony/console": "^5.1",
|
||||
"symfony/filesystem": "^5.1",
|
||||
"symfony/lock": "^5.1",
|
||||
"symfony/mercure": "^0.3.0",
|
||||
"symfony/mercure": "^0.4.1",
|
||||
"symfony/process": "^5.1",
|
||||
"symfony/string": "^5.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"devster/ubench": "^2.0",
|
||||
"dms/phpunit-arraysubset-asserts": "^0.2.0",
|
||||
"devster/ubench": "^2.1",
|
||||
"dms/phpunit-arraysubset-asserts": "^0.2.1",
|
||||
"eaglewu/swoole-ide-helper": "dev-master",
|
||||
"infection/infection": "^0.20.0",
|
||||
"infection/infection": "^0.20.2",
|
||||
"phpspec/prophecy-phpunit": "^2.0",
|
||||
"phpstan/phpstan": "^0.12.52",
|
||||
"phpstan/phpstan": "^0.12.64",
|
||||
"phpunit/php-code-coverage": "^9.2",
|
||||
"phpunit/phpunit": "^9.4",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"shlinkio/php-coding-standard": "~2.1.1",
|
||||
"shlinkio/shlink-test-utils": "^1.5",
|
||||
"symfony/var-dumper": "^5.1"
|
||||
"shlinkio/shlink-test-utils": "^1.7",
|
||||
"symfony/var-dumper": "^5.2",
|
||||
"veewee/composer-run-parallel": "^0.1.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -107,6 +106,10 @@
|
||||
"@test:ci",
|
||||
"@infect:ci"
|
||||
],
|
||||
"ci:parallel": [
|
||||
"@parallel cs stan test:unit:ci test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
|
||||
"@parallel test:api infect:ci:unit infect:ci:db"
|
||||
],
|
||||
"cs": "phpcs",
|
||||
"cs:fix": "phpcbf",
|
||||
"stan": "phpstan analyse module/*/src/ module/*/config config docker/config data/migrations --level=6",
|
||||
@@ -117,17 +120,12 @@
|
||||
],
|
||||
"test:ci": [
|
||||
"@test:unit:ci",
|
||||
"@test:db"
|
||||
"@test:db",
|
||||
"@test:api"
|
||||
],
|
||||
"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:ci",
|
||||
"@test:db:mysql",
|
||||
"@test:db:maria",
|
||||
"@test:db:postgres",
|
||||
"@test:db:ms"
|
||||
],
|
||||
"test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
|
||||
"test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
|
||||
"test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml",
|
||||
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
|
||||
@@ -136,21 +134,19 @@
|
||||
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
|
||||
"test:api": "bin/test/run-api-tests.sh",
|
||||
"test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html",
|
||||
"infect": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered",
|
||||
"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:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --skip-initial-tests",
|
||||
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80",
|
||||
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json",
|
||||
"infect:ci": "@parallel infect:ci:unit infect:ci:db",
|
||||
"infect:test": [
|
||||
"@test:unit:ci",
|
||||
"@test:db:sqlite:ci",
|
||||
"@parallel test:unit:ci test:db:sqlite:ci",
|
||||
"@infect:ci"
|
||||
],
|
||||
"clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php"
|
||||
},
|
||||
"scripts-descriptions": {
|
||||
"ci": "<fg=blue;options=bold>Alias for \"cs\", \"stan\", \"test:ci\" and \"infect:ci\"</>",
|
||||
"ci:parallel": "<fg=blue;options=bold>Same as \"ci\", but parallelizing tasks as much as possible</>",
|
||||
"cs": "<fg=blue;options=bold>Checks coding styles</>",
|
||||
"cs:fix": "<fg=blue;options=bold>Fixes coding styles, when possible</>",
|
||||
"stan": "<fg=blue;options=bold>Inspects code with phpstan</>",
|
||||
@@ -160,14 +156,17 @@
|
||||
"test:unit:ci": "<fg=blue;options=bold>Runs unit test suites, generating all needed reports and logs for CI envs</>",
|
||||
"test:db": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL, MariaDB, PostgreSQL and MsSQL</>",
|
||||
"test:db:sqlite": "<fg=blue;options=bold>Runs database test suites on a SQLite database</>",
|
||||
"test:db:sqlite:ci": "<fg=blue;options=bold>Runs database test suites on a SQLite database, generating all needed reports and logs for CI envs</>",
|
||||
"test:db:mysql": "<fg=blue;options=bold>Runs database test suites on a MySQL database</>",
|
||||
"test:db:maria": "<fg=blue;options=bold>Runs database test suites on a MariaDB database</>",
|
||||
"test:db:postgres": "<fg=blue;options=bold>Runs database test suites on a PostgreSQL database</>",
|
||||
"test:db:ms": "<fg=blue;options=bold>Runs database test suites on a Miscrosoft SQL Server database</>",
|
||||
"test:api": "<fg=blue;options=bold>Runs API test suites</>",
|
||||
"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:test": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing</>",
|
||||
"infect:ci": "<fg=blue;options=bold>Checks unit and db tests quality applying mutation testing with existing reports and logs</>",
|
||||
"infect:ci:unit": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing with existing reports and logs</>",
|
||||
"infect:ci:db": "<fg=blue;options=bold>Checks db tests quality applying mutation testing with existing reports and logs</>",
|
||||
"infect:test": "<fg=blue;options=bold>Runs unit and db tests, then checks tests quality applying mutation testing</>",
|
||||
"clean:dev": "<fg=blue;options=bold>Deletes artifacts which are gitignored and could affect dev env</>"
|
||||
},
|
||||
"config": {
|
||||
|
||||
@@ -7,6 +7,9 @@ use Laminas\ConfigAggregator\ConfigAggregator;
|
||||
return [
|
||||
|
||||
'debug' => false,
|
||||
ConfigAggregator::ENABLE_CACHE => true,
|
||||
|
||||
// Disabling config cache for cli, ensures it's never used for swoole and also that console commands don't generate
|
||||
// a cache file that's then used by non-swoole web executions
|
||||
ConfigAggregator::ENABLE_CACHE => PHP_SAPI !== 'cli',
|
||||
|
||||
];
|
||||
|
||||
11
config/autoload/cors.global.php
Normal file
11
config/autoload/cors.global.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
'cors' => [
|
||||
'max_age' => 3600,
|
||||
],
|
||||
|
||||
];
|
||||
@@ -4,12 +4,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Common;
|
||||
|
||||
use Happyr\DoctrineSpecification\EntitySpecificationRepository;
|
||||
|
||||
return [
|
||||
|
||||
'entity_manager' => [
|
||||
'orm' => [
|
||||
'proxies_dir' => 'data/proxies',
|
||||
'load_mappings_using_functional_style' => true,
|
||||
'default_repository_classname' => EntitySpecificationRepository::class,
|
||||
],
|
||||
'connection' => [
|
||||
'user' => '',
|
||||
|
||||
@@ -6,8 +6,8 @@ return [
|
||||
|
||||
'geolite2' => [
|
||||
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
|
||||
'temp_dir' => sys_get_temp_dir(),
|
||||
'license_key' => 'G4Lm0C60yJsnkdPi',
|
||||
'temp_dir' => __DIR__ . '/../../data',
|
||||
'license_key' => 'G4Lm0C60yJsnkdPi', // Deprecated. Remove hardcoded license on v3
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -14,6 +14,7 @@ return [
|
||||
Option\Database\DatabasePortConfigOption::class,
|
||||
Option\Database\DatabaseUserConfigOption::class,
|
||||
Option\Database\DatabasePasswordConfigOption::class,
|
||||
Option\Database\DatabaseUnixSocketConfigOption::class,
|
||||
Option\Database\DatabaseSqlitePathConfigOption::class,
|
||||
Option\Database\DatabaseMySqlOptionsConfigOption::class,
|
||||
Option\UrlShortener\ShortDomainHostConfigOption::class,
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
'templates' => [
|
||||
'extension' => 'phtml',
|
||||
],
|
||||
|
||||
'plates' => [
|
||||
'extensions' => [
|
||||
// extension service names or instances
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
@@ -15,7 +15,6 @@ return (new ConfigAggregator\ConfigAggregator([
|
||||
Mezzio\ConfigProvider::class,
|
||||
Mezzio\Router\ConfigProvider::class,
|
||||
Mezzio\Router\FastRouteRouter\ConfigProvider::class,
|
||||
Mezzio\Plates\ConfigProvider::class,
|
||||
Mezzio\Swoole\ConfigProvider::class,
|
||||
ProblemDetails\ConfigProvider::class,
|
||||
Diactoros\ConfigProvider::class,
|
||||
|
||||
@@ -36,7 +36,7 @@ if ($isApiTest) {
|
||||
|
||||
$buildDbConnection = function (): array {
|
||||
$driver = env('DB_DRIVER', 'sqlite');
|
||||
$isCi = env('TRAVIS', false);
|
||||
$isCi = env('CI', false);
|
||||
$getMysqlHost = fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria');
|
||||
$getCiMysqlPort = fn (string $driver) => $driver === 'mysql' ? '3307' : '3308';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
set -ex
|
||||
|
||||
curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add -
|
||||
curl https://packages.microsoft.com/config/ubuntu/16.04/prod.list > /etc/apt/sources.list.d/mssql-release.list
|
||||
curl https://packages.microsoft.com/config/ubuntu/20.04/prod.list > /etc/apt/sources.list.d/mssql-release.list
|
||||
apt-get update
|
||||
ACCEPT_EULA=Y apt-get install msodbcsql17
|
||||
apt-get install unixodbc-dev
|
||||
|
||||
@@ -4,7 +4,7 @@ MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
ENV APCU_VERSION 5.1.18
|
||||
ENV APCU_BC_VERSION 1.0.5
|
||||
ENV INOTIFY_VERSION 2.0.0
|
||||
ENV SWOOLE_VERSION 4.5.5
|
||||
ENV SWOOLE_VERSION 4.5.9
|
||||
|
||||
RUN apk update
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ final class Version20180913205455 extends AbstractMigration
|
||||
}
|
||||
|
||||
try {
|
||||
return (string) IpAddress::fromString($addr)->getObfuscatedCopy();
|
||||
return (string) IpAddress::fromString($addr)->getAnonymizedCopy();
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
52
data/migrations/Version20210102174433.php
Normal file
52
data/migrations/Version20210102174433.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20210102174433 extends AbstractMigration
|
||||
{
|
||||
private const TABLE_NAME = 'api_key_roles';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->skipIf($schema->hasTable(self::TABLE_NAME));
|
||||
|
||||
$table = $schema->createTable(self::TABLE_NAME);
|
||||
$table->addColumn('id', Types::BIGINT, [
|
||||
'unsigned' => true,
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
]);
|
||||
$table->setPrimaryKey(['id']);
|
||||
|
||||
$table->addColumn('role_name', Types::STRING, [
|
||||
'length' => 256,
|
||||
'notnull' => true,
|
||||
]);
|
||||
$table->addColumn('meta', Types::JSON, [
|
||||
'notnull' => true,
|
||||
]);
|
||||
|
||||
$table->addColumn('api_key_id', Types::BIGINT, [
|
||||
'unsigned' => true,
|
||||
'notnull' => true,
|
||||
]);
|
||||
$table->addForeignKeyConstraint('api_keys', ['api_key_id'], ['id'], [
|
||||
'onDelete' => 'CASCADE',
|
||||
'onUpdate' => 'RESTRICT',
|
||||
]);
|
||||
$table->addUniqueIndex(['role_name', 'api_key_id'], 'UQ_role_plus_api_key');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->skipIf(! $schema->hasTable(self::TABLE_NAME));
|
||||
$schema->getTable(self::TABLE_NAME)->dropIndex('UQ_role_plus_api_key');
|
||||
$schema->dropTable(self::TABLE_NAME);
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ services:
|
||||
|
||||
shlink_mercure:
|
||||
container_name: shlink_mercure
|
||||
image: dunglas/mercure:v0.9
|
||||
image: dunglas/mercure:v0.10
|
||||
ports:
|
||||
- "3080:80"
|
||||
environment:
|
||||
|
||||
@@ -53,7 +53,7 @@ docker exec -it shlink_container shlink
|
||||
|
||||
## Use an external DB
|
||||
|
||||
The image comes with a working sqlite database, but in production you will probably want to usa a distributed database.
|
||||
The image comes with a working sqlite database, but in production, it's strongly recommended using a distributed database.
|
||||
|
||||
It is possible to use a set of env vars to make this shlink instance interact with an external MySQL, MariaDB, PostgreSQL or Microsoft SQL Server database.
|
||||
|
||||
@@ -157,6 +157,7 @@ This is the complete list of supported env vars:
|
||||
* **mysql** or **maria** -> `3306`
|
||||
* **postgres** -> `5432`
|
||||
* **mssql** -> `1433`
|
||||
* `DB_UNIX_SOCKET`: Alternatively to the `DB_HOST`, you can provide this to connect through unix sockets when using `mysql`, `maria` or `postgres` drivers.
|
||||
* `DISABLE_TRACK_PARAM`: The name of a query param that can be used to visit short URLs avoiding the visit to be tracked. This feature won't be available if not value is provided.
|
||||
* `DELETE_SHORT_URL_THRESHOLD`: The amount of visits on short URLs which will not allow them to be deleted. Defaults to `15`.
|
||||
* `VALIDATE_URLS`: Boolean which tells if shlink should validate a status 20x is returned (after following redirects) when trying to shorten a URL. Defaults to `false`.
|
||||
@@ -215,7 +216,11 @@ docker run \
|
||||
shlinkio/shlink:stable
|
||||
```
|
||||
|
||||
## Provide config via volumes
|
||||
## [DEPRECATED] Provide config via volumes
|
||||
|
||||
> As of v2.5.0, providing config through volumes is deprecated, and no new options will be added anymore. Use env vars instead.
|
||||
>
|
||||
> Support for config options through volumes will be removed in Shlink v3.0.0
|
||||
|
||||
Rather than providing custom configuration via env vars, it is also possible ot provide config files in json format.
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ $helper = new class {
|
||||
public function getDbConfig(): array
|
||||
{
|
||||
$driver = env('DB_DRIVER');
|
||||
$isMysql = contains(['maria', 'mysql'], $driver);
|
||||
if ($driver === null || $driver === 'sqlite') {
|
||||
return [
|
||||
'driver' => 'pdo_sqlite',
|
||||
@@ -41,7 +42,7 @@ $helper = new class {
|
||||
];
|
||||
}
|
||||
|
||||
$driverOptions = ! contains(['maria', 'mysql'], $driver) ? [] : [
|
||||
$driverOptions = ! $isMysql ? [] : [
|
||||
// 1002 -> PDO::MYSQL_ATTR_INIT_COMMAND
|
||||
1002 => 'SET NAMES utf8',
|
||||
// 1000 -> PDO::MYSQL_ATTR_USE_BUFFERED_QUERY
|
||||
@@ -52,9 +53,10 @@ $helper = new class {
|
||||
'dbname' => env('DB_NAME', 'shlink'),
|
||||
'user' => env('DB_USER'),
|
||||
'password' => env('DB_PASSWORD'),
|
||||
'host' => env('DB_HOST'),
|
||||
'host' => env('DB_HOST', $driver === 'postgres' ? env('DB_UNIX_SOCKET') : null),
|
||||
'port' => env('DB_PORT', self::DB_PORTS_MAP[$driver]),
|
||||
'driverOptions' => $driverOptions,
|
||||
'unix_socket' => $isMysql ? env('DB_UNIX_SOCKET') : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -99,8 +101,6 @@ $helper = new class {
|
||||
|
||||
return [
|
||||
|
||||
'config_cache_enabled' => false,
|
||||
|
||||
'app_options' => [
|
||||
'disable_track_param' => env('DISABLE_TRACK_PARAM'),
|
||||
],
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
"Short URLs"
|
||||
],
|
||||
"summary": "Create short URL",
|
||||
"description": "Creates a new short URL.<br></br>**Param findIfExists:**: Starting with v1.16, this new param allows to force shlink to return existing short URLs when found based on provided params, instead of creating a new one. However, it might add complexity and have unexpected outputs.\n\nThese are the use cases:\n* Only the long URL is provided: It will return the newest match or create a new short URL if none is found.\n* Long url and custom slug are provided: It will return the short URL when both params match, return an error when the slug is in use for another long URL, or create a new short URL otherwise.\n* Any of the above but including other params (tags, validSince, validUntil, maxVisits): It will behave the same as the previous two cases, but it will try to exactly match existing results using all the params. If any of them does not match, it will try to create a new short URL.",
|
||||
"description": "Creates a new short URL.<br></br>**Param findIfExists**: This new param allows to force shlink to return existing short URLs when found based on provided params, instead of creating a new one. However, it might add complexity and have unexpected outputs.\n\nThese are the use cases:\n* Only the long URL is provided: It will return the newest match or create a new short URL if none is found.\n* Long url and custom slug are provided: It will return the short URL when both params match, return an error when the slug is in use for another long URL, or create a new short URL otherwise.\n* Any of the above but including other params (tags, validSince, validUntil, maxVisits): It will behave the same as the previous two cases, but it will try to exactly match existing results using all the params. If any of them does not match, it will try to create a new short URL.",
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
|
||||
@@ -232,6 +232,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "The API key you used does not have permissions to rename tags.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "There's no tag found with the name provided in oldName param.",
|
||||
"content": {
|
||||
@@ -298,6 +308,16 @@
|
||||
"204": {
|
||||
"description": "Tags properly deleted"
|
||||
},
|
||||
"403": {
|
||||
"description": "The API key you used does not have permissions to delete tags.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Unexpected error.",
|
||||
"content": {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"in": "path",
|
||||
"in": "query",
|
||||
"description": "The size of the image to be returned.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
|
||||
66
docs/swagger/paths/{shortCode}_qr-code_{size}.json
Normal file
66
docs/swagger/paths/{shortCode}_qr-code_{size}.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"get": {
|
||||
"operationId": "shortUrlQrCodeSize",
|
||||
"deprecated": true,
|
||||
"tags": [
|
||||
"URL Shortener"
|
||||
],
|
||||
"summary": "Short URL QR code",
|
||||
"description": "Generates a QR code image pointing to a short URL",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "shortCode",
|
||||
"in": "path",
|
||||
"description": "The short code to resolve.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"in": "path",
|
||||
"description": "The size of the image to be returned.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 50,
|
||||
"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": {
|
||||
"200": {
|
||||
"description": "QR code in PNG format",
|
||||
"content": {
|
||||
"image/png": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
},
|
||||
"image/svg+xml": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,14 @@
|
||||
"name": "Visits",
|
||||
"description": "Operations to manage visits on short URLs"
|
||||
},
|
||||
{
|
||||
"name": "Domains",
|
||||
"description": "Operations to manage domains used on short URLs"
|
||||
},
|
||||
{
|
||||
"name": "Integrations",
|
||||
"description": "Handle services with which shlink is integrated"
|
||||
},
|
||||
{
|
||||
"name": "Monitoring",
|
||||
"description": "Public endpoints designed to monitor the service"
|
||||
@@ -108,6 +116,9 @@
|
||||
},
|
||||
"/{shortCode}/qr-code": {
|
||||
"$ref": "paths/{shortCode}_qr-code.json"
|
||||
},
|
||||
"/{shortCode}/qr-code/{size}": {
|
||||
"$ref": "paths/{shortCode}_qr-code_{size}.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
23
infection-db.json
Normal file
23
infection-db.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"source": {
|
||||
"directories": [
|
||||
"module/*/src"
|
||||
]
|
||||
},
|
||||
"timeout": 5,
|
||||
"logs": {
|
||||
"text": "build/infection-db/infection-log.txt",
|
||||
"summary": "build/infection-db/summary-log.txt",
|
||||
"debug": "build/infection-db/debug-log.txt"
|
||||
},
|
||||
"tmpDir": "build/infection-db/temp",
|
||||
"phpUnit": {
|
||||
"configDir": "."
|
||||
},
|
||||
"testFrameworkOptions": "--configuration=phpunit-db.xml",
|
||||
"mutators": {
|
||||
"@default": true,
|
||||
"IdenticalEqual": false,
|
||||
"NotIdenticalNotEqual": false
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@
|
||||
},
|
||||
"timeout": 5,
|
||||
"logs": {
|
||||
"text": "build/infection/infection-log.txt",
|
||||
"summary": "build/infection/summary-log.txt",
|
||||
"debug": "build/infection/debug-log.txt"
|
||||
"text": "build/infection-unit/infection-log.txt",
|
||||
"summary": "build/infection-unit/summary-log.txt",
|
||||
"debug": "build/infection-unit/debug-log.txt"
|
||||
},
|
||||
"tmpDir": "build/infection/temp",
|
||||
"tmpDir": "build/infection-unit/temp",
|
||||
"phpUnit": {
|
||||
"configDir": "."
|
||||
},
|
||||
|
||||
@@ -8,7 +8,6 @@ use Doctrine\DBAL\Connection;
|
||||
use GeoIp2\Database\Reader;
|
||||
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;
|
||||
@@ -32,7 +31,8 @@ return [
|
||||
SymfonyCli\Helper\ProcessHelper::class => ProcessHelperFactory::class,
|
||||
PhpExecutableFinder::class => InvokableFactory::class,
|
||||
|
||||
GeolocationDbUpdater::class => ConfigAbstractFactory::class,
|
||||
Util\GeolocationDbUpdater::class => ConfigAbstractFactory::class,
|
||||
ApiKey\RoleResolver::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\ShortUrl\GenerateShortUrlCommand::class => ConfigAbstractFactory::class,
|
||||
Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class,
|
||||
@@ -59,7 +59,8 @@ return [
|
||||
],
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY],
|
||||
Util\GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY],
|
||||
ApiKey\RoleResolver::class => [DomainService::class],
|
||||
|
||||
Command\ShortUrl\GenerateShortUrlCommand::class => [
|
||||
Service\UrlShortener::class,
|
||||
@@ -75,10 +76,10 @@ return [
|
||||
Visit\VisitLocator::class,
|
||||
IpLocationResolverInterface::class,
|
||||
LockFactory::class,
|
||||
GeolocationDbUpdater::class,
|
||||
Util\GeolocationDbUpdater::class,
|
||||
],
|
||||
|
||||
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class],
|
||||
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
|
||||
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
|
||||
Command\Api\ListKeysCommand::class => [ApiKeyService::class],
|
||||
|
||||
@@ -87,7 +88,7 @@ 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\Domain\ListDomainsCommand::class => [DomainService::class],
|
||||
|
||||
Command\Db\CreateDatabaseCommand::class => [
|
||||
LockFactory::class,
|
||||
|
||||
36
module/CLI/src/ApiKey/RoleResolver.php
Normal file
36
module/CLI/src/ApiKey/RoleResolver.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\ApiKey;
|
||||
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
class RoleResolver implements RoleResolverInterface
|
||||
{
|
||||
private DomainServiceInterface $domainService;
|
||||
|
||||
public function __construct(DomainServiceInterface $domainService)
|
||||
{
|
||||
$this->domainService = $domainService;
|
||||
}
|
||||
|
||||
public function determineRoles(InputInterface $input): array
|
||||
{
|
||||
$domainAuthority = $input->getOption('domain-only');
|
||||
$author = $input->getOption('author-only');
|
||||
|
||||
$roleDefinitions = [];
|
||||
if ($author) {
|
||||
$roleDefinitions[] = RoleDefinition::forAuthoredShortUrls();
|
||||
}
|
||||
if ($domainAuthority !== null) {
|
||||
$domain = $this->domainService->getOrCreate($domainAuthority);
|
||||
$roleDefinitions[] = RoleDefinition::forDomain($domain);
|
||||
}
|
||||
|
||||
return $roleDefinitions;
|
||||
}
|
||||
}
|
||||
19
module/CLI/src/ApiKey/RoleResolverInterface.php
Normal file
19
module/CLI/src/ApiKey/RoleResolverInterface.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\ApiKey;
|
||||
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
interface RoleResolverInterface
|
||||
{
|
||||
public const AUTHOR_ONLY_PARAM = 'author-only';
|
||||
public const DOMAIN_ONLY_PARAM = 'domain-only';
|
||||
|
||||
/**
|
||||
* @return RoleDefinition[]
|
||||
*/
|
||||
public function determineRoles(InputInterface $input): array;
|
||||
}
|
||||
@@ -5,7 +5,10 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\CLI\Command\Api;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -13,6 +16,7 @@ use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function Shlinkio\Shlink\Core\arrayToString;
|
||||
use function sprintf;
|
||||
|
||||
class GenerateKeyCommand extends Command
|
||||
@@ -20,15 +24,35 @@ class GenerateKeyCommand extends Command
|
||||
public const NAME = 'api-key:generate';
|
||||
|
||||
private ApiKeyServiceInterface $apiKeyService;
|
||||
private RoleResolverInterface $roleResolver;
|
||||
|
||||
public function __construct(ApiKeyServiceInterface $apiKeyService)
|
||||
public function __construct(ApiKeyServiceInterface $apiKeyService, RoleResolverInterface $roleResolver)
|
||||
{
|
||||
$this->apiKeyService = $apiKeyService;
|
||||
parent::__construct();
|
||||
$this->apiKeyService = $apiKeyService;
|
||||
$this->roleResolver = $roleResolver;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$authorOnly = RoleResolverInterface::AUTHOR_ONLY_PARAM;
|
||||
$domainOnly = RoleResolverInterface::DOMAIN_ONLY_PARAM;
|
||||
$help = <<<HELP
|
||||
The <info>%command.name%</info> generates a new valid API key.
|
||||
|
||||
<info>%command.full_name%</info>
|
||||
|
||||
You can optionally set its expiration date with <comment>--expirationDate</comment> or <comment>-e</comment>:
|
||||
|
||||
<info>%command.full_name% --expirationDate 2020-01-01</info>
|
||||
|
||||
You can also set roles to the API key:
|
||||
|
||||
* Can interact with short URLs created with this API key: <info>%command.full_name% --{$authorOnly}</info>
|
||||
* Can interact with short URLs for one domain: <info>%command.full_name% --{$domainOnly}=example.com</info>
|
||||
* Both: <info>%command.full_name% --{$authorOnly} --{$domainOnly}=example.com</info>
|
||||
HELP;
|
||||
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Generates a new valid API key.')
|
||||
@@ -37,15 +61,42 @@ class GenerateKeyCommand extends Command
|
||||
'e',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The date in which the API key should expire. Use any valid PHP format.',
|
||||
);
|
||||
)
|
||||
->addOption(
|
||||
$authorOnly,
|
||||
'a',
|
||||
InputOption::VALUE_NONE,
|
||||
sprintf('Adds the "%s" role to the new API key.', Role::AUTHORED_SHORT_URLS),
|
||||
)
|
||||
->addOption(
|
||||
$domainOnly,
|
||||
'd',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
sprintf('Adds the "%s" role to the new API key, with the domain provided.', Role::DOMAIN_SPECIFIC),
|
||||
)
|
||||
->setHelp($help);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$expirationDate = $input->getOption('expirationDate');
|
||||
$apiKey = $this->apiKeyService->create(isset($expirationDate) ? Chronos::parse($expirationDate) : null);
|
||||
$apiKey = $this->apiKeyService->create(
|
||||
isset($expirationDate) ? Chronos::parse($expirationDate) : null,
|
||||
...$this->roleResolver->determineRoles($input),
|
||||
);
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->success(sprintf('Generated API key: "%s"', $apiKey->toString()));
|
||||
|
||||
if (! $apiKey->isAdmin()) {
|
||||
ShlinkTable::fromOutput($io)->render(
|
||||
['Role name', 'Role metadata'],
|
||||
$apiKey->mapRoles(fn (string $name, array $meta) => [$name, arrayToString($meta, 0)]),
|
||||
null,
|
||||
'Roles',
|
||||
);
|
||||
}
|
||||
|
||||
(new SymfonyStyle($input, $output))->success(sprintf('Generated API key: "%s"', $apiKey));
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Api;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
@@ -14,7 +15,8 @@ use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
use function array_filter;
|
||||
use function array_map;
|
||||
use function Functional\map;
|
||||
use function implode;
|
||||
use function sprintf;
|
||||
|
||||
class ListKeysCommand extends Command
|
||||
@@ -50,7 +52,7 @@ class ListKeysCommand extends Command
|
||||
{
|
||||
$enabledOnly = $input->getOption('enabledOnly');
|
||||
|
||||
$rows = array_map(function (ApiKey $apiKey) use ($enabledOnly) {
|
||||
$rows = map($this->apiKeyService->listKeys($enabledOnly), function (ApiKey $apiKey) use ($enabledOnly) {
|
||||
$expiration = $apiKey->getExpirationDate();
|
||||
$messagePattern = $this->determineMessagePattern($apiKey);
|
||||
|
||||
@@ -60,13 +62,21 @@ class ListKeysCommand extends Command
|
||||
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
|
||||
}
|
||||
$rowData[] = $expiration !== null ? $expiration->toAtomString() : '-';
|
||||
$rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles(
|
||||
fn (string $roleName, array $meta) =>
|
||||
empty($meta)
|
||||
? Role::toFriendlyName($roleName)
|
||||
: sprintf('%s: %s', Role::toFriendlyName($roleName), Role::domainAuthorityFromMeta($meta)),
|
||||
));
|
||||
|
||||
return $rowData;
|
||||
}, $this->apiKeyService->listKeys($enabledOnly));
|
||||
});
|
||||
|
||||
ShlinkTable::fromOutput($output)->render(array_filter([
|
||||
'Key',
|
||||
! $enabledOnly ? 'Is enabled' : null,
|
||||
'Expiration date',
|
||||
'Roles',
|
||||
]), $rows);
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
@@ -80,8 +90,6 @@ class ListKeysCommand extends Command
|
||||
return $apiKey->isExpired() ? self::WARNING_STRING_PATTERN : self::SUCCESS_STRING_PATTERN;
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
private function getEnabledSymbol(ApiKey $apiKey): string
|
||||
{
|
||||
return ! $apiKey->isEnabled() || $apiKey->isExpired() ? '---' : '+++';
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -19,13 +19,11 @@ class ListDomainsCommand extends Command
|
||||
public const NAME = 'domain:list';
|
||||
|
||||
private DomainServiceInterface $domainService;
|
||||
private string $defaultDomain;
|
||||
|
||||
public function __construct(DomainServiceInterface $domainService, string $defaultDomain)
|
||||
public function __construct(DomainServiceInterface $domainService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->domainService = $domainService;
|
||||
$this->defaultDomain = $defaultDomain;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
@@ -37,12 +35,12 @@ class ListDomainsCommand extends Command
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$regularDomains = $this->domainService->listDomainsWithout($this->defaultDomain);
|
||||
$domains = $this->domainService->listDomains();
|
||||
|
||||
ShlinkTable::fromOutput($output)->render(['Domain', 'Is default'], [
|
||||
[$this->defaultDomain, 'Yes'],
|
||||
...map($regularDomains, fn (Domain $domain) => [$domain->getAuthority(), 'No']),
|
||||
]);
|
||||
ShlinkTable::fromOutput($output)->render(
|
||||
['Domain', 'Is default'],
|
||||
map($domains, fn (DomainItem $domain) => [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No']),
|
||||
);
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
@@ -42,7 +43,7 @@ class RenameTagCommand extends Command
|
||||
$newName = $input->getArgument('newName');
|
||||
|
||||
try {
|
||||
$this->tagService->renameTag($oldName, $newName);
|
||||
$this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName));
|
||||
$io->success('Tag properly renamed.');
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
} catch (TagNotFoundException | TagConflictException $e) {
|
||||
|
||||
82
module/CLI/test/ApiKey/RoleResolverTest.php
Normal file
82
module/CLI/test/ApiKey/RoleResolverTest.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\ApiKey;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\ApiKey\RoleResolver;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
class RoleResolverTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private RoleResolver $resolver;
|
||||
private ObjectProphecy $domainService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->domainService = $this->prophesize(DomainServiceInterface::class);
|
||||
$this->resolver = new RoleResolver($this->domainService->reveal());
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideRoles
|
||||
*/
|
||||
public function properRolesAreResolvedBasedOnInput(
|
||||
InputInterface $input,
|
||||
array $expectedRoles,
|
||||
int $expectedDomainCalls
|
||||
): void {
|
||||
$getDomain = $this->domainService->getOrCreate('example.com')->willReturn(
|
||||
(new Domain('example.com'))->setId('1'),
|
||||
);
|
||||
|
||||
$result = $this->resolver->determineRoles($input);
|
||||
|
||||
self::assertEquals($expectedRoles, $result);
|
||||
$getDomain->shouldHaveBeenCalledTimes($expectedDomainCalls);
|
||||
}
|
||||
|
||||
public function provideRoles(): iterable
|
||||
{
|
||||
$domain = (new Domain('example.com'))->setId('1');
|
||||
$buildInput = function (array $definition): InputInterface {
|
||||
$input = $this->prophesize(InputInterface::class);
|
||||
|
||||
foreach ($definition as $name => $value) {
|
||||
$input->getOption($name)->willReturn($value);
|
||||
}
|
||||
|
||||
return $input->reveal();
|
||||
};
|
||||
|
||||
yield 'no roles' => [
|
||||
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => false]),
|
||||
[],
|
||||
0,
|
||||
];
|
||||
yield 'domain role only' => [
|
||||
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => false]),
|
||||
[RoleDefinition::forDomain($domain)],
|
||||
1,
|
||||
];
|
||||
yield 'author role only' => [
|
||||
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => true]),
|
||||
[RoleDefinition::forAuthoredShortUrls()],
|
||||
0,
|
||||
];
|
||||
yield 'both roles' => [
|
||||
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => true]),
|
||||
[RoleDefinition::forAuthoredShortUrls(), RoleDefinition::forDomain($domain)],
|
||||
1,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,12 @@ use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class GenerateKeyCommandTest extends TestCase
|
||||
@@ -21,11 +23,15 @@ class GenerateKeyCommandTest extends TestCase
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $apiKeyService;
|
||||
private ObjectProphecy $roleResolver;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
|
||||
$command = new GenerateKeyCommand($this->apiKeyService->reveal());
|
||||
$this->roleResolver = $this->prophesize(RoleResolverInterface::class);
|
||||
$this->roleResolver->determineRoles(Argument::type(InputInterface::class))->willReturn([]);
|
||||
|
||||
$command = new GenerateKeyCommand($this->apiKeyService->reveal(), $this->roleResolver->reveal());
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
$this->commandTester = new CommandTester($command);
|
||||
|
||||
@@ -8,6 +8,8 @@ use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
@@ -29,42 +31,87 @@ class ListKeysCommandTest extends TestCase
|
||||
$this->commandTester = new CommandTester($command);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function everythingIsListedIfEnabledOnlyIsNotProvided(): void
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideKeysAndOutputs
|
||||
*/
|
||||
public function returnsExpectedOutput(array $keys, bool $enabledOnly, string $expected): void
|
||||
{
|
||||
$this->apiKeyService->listKeys(false)->willReturn([
|
||||
new ApiKey(),
|
||||
new ApiKey(),
|
||||
new ApiKey(),
|
||||
])->shouldBeCalledOnce();
|
||||
$listKeys = $this->apiKeyService->listKeys($enabledOnly)->willReturn($keys);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$this->commandTester->execute(['--enabledOnly' => $enabledOnly]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('Key', $output);
|
||||
self::assertStringContainsString('Is enabled', $output);
|
||||
self::assertStringContainsString(' +++ ', $output);
|
||||
self::assertStringNotContainsString(' --- ', $output);
|
||||
self::assertStringContainsString('Expiration date', $output);
|
||||
self::assertEquals($expected, $output);
|
||||
$listKeys->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function onlyEnabledKeysAreListedIfEnabledOnlyIsProvided(): void
|
||||
public function provideKeysAndOutputs(): iterable
|
||||
{
|
||||
$this->apiKeyService->listKeys(true)->willReturn([
|
||||
(new ApiKey())->disable(),
|
||||
new ApiKey(),
|
||||
])->shouldBeCalledOnce();
|
||||
yield 'all keys' => [
|
||||
[ApiKey::withKey('foo'), ApiKey::withKey('bar'), ApiKey::withKey('baz')],
|
||||
false,
|
||||
<<<OUTPUT
|
||||
+-----+------------+-----------------+-------+
|
||||
| Key | Is enabled | Expiration date | Roles |
|
||||
+-----+------------+-----------------+-------+
|
||||
| foo | +++ | - | Admin |
|
||||
| bar | +++ | - | Admin |
|
||||
| baz | +++ | - | Admin |
|
||||
+-----+------------+-----------------+-------+
|
||||
|
||||
$this->commandTester->execute([
|
||||
'--enabledOnly' => true,
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
OUTPUT,
|
||||
];
|
||||
yield 'enabled keys' => [
|
||||
[ApiKey::withKey('foo')->disable(), ApiKey::withKey('bar')],
|
||||
true,
|
||||
<<<OUTPUT
|
||||
+-----+-----------------+-------+
|
||||
| Key | Expiration date | Roles |
|
||||
+-----+-----------------+-------+
|
||||
| foo | - | Admin |
|
||||
| bar | - | Admin |
|
||||
+-----+-----------------+-------+
|
||||
|
||||
self::assertStringContainsString('Key', $output);
|
||||
self::assertStringNotContainsString('Is enabled', $output);
|
||||
self::assertStringNotContainsString(' +++ ', $output);
|
||||
self::assertStringNotContainsString(' --- ', $output);
|
||||
self::assertStringContainsString('Expiration date', $output);
|
||||
OUTPUT,
|
||||
];
|
||||
yield 'with roles' => [
|
||||
[
|
||||
ApiKey::withKey('foo'),
|
||||
$this->apiKeyWithRoles('bar', [RoleDefinition::forAuthoredShortUrls()]),
|
||||
$this->apiKeyWithRoles('baz', [RoleDefinition::forDomain((new Domain('example.com'))->setId('1'))]),
|
||||
ApiKey::withKey('foo2'),
|
||||
$this->apiKeyWithRoles('baz2', [
|
||||
RoleDefinition::forAuthoredShortUrls(),
|
||||
RoleDefinition::forDomain((new Domain('example.com'))->setId('1')),
|
||||
]),
|
||||
ApiKey::withKey('foo3'),
|
||||
],
|
||||
true,
|
||||
<<<OUTPUT
|
||||
+------+-----------------+--------------------------+
|
||||
| Key | Expiration date | Roles |
|
||||
+------+-----------------+--------------------------+
|
||||
| foo | - | Admin |
|
||||
| bar | - | Author only |
|
||||
| baz | - | Domain only: example.com |
|
||||
| foo2 | - | Admin |
|
||||
| baz2 | - | Author only |
|
||||
| | | Domain only: example.com |
|
||||
| foo3 | - | Admin |
|
||||
+------+-----------------+--------------------------+
|
||||
|
||||
OUTPUT,
|
||||
];
|
||||
}
|
||||
|
||||
private function apiKeyWithRoles(string $key, array $roles): ApiKey
|
||||
{
|
||||
$apiKey = ApiKey::withKey($key);
|
||||
foreach ($roles as $role) {
|
||||
$apiKey->registerRole($role);
|
||||
}
|
||||
|
||||
return $apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
@@ -25,7 +25,7 @@ class ListDomainsCommandTest extends TestCase
|
||||
{
|
||||
$this->domainService = $this->prophesize(DomainServiceInterface::class);
|
||||
|
||||
$command = new ListDomainsCommand($this->domainService->reveal(), 'foo.com');
|
||||
$command = new ListDomainsCommand($this->domainService->reveal());
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
|
||||
@@ -45,9 +45,10 @@ class ListDomainsCommandTest extends TestCase
|
||||
+---------+------------+
|
||||
|
||||
OUTPUT;
|
||||
$listDomains = $this->domainService->listDomainsWithout('foo.com')->willReturn([
|
||||
new Domain('bar.com'),
|
||||
new Domain('baz.com'),
|
||||
$listDomains = $this->domainService->listDomains()->willReturn([
|
||||
new DomainItem('foo.com', true),
|
||||
new DomainItem('bar.com', false),
|
||||
new DomainItem('baz.com', false),
|
||||
]);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
|
||||
@@ -10,6 +10,7 @@ use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
@@ -37,7 +38,9 @@ class RenameTagCommandTest extends TestCase
|
||||
{
|
||||
$oldName = 'foo';
|
||||
$newName = 'bar';
|
||||
$renameTag = $this->tagService->renameTag($oldName, $newName)->willThrow(TagNotFoundException::fromTag('foo'));
|
||||
$renameTag = $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName))->willThrow(
|
||||
TagNotFoundException::fromTag('foo'),
|
||||
);
|
||||
|
||||
$this->commandTester->execute([
|
||||
'oldName' => $oldName,
|
||||
@@ -54,7 +57,9 @@ class RenameTagCommandTest extends TestCase
|
||||
{
|
||||
$oldName = 'foo';
|
||||
$newName = 'bar';
|
||||
$renameTag = $this->tagService->renameTag($oldName, $newName)->willReturn(new Tag($newName));
|
||||
$renameTag = $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName))->willReturn(
|
||||
new Tag($newName),
|
||||
);
|
||||
|
||||
$this->commandTester->execute([
|
||||
'oldName' => $oldName,
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI;
|
||||
|
||||
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\ConfigProvider;
|
||||
|
||||
@@ -21,7 +22,9 @@ class ConfigProviderTest extends TestCase
|
||||
{
|
||||
$config = ($this->configProvider)();
|
||||
|
||||
self::assertCount(3, $config);
|
||||
self::assertArrayHasKey('cli', $config);
|
||||
self::assertArrayHasKey('dependencies', $config);
|
||||
self::assertArrayHasKey(ConfigAbstractFactory::class, $config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Mezzio\Template\TemplateRendererInterface;
|
||||
use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler;
|
||||
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
||||
@@ -16,7 +16,7 @@ return [
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class,
|
||||
ErrorHandler\NotFoundTemplateHandler::class => ConfigAbstractFactory::class,
|
||||
ErrorHandler\NotFoundTemplateHandler::class => InvokableFactory::class,
|
||||
|
||||
Options\AppOptions::class => ConfigAbstractFactory::class,
|
||||
Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class,
|
||||
@@ -60,7 +60,6 @@ return [
|
||||
Util\RedirectResponseHelper::class,
|
||||
'config.router.base_path',
|
||||
],
|
||||
ErrorHandler\NotFoundTemplateHandler::class => [TemplateRendererInterface::class],
|
||||
|
||||
Options\AppOptions::class => ['config.app_options'],
|
||||
Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'],
|
||||
@@ -89,7 +88,7 @@ return [
|
||||
],
|
||||
Service\ShortUrl\ShortUrlResolver::class => ['em'],
|
||||
Service\ShortUrl\ShortCodeHelper::class => ['em'],
|
||||
Domain\DomainService::class => ['em'],
|
||||
Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'],
|
||||
|
||||
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
|
||||
Util\DoctrineBatchHelper::class => ['em'],
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
'mezzio' => [
|
||||
'error_handler' => [
|
||||
'template_404' => 'ShlinkCore::error/404',
|
||||
'template_error' => 'ShlinkCore::error/error',
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
@@ -29,7 +29,17 @@ return [
|
||||
],
|
||||
[
|
||||
'name' => Action\QrCodeAction::class,
|
||||
'path' => '/{shortCode}/qr-code[/{size:[0-9]+}]',
|
||||
'path' => '/{shortCode}/qr-code',
|
||||
'middleware' => [
|
||||
Action\QrCodeAction::class,
|
||||
],
|
||||
'allowed_methods' => [RequestMethod::METHOD_GET],
|
||||
],
|
||||
|
||||
// Deprecated
|
||||
[
|
||||
'name' => 'old_' . Action\QrCodeAction::class,
|
||||
'path' => '/{shortCode}/qr-code/{size:[0-9]+}',
|
||||
'middleware' => [
|
||||
Action\QrCodeAction::class,
|
||||
],
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
'templates' => [
|
||||
'paths' => [
|
||||
'ShlinkCore' => __DIR__ . '/../templates',
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
@@ -10,7 +10,11 @@ use Fig\Http\Message\StatusCodeInterface;
|
||||
use Laminas\InputFilter\InputFilter;
|
||||
use PUGX\Shortid\Factory as ShortIdFactory;
|
||||
|
||||
use function Functional\reduce_left;
|
||||
use function is_array;
|
||||
use function print_r;
|
||||
use function sprintf;
|
||||
use function str_repeat;
|
||||
|
||||
const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15;
|
||||
const DEFAULT_SHORT_CODES_LENGTH = 5;
|
||||
@@ -18,7 +22,7 @@ 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._~]+/';
|
||||
const CUSTOM_SLUGS_REGEXP = '/[^\pL\pN._~]/u'; // Any unicode letter or number, plus ".", "_" and "~" chars
|
||||
|
||||
function generateRandomShortCode(int $length): string
|
||||
{
|
||||
@@ -75,3 +79,21 @@ function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldN
|
||||
$value = $inputFilter->getValue($fieldName);
|
||||
return $value !== null ? (bool) $value : null;
|
||||
}
|
||||
|
||||
function arrayToString(array $array, int $indentSize = 4): string
|
||||
{
|
||||
$indent = str_repeat(' ', $indentSize);
|
||||
$index = 0;
|
||||
|
||||
return reduce_left($array, static function ($messages, string $name, $_, string $acc) use (&$index, $indent) {
|
||||
$index++;
|
||||
|
||||
return $acc . sprintf(
|
||||
"%s%s'%s' => %s",
|
||||
$index === 1 ? '' : "\n",
|
||||
$indent,
|
||||
$name,
|
||||
is_array($messages) ? print_r($messages, true) : $messages,
|
||||
);
|
||||
}, '');
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet
|
||||
$this->urlResolver = $urlResolver;
|
||||
$this->visitTracker = $visitTracker;
|
||||
$this->appOptions = $appOptions;
|
||||
$this->logger = $logger ?: new NullLogger();
|
||||
$this->logger = $logger ?? new NullLogger();
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
|
||||
@@ -34,7 +34,7 @@ class QrCodeAction implements MiddlewareInterface
|
||||
) {
|
||||
$this->urlResolver = $urlResolver;
|
||||
$this->domainConfig = $domainConfig;
|
||||
$this->logger = $logger ?: new NullLogger();
|
||||
$this->logger = $logger ?? new NullLogger();
|
||||
}
|
||||
|
||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||
@@ -48,11 +48,15 @@ class QrCodeAction implements MiddlewareInterface
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
$query = $request->getQueryParams();
|
||||
// Size attribute is deprecated
|
||||
$size = $this->normalizeSize((int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE));
|
||||
|
||||
$qrCode = new QrCode($shortUrl->toString($this->domainConfig));
|
||||
$qrCode->setSize($this->getSizeParam($request));
|
||||
$qrCode->setSize($size);
|
||||
$qrCode->setMargin(0);
|
||||
|
||||
$format = $request->getQueryParams()['format'] ?? 'png';
|
||||
$format = $query['format'] ?? 'png';
|
||||
if ($format === 'svg') {
|
||||
$qrCode->setWriter(new SvgWriter());
|
||||
}
|
||||
@@ -60,9 +64,8 @@ class QrCodeAction implements MiddlewareInterface
|
||||
return new QrCodeResponse($qrCode);
|
||||
}
|
||||
|
||||
private function getSizeParam(Request $request): int
|
||||
private function normalizeSize(int $size): int
|
||||
{
|
||||
$size = (int) $request->getAttribute('size', self::DEFAULT_SIZE);
|
||||
if ($size < self::MIN_SIZE) {
|
||||
return self::MIN_SIZE;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use function Functional\contains;
|
||||
use function Functional\reduce_left;
|
||||
use function uksort;
|
||||
|
||||
/** @deprecated */
|
||||
class SimplifiedConfigParser
|
||||
{
|
||||
private const SIMPLIFIED_CONFIG_MAPPING = [
|
||||
|
||||
@@ -5,25 +5,69 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Domain;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
use function Functional\map;
|
||||
|
||||
class DomainService implements DomainServiceInterface
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private string $defaultDomain;
|
||||
|
||||
public function __construct(EntityManagerInterface $em)
|
||||
public function __construct(EntityManagerInterface $em, string $defaultDomain)
|
||||
{
|
||||
$this->em = $em;
|
||||
$this->defaultDomain = $defaultDomain;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Domain[]
|
||||
* @return DomainItem[]
|
||||
*/
|
||||
public function listDomainsWithout(?string $excludeDomain = null): array
|
||||
public function listDomains(?ApiKey $apiKey = null): array
|
||||
{
|
||||
/** @var DomainRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Domain::class);
|
||||
return $repo->findDomainsWithout($excludeDomain);
|
||||
$domains = $repo->findDomainsWithout($this->defaultDomain, $apiKey);
|
||||
$mappedDomains = map($domains, fn (Domain $domain) => new DomainItem($domain->getAuthority(), false));
|
||||
|
||||
if ($apiKey !== null && $apiKey->hasRole(Role::DOMAIN_SPECIFIC)) {
|
||||
return $mappedDomains;
|
||||
}
|
||||
|
||||
return [
|
||||
new DomainItem($this->defaultDomain, true),
|
||||
...$mappedDomains,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DomainNotFoundException
|
||||
*/
|
||||
public function getDomain(string $domainId): Domain
|
||||
{
|
||||
/** @var Domain|null $domain */
|
||||
$domain = $this->em->find(Domain::class, $domainId);
|
||||
if ($domain === null) {
|
||||
throw DomainNotFoundException::fromId($domainId);
|
||||
}
|
||||
|
||||
return $domain;
|
||||
}
|
||||
|
||||
public function getOrCreate(string $authority): Domain
|
||||
{
|
||||
$repo = $this->em->getRepository(Domain::class);
|
||||
/** @var Domain|null $domain */
|
||||
$domain = $repo->findOneBy(['authority' => $authority]) ?? new Domain($authority);
|
||||
|
||||
$this->em->persist($domain);
|
||||
$this->em->flush();
|
||||
|
||||
return $domain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,22 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain;
|
||||
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface DomainServiceInterface
|
||||
{
|
||||
/**
|
||||
* @return Domain[]
|
||||
* @return DomainItem[]
|
||||
*/
|
||||
public function listDomainsWithout(?string $excludeDomain = null): array;
|
||||
public function listDomains(?ApiKey $apiKey = null): array;
|
||||
|
||||
/**
|
||||
* @throws DomainNotFoundException
|
||||
*/
|
||||
public function getDomain(string $domainId): Domain;
|
||||
|
||||
public function getOrCreate(string $authority): Domain;
|
||||
}
|
||||
|
||||
37
module/Core/src/Domain/Model/DomainItem.php
Normal file
37
module/Core/src/Domain/Model/DomainItem.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain\Model;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
final class DomainItem implements JsonSerializable
|
||||
{
|
||||
private string $domain;
|
||||
private bool $isDefault;
|
||||
|
||||
public function __construct(string $domain, bool $isDefault)
|
||||
{
|
||||
$this->domain = $domain;
|
||||
$this->isDefault = $isDefault;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'domain' => $this->domain,
|
||||
'isDefault' => $this->isDefault,
|
||||
];
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
public function isDefault(): bool
|
||||
{
|
||||
return $this->isDefault;
|
||||
}
|
||||
}
|
||||
@@ -4,23 +4,32 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain\Repository;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\Query\Expr\Join;
|
||||
use Happyr\DoctrineSpecification\EntitySpecificationRepository;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class DomainRepository extends EntityRepository implements DomainRepositoryInterface
|
||||
class DomainRepository extends EntitySpecificationRepository implements DomainRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @return Domain[]
|
||||
*/
|
||||
public function findDomainsWithout(?string $excludedAuthority = null): array
|
||||
public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('d')->orderBy('d.authority', 'ASC');
|
||||
$qb = $this->createQueryBuilder('d');
|
||||
$qb->join(ShortUrl::class, 's', Join::WITH, 's.domain = d')
|
||||
->orderBy('d.authority', 'ASC');
|
||||
|
||||
if ($excludedAuthority !== null) {
|
||||
$qb->where($qb->expr()->neq('d.authority', ':excludedAuthority'))
|
||||
->setParameter('excludedAuthority', $excludedAuthority);
|
||||
}
|
||||
|
||||
if ($apiKey !== null) {
|
||||
$this->applySpecification($qb, $apiKey->spec(), 's');
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Domain\Repository;
|
||||
|
||||
use Doctrine\Persistence\ObjectRepository;
|
||||
use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface DomainRepositoryInterface extends ObjectRepository
|
||||
interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @return Domain[]
|
||||
*/
|
||||
public function findDomainsWithout(?string $excludedAuthority = null): array;
|
||||
public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ class ShortUrl extends AbstractEntity
|
||||
$this->shortCodeLength = $meta->getShortCodeLength();
|
||||
$this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($this->shortCodeLength);
|
||||
$this->domain = $relationResolver->resolveDomain($meta->getDomain());
|
||||
$this->authorApiKey = $relationResolver->resolveApiKey($meta->getApiKey());
|
||||
$this->authorApiKey = $meta->getApiKey();
|
||||
}
|
||||
|
||||
public static function fromImport(
|
||||
|
||||
@@ -4,40 +4,37 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ErrorHandler;
|
||||
|
||||
use Closure;
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use InvalidArgumentException;
|
||||
use Laminas\Diactoros\Response;
|
||||
use Mezzio\Router\RouteResult;
|
||||
use Mezzio\Template\TemplateRendererInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
use function file_get_contents;
|
||||
use function sprintf;
|
||||
|
||||
class NotFoundTemplateHandler implements RequestHandlerInterface
|
||||
{
|
||||
public const NOT_FOUND_TEMPLATE = 'ShlinkCore::error/404';
|
||||
public const INVALID_SHORT_CODE_TEMPLATE = 'ShlinkCore::invalid-short-code';
|
||||
private const TEMPLATES_BASE_DIR = __DIR__ . '/../../templates';
|
||||
public const NOT_FOUND_TEMPLATE = '404.html';
|
||||
public const INVALID_SHORT_CODE_TEMPLATE = 'invalid-short-code.html';
|
||||
private Closure $readFile;
|
||||
|
||||
private TemplateRendererInterface $renderer;
|
||||
|
||||
public function __construct(TemplateRendererInterface $renderer)
|
||||
public function __construct(?callable $readFile = null)
|
||||
{
|
||||
$this->renderer = $renderer;
|
||||
$this->readFile = $readFile ? Closure::fromCallable($readFile) : fn (string $file) => file_get_contents($file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the next available middleware and return the response.
|
||||
*
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
/** @var RouteResult $routeResult */
|
||||
$routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
|
||||
$routeResult = $request->getAttribute(RouteResult::class) ?? RouteResult::fromRouteFailure(null);
|
||||
$status = StatusCodeInterface::STATUS_NOT_FOUND;
|
||||
|
||||
$template = $routeResult->isFailure() ? self::NOT_FOUND_TEMPLATE : self::INVALID_SHORT_CODE_TEMPLATE;
|
||||
return new Response\HtmlResponse($this->renderer->render($template), $status);
|
||||
$templateContent = ($this->readFile)(sprintf('%s/%s', self::TEMPLATES_BASE_DIR, $template));
|
||||
return new Response\HtmlResponse($templateContent, $status);
|
||||
}
|
||||
}
|
||||
|
||||
32
module/Core/src/Exception/DomainNotFoundException.php
Normal file
32
module/Core/src/Exception/DomainNotFoundException.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Exception;
|
||||
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class DomainNotFoundException extends DomainException implements ProblemDetailsExceptionInterface
|
||||
{
|
||||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Domain not found';
|
||||
private const TYPE = 'DOMAIN_NOT_FOUND';
|
||||
|
||||
public static function fromId(string $id): self
|
||||
{
|
||||
$e = new self(sprintf('Domain with id "%s" could not be found', $id));
|
||||
|
||||
$e->detail = $e->getMessage();
|
||||
$e->title = self::TITLE;
|
||||
$e->type = self::TYPE;
|
||||
$e->status = StatusCodeInterface::STATUS_NOT_FOUND;
|
||||
$e->additional = ['id' => $id];
|
||||
|
||||
return $e;
|
||||
}
|
||||
}
|
||||
39
module/Core/src/Exception/ForbiddenTagOperationException.php
Normal file
39
module/Core/src/Exception/ForbiddenTagOperationException.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Exception;
|
||||
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
|
||||
class ForbiddenTagOperationException extends DomainException implements ProblemDetailsExceptionInterface
|
||||
{
|
||||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Forbidden tag operation';
|
||||
private const TYPE = 'FORBIDDEN_OPERATION';
|
||||
|
||||
public static function forDeletion(): self
|
||||
{
|
||||
return self::createWithMessage('You are not allowed to delete tags');
|
||||
}
|
||||
|
||||
public static function forRenaming(): self
|
||||
{
|
||||
return self::createWithMessage('You are not allowed to rename tags');
|
||||
}
|
||||
|
||||
private static function createWithMessage(string $message): self
|
||||
{
|
||||
$e = new self($message);
|
||||
|
||||
$e->detail = $message;
|
||||
$e->title = self::TITLE;
|
||||
$e->type = self::TYPE;
|
||||
$e->status = StatusCodeInterface::STATUS_FORBIDDEN;
|
||||
|
||||
return $e;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Exception;
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
@@ -17,18 +18,15 @@ class TagConflictException extends RuntimeException implements ProblemDetailsExc
|
||||
private const TITLE = 'Tag conflict';
|
||||
private const TYPE = 'TAG_CONFLICT';
|
||||
|
||||
public static function fromExistingTag(string $oldName, string $newName): self
|
||||
public static function forExistingTag(TagRenaming $renaming): self
|
||||
{
|
||||
$e = new self(sprintf('You cannot rename tag %s to %s, because it already exists', $oldName, $newName));
|
||||
$e = new self(sprintf('You cannot rename tag %s, because it already exists', $renaming->toString()));
|
||||
|
||||
$e->detail = $e->getMessage();
|
||||
$e->title = self::TITLE;
|
||||
$e->type = self::TYPE;
|
||||
$e->status = StatusCodeInterface::STATUS_CONFLICT;
|
||||
$e->additional = [
|
||||
'oldName' => $oldName,
|
||||
'newName' => $newName,
|
||||
];
|
||||
$e->additional = $renaming->toArray();
|
||||
|
||||
return $e;
|
||||
}
|
||||
|
||||
@@ -11,9 +11,7 @@ use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
use Throwable;
|
||||
|
||||
use function array_keys;
|
||||
use function Functional\reduce_left;
|
||||
use function is_array;
|
||||
use function print_r;
|
||||
use function Shlinkio\Shlink\Core\arrayToString;
|
||||
use function sprintf;
|
||||
|
||||
use const PHP_EOL;
|
||||
@@ -55,24 +53,16 @@ class ValidationException extends InvalidArgumentException implements ProblemDet
|
||||
public function __toString(): string
|
||||
{
|
||||
return sprintf(
|
||||
'%s %s in %s:%s%s%sStack trace:%s%s',
|
||||
'%s %s in %s:%s%s%s%sStack trace:%s%s',
|
||||
__CLASS__,
|
||||
$this->getMessage(),
|
||||
$this->getFile(),
|
||||
$this->getLine(),
|
||||
$this->invalidElementsToString(),
|
||||
PHP_EOL,
|
||||
arrayToString($this->getInvalidElements()),
|
||||
PHP_EOL,
|
||||
PHP_EOL,
|
||||
$this->getTraceAsString(),
|
||||
);
|
||||
}
|
||||
|
||||
private function invalidElementsToString(): string
|
||||
{
|
||||
return reduce_left($this->getInvalidElements(), fn ($messages, string $name, $_, string $acc) => $acc . sprintf(
|
||||
"\n '%s' => %s",
|
||||
$name,
|
||||
is_array($messages) ? print_r($messages, true) : $messages,
|
||||
), '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Model;
|
||||
use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
|
||||
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
|
||||
@@ -24,7 +25,7 @@ final class ShortUrlMeta
|
||||
private ?string $domain = null;
|
||||
private int $shortCodeLength = 5;
|
||||
private ?bool $validateUrl = null;
|
||||
private ?string $apiKey = null;
|
||||
private ?ApiKey $apiKey = null;
|
||||
|
||||
// Enforce named constructors
|
||||
private function __construct()
|
||||
@@ -135,7 +136,7 @@ final class ShortUrlMeta
|
||||
return $this->validateUrl;
|
||||
}
|
||||
|
||||
public function getApiKey(): ?string
|
||||
public function getApiKey(): ?ApiKey
|
||||
{
|
||||
return $this->apiKey;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ final class ShortUrlsOrdering
|
||||
*/
|
||||
private function validateAndInit(array $data): void
|
||||
{
|
||||
/** @var string|array|null $orderBy */
|
||||
$orderBy = $data[self::ORDER_BY] ?? null;
|
||||
if ($orderBy === null) {
|
||||
return;
|
||||
@@ -49,6 +48,7 @@ final class ShortUrlsOrdering
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var string|array $orderBy */
|
||||
if (! $isArray) {
|
||||
$parts = explode('-', $orderBy);
|
||||
$this->orderField = $parts[0];
|
||||
|
||||
@@ -4,27 +4,25 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
use Laminas\Paginator\Adapter\AdapterInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class ShortUrlRepositoryAdapter implements AdapterInterface
|
||||
{
|
||||
private ShortUrlRepositoryInterface $repository;
|
||||
private ShortUrlsParams $params;
|
||||
private ?ApiKey $apiKey;
|
||||
|
||||
public function __construct(ShortUrlRepositoryInterface $repository, ShortUrlsParams $params)
|
||||
public function __construct(ShortUrlRepositoryInterface $repository, ShortUrlsParams $params, ?ApiKey $apiKey)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
$this->params = $params;
|
||||
$this->apiKey = $apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection of items for a page.
|
||||
*
|
||||
* @param int $offset Page offset
|
||||
* @param int $itemCountPerPage Number of items per page
|
||||
*/
|
||||
public function getItems($offset, $itemCountPerPage): array // phpcs:ignore
|
||||
{
|
||||
return $this->repository->findList(
|
||||
@@ -34,24 +32,22 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
|
||||
$this->params->tags(),
|
||||
$this->params->orderBy(),
|
||||
$this->params->dateRange(),
|
||||
$this->resolveSpec(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count elements of an object
|
||||
* @link http://php.net/manual/en/countable.count.php
|
||||
* @return int The custom count as an integer.
|
||||
* </p>
|
||||
* <p>
|
||||
* The return value is cast to an integer.
|
||||
* @since 5.1.0
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return $this->repository->countList(
|
||||
$this->params->searchTerm(),
|
||||
$this->params->tags(),
|
||||
$this->params->dateRange(),
|
||||
$this->resolveSpec(),
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveSpec(): ?Specification
|
||||
{
|
||||
return $this->apiKey !== null ? $this->apiKey->spec() : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,20 +4,28 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
{
|
||||
private VisitRepositoryInterface $visitRepository;
|
||||
private string $tag;
|
||||
private VisitsParams $params;
|
||||
private ?ApiKey $apiKey;
|
||||
|
||||
public function __construct(VisitRepositoryInterface $visitRepository, string $tag, VisitsParams $params)
|
||||
{
|
||||
public function __construct(
|
||||
VisitRepositoryInterface $visitRepository,
|
||||
string $tag,
|
||||
VisitsParams $params,
|
||||
?ApiKey $apiKey
|
||||
) {
|
||||
$this->visitRepository = $visitRepository;
|
||||
$this->params = $params;
|
||||
$this->tag = $tag;
|
||||
$this->apiKey = $apiKey;
|
||||
}
|
||||
|
||||
public function getItems($offset, $itemCountPerPage): array // phpcs:ignore
|
||||
@@ -27,11 +35,21 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
|
||||
$this->params->getDateRange(),
|
||||
$itemCountPerPage,
|
||||
$offset,
|
||||
$this->resolveSpec(),
|
||||
);
|
||||
}
|
||||
|
||||
protected function doCount(): int
|
||||
{
|
||||
return $this->visitRepository->countVisitsByTag($this->tag, $this->params->getDateRange());
|
||||
return $this->visitRepository->countVisitsByTag(
|
||||
$this->tag,
|
||||
$this->params->getDateRange(),
|
||||
$this->resolveSpec(),
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveSpec(): ?Specification
|
||||
{
|
||||
return $this->apiKey !== null ? $this->apiKey->spec(true) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
@@ -13,15 +14,18 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
private VisitRepositoryInterface $visitRepository;
|
||||
private ShortUrlIdentifier $identifier;
|
||||
private VisitsParams $params;
|
||||
private ?Specification $spec;
|
||||
|
||||
public function __construct(
|
||||
VisitRepositoryInterface $visitRepository,
|
||||
ShortUrlIdentifier $identifier,
|
||||
VisitsParams $params
|
||||
VisitsParams $params,
|
||||
?Specification $spec
|
||||
) {
|
||||
$this->visitRepository = $visitRepository;
|
||||
$this->params = $params;
|
||||
$this->identifier = $identifier;
|
||||
$this->spec = $spec;
|
||||
}
|
||||
|
||||
public function getItems($offset, $itemCountPerPage): array // phpcs:ignore
|
||||
@@ -32,6 +36,7 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
$this->params->getDateRange(),
|
||||
$itemCountPerPage,
|
||||
$offset,
|
||||
$this->spec,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +46,7 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
$this->identifier->shortCode(),
|
||||
$this->identifier->domain(),
|
||||
$this->params->getDateRange(),
|
||||
$this->spec,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Repository;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\Query\Expr\Join;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Happyr\DoctrineSpecification\EntitySpecificationRepository;
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
@@ -18,7 +20,7 @@ use function array_key_exists;
|
||||
use function count;
|
||||
use function Functional\contains;
|
||||
|
||||
class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface
|
||||
class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @param string[] $tags
|
||||
@@ -30,18 +32,13 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
?string $searchTerm = null,
|
||||
array $tags = [],
|
||||
?ShortUrlsOrdering $orderBy = null,
|
||||
?DateRange $dateRange = null
|
||||
?DateRange $dateRange = null,
|
||||
?Specification $spec = null
|
||||
): array {
|
||||
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange);
|
||||
$qb->select('DISTINCT s');
|
||||
|
||||
// Set limit and offset
|
||||
if ($limit !== null) {
|
||||
$qb->setMaxResults($limit);
|
||||
}
|
||||
if ($offset !== null) {
|
||||
$qb->setFirstResult($offset);
|
||||
}
|
||||
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec);
|
||||
$qb->select('DISTINCT s')
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset);
|
||||
|
||||
// In case the ordering has been specified, the query could be more complex. Process it
|
||||
if ($orderBy !== null && $orderBy->hasOrderField()) {
|
||||
@@ -80,18 +77,23 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function countList(?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null): int
|
||||
{
|
||||
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange);
|
||||
public function countList(
|
||||
?string $searchTerm = null,
|
||||
array $tags = [],
|
||||
?DateRange $dateRange = null,
|
||||
?Specification $spec = null
|
||||
): int {
|
||||
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec);
|
||||
$qb->select('COUNT(DISTINCT s)');
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
private function createListQueryBuilder(
|
||||
?string $searchTerm = null,
|
||||
array $tags = [],
|
||||
?DateRange $dateRange = null
|
||||
?string $searchTerm,
|
||||
array $tags,
|
||||
?DateRange $dateRange,
|
||||
?Specification $spec
|
||||
): QueryBuilder {
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->from(ShortUrl::class, 's')
|
||||
@@ -99,11 +101,11 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
|
||||
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
|
||||
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
|
||||
$qb->setParameter('startDate', $dateRange->getStartDate());
|
||||
$qb->setParameter('startDate', $dateRange->getStartDate(), ChronosDateTimeType::CHRONOS_DATETIME);
|
||||
}
|
||||
if ($dateRange !== null && $dateRange->getEndDate() !== null) {
|
||||
$qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate'));
|
||||
$qb->setParameter('endDate', $dateRange->getEndDate());
|
||||
$qb->setParameter('endDate', $dateRange->getEndDate(), ChronosDateTimeType::CHRONOS_DATETIME);
|
||||
}
|
||||
|
||||
// Apply search term to every searchable field if not empty
|
||||
@@ -130,6 +132,8 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
->andWhere($qb->expr()->in('t.name', $tags));
|
||||
}
|
||||
|
||||
$this->applySpecification($qb, $spec, 's');
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
@@ -147,7 +151,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
WHERE s.shortCode = :shortCode
|
||||
AND (s.domain IS NULL OR d.authority = :domain)
|
||||
ORDER BY s.domain {$ordering}
|
||||
DQL;
|
||||
DQL;
|
||||
|
||||
$query = $this->getEntityManager()->createQuery($dql);
|
||||
$query->setMaxResults(1)
|
||||
@@ -165,23 +169,23 @@ DQL;
|
||||
return $query->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function findOne(string $shortCode, ?string $domain = null): ?ShortUrl
|
||||
public function findOne(string $shortCode, ?string $domain = null, ?Specification $spec = null): ?ShortUrl
|
||||
{
|
||||
$qb = $this->createFindOneQueryBuilder($shortCode, $domain);
|
||||
$qb = $this->createFindOneQueryBuilder($shortCode, $domain, $spec);
|
||||
$qb->select('s');
|
||||
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function shortCodeIsInUse(string $slug, ?string $domain = null): bool
|
||||
public function shortCodeIsInUse(string $slug, ?string $domain = null, ?Specification $spec = null): bool
|
||||
{
|
||||
$qb = $this->createFindOneQueryBuilder($slug, $domain);
|
||||
$qb = $this->createFindOneQueryBuilder($slug, $domain, $spec);
|
||||
$qb->select('COUNT(DISTINCT s.id)');
|
||||
|
||||
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
|
||||
}
|
||||
|
||||
private function createFindOneQueryBuilder(string $slug, ?string $domain = null): QueryBuilder
|
||||
private function createFindOneQueryBuilder(string $slug, ?string $domain, ?Specification $spec): QueryBuilder
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->from(ShortUrl::class, 's')
|
||||
@@ -192,6 +196,8 @@ DQL;
|
||||
|
||||
$this->whereDomainIs($qb, $domain);
|
||||
|
||||
$this->applySpecification($qb, $spec, 's');
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
@@ -216,19 +222,23 @@ DQL;
|
||||
}
|
||||
if ($meta->hasValidSince()) {
|
||||
$qb->andWhere($qb->expr()->eq('s.validSince', ':validSince'))
|
||||
->setParameter('validSince', $meta->getValidSince());
|
||||
->setParameter('validSince', $meta->getValidSince(), ChronosDateTimeType::CHRONOS_DATETIME);
|
||||
}
|
||||
if ($meta->hasValidUntil()) {
|
||||
$qb->andWhere($qb->expr()->eq('s.validUntil', ':validUntil'))
|
||||
->setParameter('validUntil', $meta->getValidUntil());
|
||||
->setParameter('validUntil', $meta->getValidUntil(), ChronosDateTimeType::CHRONOS_DATETIME);
|
||||
}
|
||||
|
||||
if ($meta->hasDomain()) {
|
||||
$qb->join('s.domain', 'd')
|
||||
->andWhere($qb->expr()->eq('d.authority', ':domain'))
|
||||
->setParameter('domain', $meta->getDomain());
|
||||
}
|
||||
|
||||
$apiKey = $meta->getApiKey();
|
||||
if ($apiKey !== null) {
|
||||
$this->applySpecification($qb, $apiKey->spec(), 's');
|
||||
}
|
||||
|
||||
$tagsAmount = count($tags);
|
||||
if ($tagsAmount === 0) {
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
|
||||
@@ -5,13 +5,15 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Repository;
|
||||
|
||||
use Doctrine\Persistence\ObjectRepository;
|
||||
use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface;
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
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
|
||||
interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||
{
|
||||
public function findList(
|
||||
?int $limit = null,
|
||||
@@ -19,16 +21,22 @@ interface ShortUrlRepositoryInterface extends ObjectRepository
|
||||
?string $searchTerm = null,
|
||||
array $tags = [],
|
||||
?ShortUrlsOrdering $orderBy = null,
|
||||
?DateRange $dateRange = null
|
||||
?DateRange $dateRange = null,
|
||||
?Specification $spec = null
|
||||
): array;
|
||||
|
||||
public function countList(?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null): int;
|
||||
public function countList(
|
||||
?string $searchTerm = null,
|
||||
array $tags = [],
|
||||
?DateRange $dateRange = null,
|
||||
?Specification $spec = null
|
||||
): int;
|
||||
|
||||
public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl;
|
||||
|
||||
public function findOne(string $shortCode, ?string $domain = null): ?ShortUrl;
|
||||
public function findOne(string $shortCode, ?string $domain = null, ?Specification $spec = null): ?ShortUrl;
|
||||
|
||||
public function shortCodeIsInUse(string $slug, ?string $domain): bool;
|
||||
public function shortCodeIsInUse(string $slug, ?string $domain, ?Specification $spec = null): bool;
|
||||
|
||||
public function findOneMatching(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl;
|
||||
|
||||
|
||||
@@ -4,13 +4,18 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Repository;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Happyr\DoctrineSpecification\EntitySpecificationRepository;
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Spec\CountTagsWithName;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
use function Functional\map;
|
||||
|
||||
class TagRepository extends EntityRepository implements TagRepositoryInterface
|
||||
class TagRepository extends EntitySpecificationRepository implements TagRepositoryInterface
|
||||
{
|
||||
public function deleteByName(array $names): int
|
||||
{
|
||||
@@ -28,21 +33,32 @@ class TagRepository extends EntityRepository implements TagRepositoryInterface
|
||||
/**
|
||||
* @return TagInfo[]
|
||||
*/
|
||||
public function findTagsWithInfo(): array
|
||||
public function findTagsWithInfo(?Specification $spec = null): array
|
||||
{
|
||||
$dql = <<<DQL
|
||||
SELECT t AS tag, COUNT(DISTINCT s.id) AS shortUrlsCount, COUNT(DISTINCT v.id) AS visitsCount
|
||||
FROM Shlinkio\Shlink\Core\Entity\Tag t
|
||||
LEFT JOIN t.shortUrls s
|
||||
LEFT JOIN s.visits v
|
||||
GROUP BY t
|
||||
ORDER BY t.name ASC
|
||||
DQL;
|
||||
$query = $this->getEntityManager()->createQuery($dql);
|
||||
$qb = $this->createQueryBuilder('t');
|
||||
$qb->select('t AS tag', 'COUNT(DISTINCT s.id) AS shortUrlsCount', 'COUNT(DISTINCT v.id) AS visitsCount')
|
||||
->leftJoin('t.shortUrls', 's')
|
||||
->leftJoin('s.visits', 'v')
|
||||
->groupBy('t')
|
||||
->orderBy('t.name', 'ASC');
|
||||
|
||||
$this->applySpecification($qb, $spec, 't');
|
||||
|
||||
$query = $qb->getQuery();
|
||||
|
||||
return map(
|
||||
$query->getResult(),
|
||||
fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']),
|
||||
);
|
||||
}
|
||||
|
||||
public function tagExists(string $tag, ?ApiKey $apiKey = null): bool
|
||||
{
|
||||
$result = (int) $this->matchSingleScalarResult(Spec::andX(
|
||||
new CountTagsWithName($tag),
|
||||
new WithApiKeySpecsEnsuringJoin($apiKey),
|
||||
));
|
||||
|
||||
return $result > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,19 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Repository;
|
||||
|
||||
use Doctrine\Persistence\ObjectRepository;
|
||||
use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface;
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface TagRepositoryInterface extends ObjectRepository
|
||||
interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||
{
|
||||
public function deleteByName(array $names): int;
|
||||
|
||||
/**
|
||||
* @return TagInfo[]
|
||||
*/
|
||||
public function findTagsWithInfo(): array;
|
||||
public function findTagsWithInfo(?Specification $spec = null): array;
|
||||
|
||||
public function tagExists(string $tag, ?ApiKey $apiKey = null): bool;
|
||||
}
|
||||
|
||||
@@ -4,17 +4,21 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Repository;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\Query\ResultSetMappingBuilder;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Happyr\DoctrineSpecification\EntitySpecificationRepository;
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
use const PHP_INT_MAX;
|
||||
|
||||
class VisitRepository extends EntityRepository implements VisitRepositoryInterface
|
||||
class VisitRepository extends EntitySpecificationRepository implements VisitRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @return iterable|Visit[]
|
||||
@@ -84,15 +88,20 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
|
||||
?string $domain = null,
|
||||
?DateRange $dateRange = null,
|
||||
?int $limit = null,
|
||||
?int $offset = null
|
||||
?int $offset = null,
|
||||
?Specification $spec = null
|
||||
): array {
|
||||
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
|
||||
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange, $spec);
|
||||
return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset);
|
||||
}
|
||||
|
||||
public function countVisitsByShortCode(string $shortCode, ?string $domain = null, ?DateRange $dateRange = null): int
|
||||
{
|
||||
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
|
||||
public function countVisitsByShortCode(
|
||||
string $shortCode,
|
||||
?string $domain = null,
|
||||
?DateRange $dateRange = null,
|
||||
?Specification $spec = null
|
||||
): int {
|
||||
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange, $spec);
|
||||
$qb->select('COUNT(v.id)');
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
@@ -101,11 +110,12 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
|
||||
private function createVisitsByShortCodeQueryBuilder(
|
||||
string $shortCode,
|
||||
?string $domain,
|
||||
?DateRange $dateRange
|
||||
?DateRange $dateRange,
|
||||
?Specification $spec = null
|
||||
): QueryBuilder {
|
||||
/** @var ShortUrlRepositoryInterface $shortUrlRepo */
|
||||
$shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class);
|
||||
$shortUrl = $shortUrlRepo->findOne($shortCode, $domain);
|
||||
$shortUrl = $shortUrlRepo->findOne($shortCode, $domain, $spec);
|
||||
$shortUrlId = $shortUrl !== null ? $shortUrl->getId() : -1;
|
||||
|
||||
// Parameters in this query need to be part of the query itself, as we need to use it a sub-query later
|
||||
@@ -124,32 +134,36 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
|
||||
string $tag,
|
||||
?DateRange $dateRange = null,
|
||||
?int $limit = null,
|
||||
?int $offset = null
|
||||
?int $offset = null,
|
||||
?Specification $spec = null
|
||||
): array {
|
||||
$qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange);
|
||||
$qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange, $spec);
|
||||
return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset);
|
||||
}
|
||||
|
||||
public function countVisitsByTag(string $tag, ?DateRange $dateRange = null): int
|
||||
public function countVisitsByTag(string $tag, ?DateRange $dateRange = null, ?Specification $spec = null): int
|
||||
{
|
||||
$qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange);
|
||||
$qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange, $spec);
|
||||
$qb->select('COUNT(v.id)');
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
private function createVisitsByTagQueryBuilder(string $tag, ?DateRange $dateRange = null): QueryBuilder
|
||||
{
|
||||
// Parameters in this query need to be part of the query itself, as we need to use it a sub-query later
|
||||
private function createVisitsByTagQueryBuilder(
|
||||
string $tag,
|
||||
?DateRange $dateRange,
|
||||
?Specification $spec
|
||||
): QueryBuilder {
|
||||
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
|
||||
// Since they are not strictly provided by the caller, it's reasonably safe
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->from(Visit::class, 'v')
|
||||
->join('v.shortUrl', 's')
|
||||
->join('s.tags', 't')
|
||||
->where($qb->expr()->eq('t.name', '\'' . $tag . '\''));
|
||||
->where($qb->expr()->eq('t.name', '\'' . $tag . '\'')); // This needs to be concatenated, not bound
|
||||
|
||||
// Apply date range filtering
|
||||
$this->applyDatesInline($qb, $dateRange);
|
||||
$this->applySpecification($qb, $spec, 'v');
|
||||
|
||||
return $qb;
|
||||
}
|
||||
@@ -194,4 +208,11 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
|
||||
|
||||
return $query->getResult();
|
||||
}
|
||||
|
||||
public function countVisits(?ApiKey $apiKey = null): int
|
||||
{
|
||||
return (int) $this->matchSingleScalarResult(
|
||||
Spec::countOf(new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrl')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Repository;
|
||||
|
||||
use Doctrine\Persistence\ObjectRepository;
|
||||
use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface;
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface VisitRepositoryInterface extends ObjectRepository
|
||||
interface VisitRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||
{
|
||||
public const DEFAULT_BLOCK_SIZE = 10000;
|
||||
|
||||
@@ -35,13 +38,15 @@ interface VisitRepositoryInterface extends ObjectRepository
|
||||
?string $domain = null,
|
||||
?DateRange $dateRange = null,
|
||||
?int $limit = null,
|
||||
?int $offset = null
|
||||
?int $offset = null,
|
||||
?Specification $spec = null
|
||||
): array;
|
||||
|
||||
public function countVisitsByShortCode(
|
||||
string $shortCode,
|
||||
?string $domain = null,
|
||||
?DateRange $dateRange = null
|
||||
?DateRange $dateRange = null,
|
||||
?Specification $spec = null
|
||||
): int;
|
||||
|
||||
/**
|
||||
@@ -51,8 +56,11 @@ interface VisitRepositoryInterface extends ObjectRepository
|
||||
string $tag,
|
||||
?DateRange $dateRange = null,
|
||||
?int $limit = null,
|
||||
?int $offset = null
|
||||
?int $offset = null,
|
||||
?Specification $spec = null
|
||||
): array;
|
||||
|
||||
public function countVisitsByTag(string $tag, ?DateRange $dateRange = null): int;
|
||||
public function countVisitsByTag(string $tag, ?DateRange $dateRange = null, ?Specification $spec = null): int;
|
||||
|
||||
public function countVisits(?ApiKey $apiKey = null): int;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class DeleteShortUrlService implements DeleteShortUrlServiceInterface
|
||||
{
|
||||
@@ -30,9 +31,12 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface
|
||||
* @throws Exception\ShortUrlNotFoundException
|
||||
* @throws Exception\DeleteShortUrlException
|
||||
*/
|
||||
public function deleteByShortCode(ShortUrlIdentifier $identifier, bool $ignoreThreshold = false): void
|
||||
{
|
||||
$shortUrl = $this->urlResolver->resolveShortUrl($identifier);
|
||||
public function deleteByShortCode(
|
||||
ShortUrlIdentifier $identifier,
|
||||
bool $ignoreThreshold = false,
|
||||
?ApiKey $apiKey = null
|
||||
): void {
|
||||
$shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey);
|
||||
if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) {
|
||||
throw Exception\DeleteShortUrlException::fromVisitsThreshold(
|
||||
$this->deleteShortUrlsOptions->getVisitsThreshold(),
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\Core\Exception;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface DeleteShortUrlServiceInterface
|
||||
{
|
||||
@@ -13,5 +14,9 @@ interface DeleteShortUrlServiceInterface
|
||||
* @throws Exception\ShortUrlNotFoundException
|
||||
* @throws Exception\DeleteShortUrlException
|
||||
*/
|
||||
public function deleteByShortCode(ShortUrlIdentifier $identifier, bool $ignoreThreshold = false): void;
|
||||
public function deleteByShortCode(
|
||||
ShortUrlIdentifier $identifier,
|
||||
bool $ignoreThreshold = false,
|
||||
?ApiKey $apiKey = null
|
||||
): void;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class ShortUrlResolver implements ShortUrlResolverInterface
|
||||
{
|
||||
@@ -22,11 +23,15 @@ class ShortUrlResolver implements ShortUrlResolverInterface
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function resolveShortUrl(ShortUrlIdentifier $identifier): ShortUrl
|
||||
public function resolveShortUrl(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): ShortUrl
|
||||
{
|
||||
/** @var ShortUrlRepository $shortUrlRepo */
|
||||
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
|
||||
$shortUrl = $shortUrlRepo->findOne($identifier->shortCode(), $identifier->domain());
|
||||
$shortUrl = $shortUrlRepo->findOne(
|
||||
$identifier->shortCode(),
|
||||
$identifier->domain(),
|
||||
$apiKey !== null ? $apiKey->spec() : null,
|
||||
);
|
||||
if ($shortUrl === null) {
|
||||
throw ShortUrlNotFoundException::fromNotFound($identifier);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface ShortUrlResolverInterface
|
||||
{
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function resolveShortUrl(ShortUrlIdentifier $identifier): ShortUrl;
|
||||
public function resolveShortUrl(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): ShortUrl;
|
||||
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
|
||||
@@ -17,6 +17,7 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
|
||||
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class ShortUrlService implements ShortUrlServiceInterface
|
||||
{
|
||||
@@ -39,11 +40,11 @@ class ShortUrlService implements ShortUrlServiceInterface
|
||||
/**
|
||||
* @return ShortUrl[]|Paginator
|
||||
*/
|
||||
public function listShortUrls(ShortUrlsParams $params): Paginator
|
||||
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||
{
|
||||
/** @var ShortUrlRepository $repo */
|
||||
$repo = $this->em->getRepository(ShortUrl::class);
|
||||
$paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $params));
|
||||
$paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $params, $apiKey));
|
||||
$paginator->setItemCountPerPage($params->itemsPerPage())
|
||||
->setCurrentPageNumber($params->page());
|
||||
|
||||
@@ -54,9 +55,9 @@ class ShortUrlService implements ShortUrlServiceInterface
|
||||
* @param string[] $tags
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags = []): ShortUrl
|
||||
public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags, ?ApiKey $apiKey = null): ShortUrl
|
||||
{
|
||||
$shortUrl = $this->urlResolver->resolveShortUrl($identifier);
|
||||
$shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey);
|
||||
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
|
||||
|
||||
$this->em->flush();
|
||||
@@ -68,13 +69,16 @@ class ShortUrlService implements ShortUrlServiceInterface
|
||||
* @throws ShortUrlNotFoundException
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit): ShortUrl
|
||||
{
|
||||
public function updateMetadataByShortCode(
|
||||
ShortUrlIdentifier $identifier,
|
||||
ShortUrlEdit $shortUrlEdit,
|
||||
?ApiKey $apiKey = null
|
||||
): ShortUrl {
|
||||
if ($shortUrlEdit->hasLongUrl()) {
|
||||
$this->urlValidator->validateUrl($shortUrlEdit->longUrl(), $shortUrlEdit->doValidateUrl());
|
||||
}
|
||||
|
||||
$shortUrl = $this->urlResolver->resolveShortUrl($identifier);
|
||||
$shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey);
|
||||
$shortUrl->update($shortUrlEdit);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
@@ -11,23 +11,28 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface ShortUrlServiceInterface
|
||||
{
|
||||
/**
|
||||
* @return ShortUrl[]|Paginator
|
||||
*/
|
||||
public function listShortUrls(ShortUrlsParams $params): Paginator;
|
||||
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||
|
||||
/**
|
||||
* @param string[] $tags
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags = []): ShortUrl;
|
||||
public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags, ?ApiKey $apiKey = null): ShortUrl;
|
||||
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit): ShortUrl;
|
||||
public function updateMetadataByShortCode(
|
||||
ShortUrlIdentifier $identifier,
|
||||
ShortUrlEdit $shortUrlEdit,
|
||||
?ApiKey $apiKey = null
|
||||
): ShortUrl;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class VisitsTracker implements VisitsTrackerInterface
|
||||
{
|
||||
@@ -52,17 +53,19 @@ class VisitsTracker implements VisitsTrackerInterface
|
||||
* @return Visit[]|Paginator
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function info(ShortUrlIdentifier $identifier, VisitsParams $params): Paginator
|
||||
public function info(ShortUrlIdentifier $identifier, VisitsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||
{
|
||||
$spec = $apiKey !== null ? $apiKey->spec() : null;
|
||||
|
||||
/** @var ShortUrlRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(ShortUrl::class);
|
||||
if (! $repo->shortCodeIsInUse($identifier->shortCode(), $identifier->domain())) {
|
||||
if (! $repo->shortCodeIsInUse($identifier->shortCode(), $identifier->domain(), $spec)) {
|
||||
throw ShortUrlNotFoundException::fromNotFound($identifier);
|
||||
}
|
||||
|
||||
/** @var VisitRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
$paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params));
|
||||
$paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params, $spec));
|
||||
$paginator->setItemCountPerPage($params->getItemsPerPage())
|
||||
->setCurrentPageNumber($params->getPage());
|
||||
|
||||
@@ -73,18 +76,17 @@ class VisitsTracker implements VisitsTrackerInterface
|
||||
* @return Visit[]|Paginator
|
||||
* @throws TagNotFoundException
|
||||
*/
|
||||
public function visitsForTag(string $tag, VisitsParams $params): Paginator
|
||||
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||
{
|
||||
/** @var TagRepository $tagRepo */
|
||||
$tagRepo = $this->em->getRepository(Tag::class);
|
||||
$count = $tagRepo->count(['name' => $tag]);
|
||||
if ($count === 0) {
|
||||
if (! $tagRepo->tagExists($tag, $apiKey)) {
|
||||
throw TagNotFoundException::fromTag($tag);
|
||||
}
|
||||
|
||||
/** @var VisitRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
$paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params));
|
||||
$paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params, $apiKey));
|
||||
$paginator->setItemCountPerPage($params->getItemsPerPage())
|
||||
->setCurrentPageNumber($params->getPage());
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface VisitsTrackerInterface
|
||||
{
|
||||
@@ -21,11 +22,11 @@ interface VisitsTrackerInterface
|
||||
* @return Visit[]|Paginator
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function info(ShortUrlIdentifier $identifier, VisitsParams $params): Paginator;
|
||||
public function info(ShortUrlIdentifier $identifier, VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||
|
||||
/**
|
||||
* @return Visit[]|Paginator
|
||||
* @throws TagNotFoundException
|
||||
*/
|
||||
public function visitsForTag(string $tag, VisitsParams $params): Paginator;
|
||||
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Resolver;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInterface
|
||||
{
|
||||
@@ -27,15 +26,4 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
|
||||
$existingDomain = $this->em->getRepository(Domain::class)->findOneBy(['authority' => $domain]);
|
||||
return $existingDomain ?? new Domain($domain);
|
||||
}
|
||||
|
||||
public function resolveApiKey(?string $key): ?ApiKey
|
||||
{
|
||||
if ($key === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var ApiKey|null $existingApiKey */
|
||||
$existingApiKey = $this->em->getRepository(ApiKey::class)->findOneBy(['key' => $key]);
|
||||
return $existingApiKey;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,8 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Resolver;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface ShortUrlRelationResolverInterface
|
||||
{
|
||||
public function resolveDomain(?string $domain): ?Domain;
|
||||
|
||||
public function resolveApiKey(?string $key): ?ApiKey;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Resolver;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterface
|
||||
{
|
||||
@@ -13,9 +12,4 @@ class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterfac
|
||||
{
|
||||
return $domain !== null ? new Domain($domain) : null;
|
||||
}
|
||||
|
||||
public function resolveApiKey(?string $key): ?ApiKey
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
28
module/Core/src/ShortUrl/Spec/BelongsToApiKey.php
Normal file
28
module/Core/src/ShortUrl/Spec/BelongsToApiKey.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Spec;
|
||||
|
||||
use Happyr\DoctrineSpecification\BaseSpecification;
|
||||
use Happyr\DoctrineSpecification\Filter\Filter;
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class BelongsToApiKey extends BaseSpecification
|
||||
{
|
||||
private ApiKey $apiKey;
|
||||
private string $dqlAlias;
|
||||
|
||||
public function __construct(ApiKey $apiKey, ?string $dqlAlias = null)
|
||||
{
|
||||
$this->apiKey = $apiKey;
|
||||
$this->dqlAlias = $dqlAlias ?? 's';
|
||||
parent::__construct($this->dqlAlias);
|
||||
}
|
||||
|
||||
protected function getSpec(): Filter
|
||||
{
|
||||
return Spec::eq('authorApiKey', $this->apiKey, $this->dqlAlias);
|
||||
}
|
||||
}
|
||||
29
module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php
Normal file
29
module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Spec;
|
||||
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class BelongsToApiKeyInlined implements Specification
|
||||
{
|
||||
private ApiKey $apiKey;
|
||||
|
||||
public function __construct(ApiKey $apiKey)
|
||||
{
|
||||
$this->apiKey = $apiKey;
|
||||
}
|
||||
|
||||
public function getFilter(QueryBuilder $qb, string $dqlAlias): string
|
||||
{
|
||||
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
|
||||
return (string) $qb->expr()->eq('s.authorApiKey', '\'' . $this->apiKey->getId() . '\'');
|
||||
}
|
||||
|
||||
public function modify(QueryBuilder $qb, string $dqlAlias): void
|
||||
{
|
||||
}
|
||||
}
|
||||
27
module/Core/src/ShortUrl/Spec/BelongsToDomain.php
Normal file
27
module/Core/src/ShortUrl/Spec/BelongsToDomain.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Spec;
|
||||
|
||||
use Happyr\DoctrineSpecification\BaseSpecification;
|
||||
use Happyr\DoctrineSpecification\Filter\Filter;
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
|
||||
class BelongsToDomain extends BaseSpecification
|
||||
{
|
||||
private string $domainId;
|
||||
private string $dqlAlias;
|
||||
|
||||
public function __construct(string $domainId, ?string $dqlAlias = null)
|
||||
{
|
||||
$this->domainId = $domainId;
|
||||
$this->dqlAlias = $dqlAlias ?? 's';
|
||||
parent::__construct($this->dqlAlias);
|
||||
}
|
||||
|
||||
protected function getSpec(): Filter
|
||||
{
|
||||
return Spec::eq('domain', $this->domainId, $this->dqlAlias);
|
||||
}
|
||||
}
|
||||
28
module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php
Normal file
28
module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Spec;
|
||||
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
|
||||
class BelongsToDomainInlined implements Specification
|
||||
{
|
||||
private string $domainId;
|
||||
|
||||
public function __construct(string $domainId)
|
||||
{
|
||||
$this->domainId = $domainId;
|
||||
}
|
||||
|
||||
public function getFilter(QueryBuilder $qb, string $dqlAlias): string
|
||||
{
|
||||
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
|
||||
return (string) $qb->expr()->eq('s.domain', '\'' . $this->domainId . '\'');
|
||||
}
|
||||
|
||||
public function modify(QueryBuilder $qb, string $dqlAlias): void
|
||||
{
|
||||
}
|
||||
}
|
||||
68
module/Core/src/Tag/Model/TagRenaming.php
Normal file
68
module/Core/src/Tag/Model/TagRenaming.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Tag\Model;
|
||||
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class TagRenaming
|
||||
{
|
||||
private string $oldName;
|
||||
private string $newName;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public static function fromNames(string $oldName, string $newName): self
|
||||
{
|
||||
$o = new self();
|
||||
$o->oldName = $oldName;
|
||||
$o->newName = $newName;
|
||||
|
||||
return $o;
|
||||
}
|
||||
|
||||
public static function fromArray(array $payload): self
|
||||
{
|
||||
if (! isset($payload['oldName'], $payload['newName'])) {
|
||||
throw ValidationException::fromArray([
|
||||
'oldName' => 'oldName is required',
|
||||
'newName' => 'newName is required',
|
||||
]);
|
||||
}
|
||||
|
||||
return self::fromNames($payload['oldName'], $payload['newName']);
|
||||
}
|
||||
|
||||
public function oldName(): string
|
||||
{
|
||||
return $this->oldName;
|
||||
}
|
||||
|
||||
public function newName(): string
|
||||
{
|
||||
return $this->newName;
|
||||
}
|
||||
|
||||
public function nameChanged(): bool
|
||||
{
|
||||
return $this->oldName !== $this->newName;
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return sprintf('%s to %s', $this->oldName, $this->newName);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'oldName' => $this->oldName,
|
||||
'newName' => $this->newName,
|
||||
];
|
||||
}
|
||||
}
|
||||
30
module/Core/src/Tag/Spec/CountTagsWithName.php
Normal file
30
module/Core/src/Tag/Spec/CountTagsWithName.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Tag\Spec;
|
||||
|
||||
use Happyr\DoctrineSpecification\BaseSpecification;
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
|
||||
class CountTagsWithName extends BaseSpecification
|
||||
{
|
||||
private string $tagName;
|
||||
|
||||
public function __construct(string $tagName)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->tagName = $tagName;
|
||||
}
|
||||
|
||||
protected function getSpec(): Specification
|
||||
{
|
||||
return Spec::countOf(
|
||||
Spec::andX(
|
||||
Spec::select('id'),
|
||||
Spec::eq('name', $this->tagName),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,18 @@ namespace Shlinkio\Shlink\Core\Tag;
|
||||
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM;
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
|
||||
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class TagService implements TagServiceInterface
|
||||
{
|
||||
@@ -28,28 +33,38 @@ class TagService implements TagServiceInterface
|
||||
/**
|
||||
* @return Tag[]
|
||||
*/
|
||||
public function listTags(): array
|
||||
public function listTags(?ApiKey $apiKey = null): array
|
||||
{
|
||||
/** @var TagRepository $repo */
|
||||
$repo = $this->em->getRepository(Tag::class);
|
||||
/** @var Tag[] $tags */
|
||||
$tags = $this->em->getRepository(Tag::class)->findBy([], ['name' => 'ASC']);
|
||||
$tags = $repo->match(Spec::andX(
|
||||
Spec::orderBy('name'),
|
||||
new WithApiKeySpecsEnsuringJoin($apiKey),
|
||||
));
|
||||
return $tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TagInfo[]
|
||||
*/
|
||||
public function tagsInfo(): array
|
||||
public function tagsInfo(?ApiKey $apiKey = null): array
|
||||
{
|
||||
/** @var TagRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Tag::class);
|
||||
return $repo->findTagsWithInfo();
|
||||
return $repo->findTagsWithInfo($apiKey !== null ? $apiKey->spec() : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $tagNames
|
||||
* @throws ForbiddenTagOperationException
|
||||
*/
|
||||
public function deleteTags(array $tagNames): void
|
||||
public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void
|
||||
{
|
||||
if ($apiKey !== null && ! $apiKey->isAdmin()) {
|
||||
throw ForbiddenTagOperationException::forDeletion();
|
||||
}
|
||||
|
||||
/** @var TagRepository $repo */
|
||||
$repo = $this->em->getRepository(Tag::class);
|
||||
$repo->deleteByName($tagNames);
|
||||
@@ -73,24 +88,29 @@ class TagService implements TagServiceInterface
|
||||
/**
|
||||
* @throws TagNotFoundException
|
||||
* @throws TagConflictException
|
||||
* @throws ForbiddenTagOperationException
|
||||
*/
|
||||
public function renameTag(string $oldName, string $newName): Tag
|
||||
public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag
|
||||
{
|
||||
if ($apiKey !== null && ! $apiKey->isAdmin()) {
|
||||
throw ForbiddenTagOperationException::forRenaming();
|
||||
}
|
||||
|
||||
/** @var TagRepository $repo */
|
||||
$repo = $this->em->getRepository(Tag::class);
|
||||
|
||||
/** @var Tag|null $tag */
|
||||
$tag = $repo->findOneBy(['name' => $oldName]);
|
||||
$tag = $repo->findOneBy(['name' => $renaming->oldName()]);
|
||||
if ($tag === null) {
|
||||
throw TagNotFoundException::fromTag($oldName);
|
||||
throw TagNotFoundException::fromTag($renaming->oldName());
|
||||
}
|
||||
|
||||
$newNameExists = $newName !== $oldName && $repo->count(['name' => $newName]) > 0;
|
||||
$newNameExists = $renaming->nameChanged() && $repo->count(['name' => $renaming->newName()]) > 0;
|
||||
if ($newNameExists) {
|
||||
throw TagConflictException::fromExistingTag($oldName, $newName);
|
||||
throw TagConflictException::forExistingTag($renaming);
|
||||
}
|
||||
|
||||
$tag->rename($newName);
|
||||
$tag->rename($renaming->newName());
|
||||
$this->em->flush();
|
||||
|
||||
return $tag;
|
||||
|
||||
@@ -6,26 +6,30 @@ namespace Shlinkio\Shlink\Core\Tag;
|
||||
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface TagServiceInterface
|
||||
{
|
||||
/**
|
||||
* @return Tag[]
|
||||
*/
|
||||
public function listTags(): array;
|
||||
public function listTags(?ApiKey $apiKey = null): array;
|
||||
|
||||
/**
|
||||
* @return TagInfo[]
|
||||
*/
|
||||
public function tagsInfo(): array;
|
||||
public function tagsInfo(?ApiKey $apiKey = null): array;
|
||||
|
||||
/**
|
||||
* @param string[] $tagNames
|
||||
* @throws ForbiddenTagOperationException
|
||||
*/
|
||||
public function deleteTags(array $tagNames): void;
|
||||
public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
@@ -37,6 +41,7 @@ interface TagServiceInterface
|
||||
/**
|
||||
* @throws TagNotFoundException
|
||||
* @throws TagConflictException
|
||||
* @throws ForbiddenTagOperationException
|
||||
*/
|
||||
public function renameTag(string $oldName, string $newName): Tag;
|
||||
public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use Laminas\InputFilter\InputFilter;
|
||||
use Laminas\Validator;
|
||||
use Shlinkio\Shlink\Common\Validation;
|
||||
use Shlinkio\Shlink\Core\Util\CocurSymfonySluggerBridge;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
use const Shlinkio\Shlink\Core\CUSTOM_SLUGS_REGEXP;
|
||||
use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH;
|
||||
@@ -54,6 +55,7 @@ class ShortUrlMetaInputFilter extends InputFilter
|
||||
$customSlug->getFilterChain()->attach(new Validation\SluggerFilter(new CocurSymfonySluggerBridge(new Slugify([
|
||||
'regexp' => CUSTOM_SLUGS_REGEXP,
|
||||
'lowercase' => false, // We want to keep it case sensitive
|
||||
'rulesets' => ['default'],
|
||||
]))));
|
||||
$customSlug->getValidatorChain()->attach(new Validator\NotEmpty([
|
||||
Validator\NotEmpty::STRING,
|
||||
@@ -72,7 +74,11 @@ class ShortUrlMetaInputFilter extends InputFilter
|
||||
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator());
|
||||
$this->add($domain);
|
||||
|
||||
$this->add($this->createInput(self::API_KEY, false));
|
||||
$apiKeyInput = new Input(self::API_KEY);
|
||||
$apiKeyInput
|
||||
->setRequired(false)
|
||||
->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class]));
|
||||
$this->add($apiKeyInput);
|
||||
}
|
||||
|
||||
private function createPositiveNumberInput(string $name, int $min = 1): Input
|
||||
|
||||
@@ -8,6 +8,7 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class VisitsStatsHelper implements VisitsStatsHelperInterface
|
||||
{
|
||||
@@ -18,15 +19,15 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface
|
||||
$this->em = $em;
|
||||
}
|
||||
|
||||
public function getVisitsStats(): VisitsStats
|
||||
public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats
|
||||
{
|
||||
return new VisitsStats($this->getVisitsCount());
|
||||
return new VisitsStats($this->getVisitsCount($apiKey));
|
||||
}
|
||||
|
||||
private function getVisitsCount(): int
|
||||
private function getVisitsCount(?ApiKey $apiKey): int
|
||||
{
|
||||
/** @var VisitRepository $visitsRepo */
|
||||
$visitsRepo = $this->em->getRepository(Visit::class);
|
||||
return $visitsRepo->count([]);
|
||||
return $visitsRepo->countVisits($apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Visit;
|
||||
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface VisitsStatsHelperInterface
|
||||
{
|
||||
public function getVisitsStats(): VisitsStats;
|
||||
public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats;
|
||||
}
|
||||
|
||||
27
module/Core/templates/404.html
Normal file
27
module/Core/templates/404.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Not Found | Shlink</title>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
|
||||
<link rel="shortcut icon" href="/favicon.ico">
|
||||
<style>
|
||||
html, body {height: 100%}
|
||||
.app {height: 100vh; display: flex; align-items: center; justify-content: center; flex-flow: column;}
|
||||
p {margin-bottom: 20px;}
|
||||
body {text-align: center;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<main class="container">
|
||||
<h1>404</h1>
|
||||
<hr>
|
||||
<h3>Page not found.</h3>
|
||||
<p>The page you requested could not be found.</p>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php $this->layout('ShlinkCore::layout/default') ?>
|
||||
|
||||
<?php $this->start('title') ?>
|
||||
Not Found
|
||||
<?php $this->end() ?>
|
||||
|
||||
<?php $this->start('stylesheets') ?>
|
||||
<style>
|
||||
p {margin-bottom: 20px;}
|
||||
body {text-align: center;}
|
||||
</style>
|
||||
<?php $this->end() ?>
|
||||
|
||||
<?php $this->start('main') ?>
|
||||
<h1>404</h1>
|
||||
<hr>
|
||||
<h3>Page not found.</h3>
|
||||
<p>The page you requested could not be found.</p>
|
||||
<?php $this->end() ?>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user