mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-01 12:43:11 +08:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6792d3fb8 | ||
|
|
2f0d658432 | ||
|
|
8c1865c3ec | ||
|
|
096d2098d6 | ||
|
|
882d64ae11 | ||
|
|
3352bcd186 | ||
|
|
9743c1624d | ||
|
|
e85d59c5a4 | ||
|
|
ac0ff8fb94 | ||
|
|
90f93ee4ec | ||
|
|
794d926e3a | ||
|
|
bd41ebef9f | ||
|
|
725370704f | ||
|
|
f03b7689ce | ||
|
|
fb31e2a5e4 | ||
|
|
d688c6da7e | ||
|
|
618784dc3b | ||
|
|
9d64d4ed1d | ||
|
|
7f02243c6c | ||
|
|
3916c68126 | ||
|
|
a6f0c66331 | ||
|
|
bdfb220126 | ||
|
|
abcf2f86be | ||
|
|
a4d8ebdfc9 | ||
|
|
b51c149c30 | ||
|
|
39095a3098 | ||
|
|
765199727e | ||
|
|
c7043af853 | ||
|
|
02a8ef7dd9 | ||
|
|
6bb8c1b2f5 | ||
|
|
3cf253fd0f | ||
|
|
0365728337 | ||
|
|
b8143a5bb4 | ||
|
|
531a19dde9 | ||
|
|
69ff7de481 | ||
|
|
ffc0555c7c | ||
|
|
84a7981dfa | ||
|
|
2573c2bf98 | ||
|
|
3b4c1501f3 | ||
|
|
e836bedecc | ||
|
|
a797b74a70 | ||
|
|
ab497403ca | ||
|
|
d4dea9a1d2 | ||
|
|
28d93ea5e0 | ||
|
|
e6a31b16ed | ||
|
|
9553192281 | ||
|
|
74069f2d24 | ||
|
|
b4b00a57c1 | ||
|
|
a516ef691d | ||
|
|
e80b7448f5 | ||
|
|
f129544f83 | ||
|
|
9fa291a32f | ||
|
|
d06e92ffc2 | ||
|
|
1b83344995 | ||
|
|
cf49393ef2 | ||
|
|
f2ecbceae9 | ||
|
|
c582eba753 | ||
|
|
de86b62cdd | ||
|
|
73150471e9 | ||
|
|
ec751f4ac2 | ||
|
|
e652166289 | ||
|
|
a671d555cb | ||
|
|
6240554f4c | ||
|
|
4ee9c9bbe3 | ||
|
|
c830439085 | ||
|
|
f2196583c8 | ||
|
|
3dbca2115c | ||
|
|
b45d8de27d | ||
|
|
3ba46bbbfa | ||
|
|
06f3f0c86c | ||
|
|
06f07e3e40 | ||
|
|
740740b8c6 | ||
|
|
b6ed39b18b | ||
|
|
958c4704f8 | ||
|
|
ef075fb0ce | ||
|
|
556520583a | ||
|
|
399c56a097 | ||
|
|
f078d95588 | ||
|
|
33911afcd6 | ||
|
|
ae8d31e83f | ||
|
|
72c4052012 |
2
.github/workflows/ci-db-tests.yml
vendored
2
.github/workflows/ci-db-tests.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-extensions: openswoole-4.12.1, pdo_sqlsrv-5.10.1
|
||||
php-extensions: openswoole-22.0.0, pdo_sqlsrv-5.10.1
|
||||
extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }}
|
||||
- name: Create test database
|
||||
if: ${{ inputs.platform == 'ms' }}
|
||||
|
||||
2
.github/workflows/ci-mutation-tests.yml
vendored
2
.github/workflows/ci-mutation-tests.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-extensions: openswoole-4.12.1
|
||||
php-extensions: openswoole-22.0.0
|
||||
extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
|
||||
2
.github/workflows/ci-tests.yml
vendored
2
.github/workflows/ci-tests.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-extensions: openswoole-4.12.1
|
||||
php-extensions: openswoole-22.0.0
|
||||
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
|
||||
- run: composer test:${{ inputs.test-group }}:ci
|
||||
- uses: actions/upload-artifact@v3
|
||||
|
||||
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-extensions: openswoole-4.12.1
|
||||
php-extensions: openswoole-22.0.0
|
||||
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ matrix.command }}
|
||||
- run: composer ${{ matrix.command }}
|
||||
|
||||
@@ -69,8 +69,8 @@ jobs:
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr
|
||||
- run: composer install --no-interaction --prefer-dist --ignore-platform-req=ext-openswoole
|
||||
- run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr
|
||||
- run: composer test:api:rr
|
||||
|
||||
sqlite-db-tests:
|
||||
@@ -168,10 +168,7 @@ jobs:
|
||||
- upload-coverage
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: geekyeggo/delete-artifact@v1
|
||||
- uses: geekyeggo/delete-artifact@v2
|
||||
with:
|
||||
name: |
|
||||
coverage-unit
|
||||
coverage-db
|
||||
coverage-api
|
||||
coverage-cli
|
||||
coverage-*
|
||||
|
||||
35
.github/workflows/publish-docker-image.yml
vendored
35
.github/workflows/publish-docker-image.yml
vendored
@@ -2,8 +2,6 @@ name: Build and publish docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
paths-ignore:
|
||||
- 'LICENSE'
|
||||
- '.*'
|
||||
@@ -12,24 +10,35 @@ on:
|
||||
- '*.yml*'
|
||||
- '*.json5'
|
||||
- '*.neon'
|
||||
branches:
|
||||
- develop
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-openswoole:
|
||||
build-image:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- runtime: 'rr'
|
||||
platforms: 'linux/arm64/v8,linux/amd64'
|
||||
- runtime: 'rr'
|
||||
tag-suffix: 'roadrunner'
|
||||
platforms: 'linux/arm64/v8,linux/amd64'
|
||||
- runtime: 'openswoole'
|
||||
tag-suffix: 'openswoole'
|
||||
platforms: 'linux/arm/v7,linux/arm64/v8,linux/amd64'
|
||||
- runtime: 'rr'
|
||||
tag-suffix: 'non-root'
|
||||
platforms: 'linux/arm64/v8,linux/amd64'
|
||||
user-id: '1001'
|
||||
uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main
|
||||
secrets: inherit
|
||||
with:
|
||||
image-name: shlinkio/shlink
|
||||
version-arg-name: SHLINK_VERSION
|
||||
|
||||
build-roadrunner:
|
||||
uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main
|
||||
secrets: inherit
|
||||
with:
|
||||
image-name: shlinkio/shlink
|
||||
version-arg-name: SHLINK_VERSION
|
||||
platforms: 'linux/arm64/v8,linux/amd64'
|
||||
tags-suffix: roadrunner
|
||||
platforms: ${{ matrix.platforms }}
|
||||
tags-suffix: ${{ matrix.tag-suffix }}
|
||||
extra-build-args: |
|
||||
SHLINK_RUNTIME=rr
|
||||
SHLINK_RUNTIME=${{ matrix.runtime }}
|
||||
SHLINK_USER_ID=${{ matrix.user-id && matrix.user-id || 'root' }}
|
||||
|
||||
10
.github/workflows/publish-release.yml
vendored
10
.github/workflows/publish-release.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-extensions: openswoole-4.12.1
|
||||
php-extensions: openswoole-22.0.0
|
||||
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
|
||||
install-deps: 'no'
|
||||
- if: ${{ matrix.swoole == 'yes' }}
|
||||
@@ -49,11 +49,7 @@ jobs:
|
||||
delete-artifacts:
|
||||
needs: ['publish']
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.1', '8.2']
|
||||
swoole: ['yes', 'no']
|
||||
steps:
|
||||
- uses: geekyeggo/delete-artifact@v1
|
||||
- uses: geekyeggo/delete-artifact@v2
|
||||
with:
|
||||
name: dist-files-${{ matrix.php-version }}-${{ matrix.swoole }}
|
||||
name: dist-files-*
|
||||
|
||||
4
.github/workflows/publish-swagger-spec.yml
vendored
4
.github/workflows/publish-swagger-spec.yml
vendored
@@ -20,13 +20,13 @@ jobs:
|
||||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-extensions: openswoole-4.12.1
|
||||
php-extensions: openswoole-22.0.0
|
||||
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
|
||||
- run: composer swagger:inline
|
||||
- run: mkdir ${{ steps.determine_version.outputs.version }}
|
||||
- run: mv docs/swagger/swagger-inlined.json ${{ steps.determine_version.outputs.version }}/open-api-spec.json
|
||||
- name: Publish spec
|
||||
uses: JamesIves/github-pages-deploy-action@4.1.7
|
||||
uses: JamesIves/github-pages-deploy-action@4
|
||||
with:
|
||||
token: ${{ secrets.OAS_PUBLISH_TOKEN }}
|
||||
repository-name: 'shlinkio/shlink-open-api-specs'
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,4 @@
|
||||
.idea
|
||||
bin/.rr.*
|
||||
bin/rr
|
||||
config/roadrunner/.pid
|
||||
build
|
||||
|
||||
67
CHANGELOG.md
67
CHANGELOG.md
@@ -4,6 +4,73 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [3.6.0] - 2023-05-24
|
||||
### Added
|
||||
* [#1148](https://github.com/shlinkio/shlink/issues/1148) Add support to delete short URL visits.
|
||||
|
||||
This can be done via `DELETE /short-urls/{shortCode}/visits` REST endpoint or via `short-url:visits-delete` console command.
|
||||
|
||||
The CLI command includes a warning and requires the user to confirm before proceeding.
|
||||
|
||||
* [#1681](https://github.com/shlinkio/shlink/issues/1681) Add support to delete orphan visits.
|
||||
|
||||
This can be done via `DELETE /visits/orphan` REST endpoint or via `visit:orphan-delete` console command.
|
||||
|
||||
The CLI command includes a warning and requires the user to confirm before proceeding.
|
||||
|
||||
* [#1753](https://github.com/shlinkio/shlink/issues/1753) Add a new `vendor/bin/shlink-installer init` command that can be used to automate Shlink installations.
|
||||
|
||||
This command can create the initial database, update it, create proxies, clean cache, download initial GeoLite db files, etc
|
||||
|
||||
The official docker image also uses it on its entry point script.
|
||||
|
||||
* [#1656](https://github.com/shlinkio/shlink/issues/1656) Add support for openswoole 22
|
||||
* [#1784](https://github.com/shlinkio/shlink/issues/1784) Add new docker tag where the container runs as a non-root user.
|
||||
* [#953](https://github.com/shlinkio/shlink/issues/953) Add locks that prevent errors on duplicated keys when creating short URLs in parallel that depend on the same new tag or domain.
|
||||
|
||||
### Changed
|
||||
* [#1755](https://github.com/shlinkio/shlink/issues/1755) Update to roadrunner 2023
|
||||
* [#1745](https://github.com/shlinkio/shlink/issues/1745) Roadrunner is now the default docker runtime.
|
||||
|
||||
There are now three different docker images published:
|
||||
|
||||
* Versions without suffix (like `3.6.0`) will contain the default runtime, whichever it is.
|
||||
* Versions with `-roadrunner` suffix (like `3.6.0-roadrunner`) will always use roadrunner as the runtime, even if default one changes in the future.
|
||||
* Versions with `-openswoole` suffix (like `3.6.0-openswoole`) will always use openswoole as the runtime, even if default one changes in the future.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#1760](https://github.com/shlinkio/shlink/issues/1760) Fix domain not being set to null when importing short URLs with default domain.
|
||||
* [#953](https://github.com/shlinkio/shlink/issues/953) Fix duplicated key errors and short URL creation failing when creating short URLs in parallel that depend on the same new tag or domain.
|
||||
* [#1741](https://github.com/shlinkio/shlink/issues/1741) Fix randomly using 100% CPU in task workers when trying to download GeoLite DB files.
|
||||
* Fix Shlink trying to connect to RabbitMQ even if configuration set to not connect.
|
||||
|
||||
|
||||
## [3.5.4] - 2023-04-12
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#1742](https://github.com/shlinkio/shlink/issues/1742) Fix URLs using schemas which do not contain `//`, like `mailto:`, to no longer be considered valid.
|
||||
* [#1743](https://github.com/shlinkio/shlink/issues/1743) Fix Error when trying to create short URLs from CLI on an openswoole context.
|
||||
|
||||
Unfortunately the reason are real-time updates do not work with openswoole when outside an openswoole request, so the feature has been disabled for that context.
|
||||
|
||||
|
||||
## [3.5.3] - 2023-03-31
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
32
Dockerfile
32
Dockerfile
@@ -2,13 +2,16 @@ FROM php:8.2-alpine3.17 as base
|
||||
|
||||
ARG SHLINK_VERSION=latest
|
||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||
ARG SHLINK_RUNTIME=openswoole
|
||||
ARG SHLINK_RUNTIME=rr
|
||||
ENV SHLINK_RUNTIME ${SHLINK_RUNTIME}
|
||||
ENV OPENSWOOLE_VERSION 4.12.1
|
||||
ARG SHLINK_USER_ID='root'
|
||||
ENV SHLINK_USER_ID ${SHLINK_USER_ID}
|
||||
|
||||
ENV OPENSWOOLE_VERSION 22.0.0
|
||||
ENV PDO_SQLSRV_VERSION 5.10.1
|
||||
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
|
||||
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
|
||||
ENV LC_ALL "C"
|
||||
ENV LC_ALL 'C'
|
||||
|
||||
WORKDIR /etc/shlink
|
||||
|
||||
@@ -43,11 +46,12 @@ FROM base as builder
|
||||
COPY . .
|
||||
COPY --from=composer:2 /usr/bin/composer ./composer.phar
|
||||
RUN apk add --no-cache git && \
|
||||
php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction && \
|
||||
# FIXME Ignoring ext-openswoole platform req, as it makes install fail with roadrunner, even though it's a dev dependency and we are passing --no-dev
|
||||
php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction --ignore-platform-req=ext-openswoole && \
|
||||
if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \
|
||||
php composer.phar remove spiral/roadrunner spiral/roadrunner-jobs --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interactionc ; \
|
||||
elif [ $SHLINK_RUNTIME == 'rr' ]; then \
|
||||
php composer.phar remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction ; \
|
||||
php composer.phar remove spiral/roadrunner spiral/roadrunner-jobs spiral/roadrunner-cli spiral/roadrunner-http --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction ; \
|
||||
elif [ "$SHLINK_RUNTIME" == 'rr' ]; then \
|
||||
php composer.phar remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction --ignore-platform-req=ext-openswoole ; \
|
||||
fi; \
|
||||
php composer.phar clear-cache && \
|
||||
rm -r docker composer.* && \
|
||||
@@ -58,10 +62,10 @@ RUN apk add --no-cache git && \
|
||||
FROM base
|
||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||
|
||||
COPY --from=builder /etc/shlink .
|
||||
COPY --from=builder --chown=${SHLINK_USER_ID} /etc/shlink .
|
||||
RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink && \
|
||||
if [ "$SHLINK_RUNTIME" == 'rr' ]; then \
|
||||
php ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr ; \
|
||||
php ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr ; \
|
||||
fi;
|
||||
|
||||
# Expose default port
|
||||
@@ -72,14 +76,6 @@ COPY docker/docker-entrypoint.sh docker-entrypoint.sh
|
||||
COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php
|
||||
COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/
|
||||
|
||||
# Change the ownership of /etc/shlink/data to be writable, then change the user to non-root
|
||||
# FIXME Disabled for now, as it conflicts with ENABLE_PERIODIC_VISIT_LOCATE, which is used to configure a cron as root.
|
||||
# Ref: https://github.com/shlinkio/shlink/issues/1132
|
||||
#RUN chown 1001 /etc/shlink/data
|
||||
#RUN chown 1001 /etc/shlink/data/locks
|
||||
#RUN chown 1001 /etc/shlink/data/proxies
|
||||
#RUN chown 1001 /etc/shlink/data/cache
|
||||
#RUN chown 1001 /etc/shlink/data/log
|
||||
#USER 1001
|
||||
USER ${SHLINK_USER_ID}
|
||||
|
||||
ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"]
|
||||
|
||||
2
build.sh
2
build.sh
@@ -39,7 +39,7 @@ if [[ $noSwoole ]]; then
|
||||
${composerBin} remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev $composerFlags
|
||||
else
|
||||
# If generating a dist for openswoole, uninstall RoadRunner
|
||||
${composerBin} remove spiral/roadrunner spiral/roadrunner-jobs --with-all-dependencies --update-no-dev $composerFlags
|
||||
${composerBin} remove spiral/roadrunner spiral/roadrunner-jobs spiral/roadrunner-cli spiral/roadrunner-http --with-all-dependencies --update-no-dev $composerFlags
|
||||
fi
|
||||
|
||||
# Delete development files
|
||||
|
||||
@@ -45,14 +45,17 @@
|
||||
"php-middleware/request-id": "^4.1",
|
||||
"pugx/shortid-php": "^1.1",
|
||||
"ramsey/uuid": "^4.7",
|
||||
"shlinkio/shlink-common": "^5.4",
|
||||
"shlinkio/shlink-common": "^5.5",
|
||||
"shlinkio/shlink-config": "^2.4",
|
||||
"shlinkio/shlink-event-dispatcher": "^2.6",
|
||||
"shlinkio/shlink-importer": "^5.0",
|
||||
"shlinkio/shlink-installer": "^8.3",
|
||||
"shlinkio/shlink-event-dispatcher": "^3.0",
|
||||
"shlinkio/shlink-importer": "^5.1",
|
||||
"shlinkio/shlink-installer": "^8.4",
|
||||
"shlinkio/shlink-ip-geolocation": "^3.2",
|
||||
"spiral/roadrunner": "^2.12",
|
||||
"spiral/roadrunner-jobs": "^2.7",
|
||||
"shlinkio/shlink-json": "^1.0",
|
||||
"spiral/roadrunner": "^2023.1",
|
||||
"spiral/roadrunner-cli": "^2.5",
|
||||
"spiral/roadrunner-http": "^3.0",
|
||||
"spiral/roadrunner-jobs": "^4.0",
|
||||
"symfony/console": "^6.2",
|
||||
"symfony/filesystem": "^6.2",
|
||||
"symfony/lock": "^6.2",
|
||||
@@ -62,17 +65,17 @@
|
||||
"require-dev": {
|
||||
"cebe/php-openapi": "^1.7",
|
||||
"devster/ubench": "^2.1",
|
||||
"infection/infection": "^0.26.19",
|
||||
"openswoole/ide-helper": "~4.11.5",
|
||||
"infection/infection": "^0.27",
|
||||
"openswoole/ide-helper": "~22.0.0",
|
||||
"phpstan/phpstan": "^1.9",
|
||||
"phpstan/phpstan-doctrine": "^1.3",
|
||||
"phpstan/phpstan-phpunit": "^1.3",
|
||||
"phpstan/phpstan-symfony": "^1.2",
|
||||
"phpunit/php-code-coverage": "^10.0",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"phpunit/phpunit": "^10.1",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"shlinkio/php-coding-standard": "~2.3.0",
|
||||
"shlinkio/shlink-test-utils": "^3.5",
|
||||
"shlinkio/shlink-test-utils": "^3.6",
|
||||
"symfony/var-dumper": "^6.2",
|
||||
"veewee/composer-run-parallel": "^1.2"
|
||||
},
|
||||
@@ -107,7 +110,7 @@
|
||||
"@parallel cs stan swagger:validate test:unit:ci test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
|
||||
"@parallel infect:test:api infect:test:cli infect:ci:unit infect:ci:db"
|
||||
],
|
||||
"cs": "phpcs",
|
||||
"cs": "phpcs -s",
|
||||
"cs:fix": "phpcbf",
|
||||
"stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/test* module/*/config config docker/config data/migrations --level=8",
|
||||
"test": [
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Mezzio\Application;
|
||||
use Mezzio\Container;
|
||||
use Psr\Http\Client\ClientInterface;
|
||||
use Psr\Http\Message\ServerRequestFactoryInterface;
|
||||
@@ -20,7 +21,7 @@ return [
|
||||
],
|
||||
|
||||
'delegators' => [
|
||||
Mezzio\Application::class => [
|
||||
Application::class => [
|
||||
Container\ApplicationConfigInjectionDelegator::class,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -4,51 +4,63 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink;
|
||||
|
||||
use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use Monolog\Level;
|
||||
use Monolog\Logger;
|
||||
use PhpMiddleware\RequestId;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Shlinkio\Shlink\Common\Logger\LoggerFactory;
|
||||
use Shlinkio\Shlink\Common\Logger\LoggerType;
|
||||
use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware;
|
||||
|
||||
$common = [
|
||||
'level' => Level::Info->value,
|
||||
'processors' => [RequestId\MonologProcessor::class],
|
||||
'line_format' => '[%datetime%] [%extra.request_id%] %channel%.%level_name% - %message%',
|
||||
];
|
||||
use function Shlinkio\Shlink\Config\runningInRoadRunner;
|
||||
|
||||
return [
|
||||
return (static function (): array {
|
||||
$common = [
|
||||
'level' => Level::Info->value,
|
||||
'processors' => [RequestId\MonologProcessor::class],
|
||||
'line_format' => '[%datetime%] [%extra.request_id%] %channel%.%level_name% - %message%',
|
||||
];
|
||||
|
||||
'logger' => [
|
||||
'Shlink' => [
|
||||
'type' => LoggerType::FILE->value,
|
||||
...$common,
|
||||
],
|
||||
'Access' => [
|
||||
'type' => LoggerType::STREAM->value,
|
||||
...$common,
|
||||
],
|
||||
],
|
||||
return [
|
||||
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
'Logger_Shlink' => [LoggerFactory::class, 'Shlink'],
|
||||
'Logger_Access' => [LoggerFactory::class, 'Access'],
|
||||
],
|
||||
'aliases' => [
|
||||
'logger' => 'Logger_Shlink',
|
||||
Logger::class => 'Logger_Shlink',
|
||||
LoggerInterface::class => 'Logger_Shlink',
|
||||
],
|
||||
],
|
||||
|
||||
'mezzio-swoole' => [
|
||||
'swoole-http-server' => [
|
||||
'logger' => [
|
||||
'logger-name' => 'Logger_Access',
|
||||
'format' => '%u "%r" %>s %B',
|
||||
'logger' => [
|
||||
'Shlink' => [
|
||||
'type' => LoggerType::FILE->value,
|
||||
...$common,
|
||||
],
|
||||
'Access' => [
|
||||
'type' => LoggerType::STREAM->value,
|
||||
'destination' => 'php://stderr',
|
||||
'add_new_line' => ! runningInRoadRunner(),
|
||||
...$common,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
'Logger_Shlink' => [LoggerFactory::class, 'Shlink'],
|
||||
'Logger_Access' => [LoggerFactory::class, 'Access'],
|
||||
NullLogger::class => InvokableFactory::class,
|
||||
],
|
||||
'aliases' => [
|
||||
'logger' => 'Logger_Shlink',
|
||||
Logger::class => 'Logger_Shlink',
|
||||
LoggerInterface::class => 'Logger_Shlink',
|
||||
AccessLogMiddleware::LOGGER_SERVICE_NAME => 'Logger_Access',
|
||||
],
|
||||
],
|
||||
|
||||
'mezzio-swoole' => [
|
||||
'swoole-http-server' => [
|
||||
'logger' => [
|
||||
// Let's disable mezio-swoole access logging, so that we can provide our own implementation,
|
||||
// consistent for roadrunner and openswoole
|
||||
'logger-name' => NullLogger::class,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
})();
|
||||
|
||||
@@ -5,16 +5,12 @@ declare(strict_types=1);
|
||||
use Monolog\Level;
|
||||
use Shlinkio\Shlink\Common\Logger\LoggerType;
|
||||
|
||||
use function Shlinkio\Shlink\Config\runningInOpenswoole;
|
||||
|
||||
$logToStream = runningInOpenswoole();
|
||||
|
||||
return [
|
||||
|
||||
'logger' => [
|
||||
'Shlink' => [
|
||||
// For openswoole, send logs as stream
|
||||
'type' => $logToStream ? LoggerType::STREAM->value : LoggerType::FILE->value,
|
||||
'type' => LoggerType::STREAM->value,
|
||||
'destination' => 'php://stderr',
|
||||
'level' => Level::Debug->value,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -9,6 +9,7 @@ use Mezzio\ProblemDetails;
|
||||
use Mezzio\Router;
|
||||
use PhpMiddleware\RequestId\RequestIdMiddleware;
|
||||
use RKA\Middleware\IpAddress;
|
||||
use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware;
|
||||
use Shlinkio\Shlink\Common\Middleware\ContentLengthMiddleware;
|
||||
|
||||
return [
|
||||
@@ -16,6 +17,7 @@ return [
|
||||
'middleware_pipeline' => [
|
||||
'error-handler' => [
|
||||
'middleware' => [
|
||||
AccessLogMiddleware::class,
|
||||
ContentLengthMiddleware::class,
|
||||
RequestIdMiddleware::class,
|
||||
ErrorHandler::class,
|
||||
|
||||
@@ -38,6 +38,7 @@ return (static function (): array {
|
||||
Action\Visit\DomainVisitsAction::getRouteDef(),
|
||||
Action\Visit\GlobalVisitsAction::getRouteDef(),
|
||||
Action\Visit\OrphanVisitsAction::getRouteDef(),
|
||||
Action\Visit\DeleteOrphanVisitsAction::getRouteDef(),
|
||||
Action\Visit\NonOrphanVisitsAction::getRouteDef(),
|
||||
|
||||
// Short URLs
|
||||
@@ -53,6 +54,7 @@ return (static function (): array {
|
||||
]),
|
||||
Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]),
|
||||
Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]),
|
||||
Action\ShortUrl\DeleteShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
|
||||
Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]),
|
||||
Action\ShortUrl\ListShortUrlsAction::getRouteDef(),
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; // Deprecated.
|
||||
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
|
||||
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
|
||||
const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside a html title tag
|
||||
const LOOSE_URI_MATCHER = '/(.+)\:\/\/(.+)/i'; // Matches anything starting with a schema.
|
||||
const LOOSE_URI_MATCHER = '/(.+)\:(.+)/i'; // Matches anything starting with a schema.
|
||||
const DEFAULT_QR_CODE_SIZE = 300;
|
||||
const DEFAULT_QR_CODE_MARGIN = 0;
|
||||
const DEFAULT_QR_CODE_FORMAT = 'png';
|
||||
|
||||
@@ -12,6 +12,16 @@ chdir(dirname(__DIR__));
|
||||
|
||||
require 'vendor/autoload.php';
|
||||
|
||||
// Workaround to make this compatible with both openswoole 22 and earlier versions.
|
||||
if (! function_exists('swoole_set_process_name')) {
|
||||
// phpcs:disable
|
||||
function swoole_set_process_name(string $name): void
|
||||
{
|
||||
OpenSwoole\Util::setProcessName($name);
|
||||
}
|
||||
// phpcs:enable
|
||||
}
|
||||
|
||||
// This is one of the first files loaded. Configure the timezone here
|
||||
date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv(date_default_timezone_get()));
|
||||
|
||||
@@ -21,7 +31,6 @@ if (! class_exists(LOCAL_LOCK_FACTORY)) {
|
||||
class_alias(Lock\LockFactory::class, LOCAL_LOCK_FACTORY);
|
||||
}
|
||||
|
||||
// Build container
|
||||
return (static function (): ServiceManager {
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$container = new ServiceManager($config['dependencies']);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: '2.7'
|
||||
version: '3.0'
|
||||
|
||||
rpc:
|
||||
listen: tcp://127.0.0.1:6001
|
||||
@@ -14,10 +14,12 @@ http:
|
||||
forbid: ['.php', '.htaccess']
|
||||
pool:
|
||||
num_workers: 1
|
||||
debug: true
|
||||
|
||||
jobs:
|
||||
pool:
|
||||
num_workers: 1
|
||||
debug: true
|
||||
timeout: 300
|
||||
consume: ['shlink']
|
||||
pipelines:
|
||||
@@ -31,19 +33,8 @@ logs:
|
||||
mode: development
|
||||
channels:
|
||||
http:
|
||||
level: debug
|
||||
mode: 'off' # Disable logging as Shlink handles it internally
|
||||
server:
|
||||
level: debug
|
||||
metrics:
|
||||
level: debug
|
||||
|
||||
reload:
|
||||
interval: 1s
|
||||
patterns: ['.php']
|
||||
services:
|
||||
http:
|
||||
dirs: ['../../bin', '../../config', '../../data/migrations', '../../module', '../../vendor']
|
||||
recursive: true
|
||||
jobs:
|
||||
dirs: ['../../bin', '../../config', '../../data/migrations', '../../module', '../../vendor']
|
||||
recursive: true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: '2.7'
|
||||
version: '3.0'
|
||||
|
||||
rpc:
|
||||
listen: tcp://127.0.0.1:6001
|
||||
@@ -31,6 +31,6 @@ logs:
|
||||
mode: production
|
||||
channels:
|
||||
http:
|
||||
level: info # Log all http requests, set to info to disable
|
||||
mode: 'off' # Disable logging as Shlink handles it internally
|
||||
server:
|
||||
level: debug # Everything written to worker stderr is logged
|
||||
|
||||
@@ -121,6 +121,7 @@ $buildTestLoggerConfig = static fn (string $filename) => [
|
||||
'level' => Level::Debug->value,
|
||||
'type' => LoggerType::STREAM->value,
|
||||
'destination' => sprintf('data/log/api-tests/%s', $filename),
|
||||
'add_new_line' => true,
|
||||
];
|
||||
|
||||
return [
|
||||
|
||||
@@ -71,6 +71,6 @@ CMD \
|
||||
# Install dependencies if the vendor dir does not exist
|
||||
if [[ ! -d "./vendor" ]]; then /usr/local/bin/composer install ; fi && \
|
||||
# Download roadrunner binary
|
||||
if [[ ! -f "./bin/rr" ]]; then ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr ; fi && \
|
||||
if [[ ! -f "./bin/rr" ]]; then ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr ; fi && \
|
||||
# This forces the app to be started every second until the exit code is 0
|
||||
until ./bin/rr serve -c config/roadrunner/.rr.dev.yml; do sleep 1 ; done
|
||||
|
||||
@@ -3,7 +3,7 @@ MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.21
|
||||
ENV INOTIFY_VERSION 3.0.0
|
||||
ENV OPENSWOOLE_VERSION 4.12.1
|
||||
ENV OPENSWOOLE_VERSION 22.0.0
|
||||
ENV PDO_SQLSRV_VERSION 5.10.1
|
||||
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
|
||||
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
|
||||
|
||||
@@ -6,14 +6,12 @@ namespace Shlinkio\Shlink;
|
||||
|
||||
use Shlinkio\Shlink\Common\Logger\LoggerType;
|
||||
|
||||
use function Shlinkio\Shlink\Config\runningInRoadRunner;
|
||||
|
||||
return [
|
||||
|
||||
'logger' => [
|
||||
'Shlink' => [
|
||||
'type' => LoggerType::STREAM->value,
|
||||
'destination' => runningInRoadRunner() ? 'php://stderr' : 'php://stdout',
|
||||
'destination' => 'php://stderr',
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -1,31 +1,20 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
# If SHELL_VERBOSITY was not explicitly provided, run commands in quite mode (-q)
|
||||
[ $SHELL_VERBOSITY ] && flags="" || flags="-q"
|
||||
|
||||
cd /etc/shlink
|
||||
|
||||
echo "Creating fresh database if needed..."
|
||||
php bin/cli db:create -n ${flags}
|
||||
flags="--clear-db-cache"
|
||||
|
||||
echo "Updating database..."
|
||||
php bin/cli db:migrate -n ${flags}
|
||||
|
||||
echo "Generating proxies..."
|
||||
php bin/doctrine orm:generate-proxies -n ${flags}
|
||||
|
||||
echo "Clearing entities cache..."
|
||||
php bin/doctrine orm:clear-cache:metadata -n ${flags}
|
||||
|
||||
# Try to download GeoLite2 db file only if the license key env var was defined and skipping was not explicitly set
|
||||
if [ ! -z "${GEOLITE_LICENSE_KEY}" ] && [ "${SKIP_INITIAL_GEOLITE_DOWNLOAD}" != "true" ]; then
|
||||
echo "Downloading GeoLite2 db file..."
|
||||
php bin/cli visit:download-db -n ${flags}
|
||||
# Skip downloading GeoLite2 db file if the license key env var was not defined or skipping was explicitly set
|
||||
if [ -z "${GEOLITE_LICENSE_KEY}" ] || [ "${SKIP_INITIAL_GEOLITE_DOWNLOAD}" == "true" ]; then
|
||||
flags="${flags} --skip-download-geolite"
|
||||
fi
|
||||
|
||||
# Periodically run visit:locate every hour, if ENABLE_PERIODIC_VISIT_LOCATE=true was provided
|
||||
if [ "${ENABLE_PERIODIC_VISIT_LOCATE}" = "true" ]; then
|
||||
php vendor/bin/shlink-installer init ${flags}
|
||||
|
||||
# Periodically run visit:locate every hour, if ENABLE_PERIODIC_VISIT_LOCATE=true was provided and running as root
|
||||
# ENABLE_PERIODIC_VISIT_LOCATE is deprecated. Remove cron support in Shlink 4.0.0
|
||||
if [ "${ENABLE_PERIODIC_VISIT_LOCATE}" = "true" ] && [ "${SHLINK_USER_ID}" = "root" ]; then
|
||||
echo "Configuring periodic visit location..."
|
||||
echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root
|
||||
/usr/sbin/crond &
|
||||
|
||||
9
docs/swagger/parameters/shortCode.json
Normal file
9
docs/swagger/parameters/shortCode.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "shortCode",
|
||||
"in": "path",
|
||||
"description": "The short code for the short URL.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,7 @@
|
||||
"$ref": "../parameters/version.json"
|
||||
},
|
||||
{
|
||||
"name": "shortCode",
|
||||
"in": "path",
|
||||
"description": "The short code to resolve.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "../parameters/shortCode.json"
|
||||
},
|
||||
{
|
||||
"$ref": "../parameters/domain.json"
|
||||
@@ -127,13 +121,7 @@
|
||||
"$ref": "../parameters/version.json"
|
||||
},
|
||||
{
|
||||
"name": "shortCode",
|
||||
"in": "path",
|
||||
"description": "The short code to edit.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "../parameters/shortCode.json"
|
||||
},
|
||||
{
|
||||
"$ref": "../parameters/domain.json"
|
||||
@@ -295,13 +283,7 @@
|
||||
"$ref": "../parameters/version.json"
|
||||
},
|
||||
{
|
||||
"name": "shortCode",
|
||||
"in": "path",
|
||||
"description": "The short code to edit.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "../parameters/shortCode.json"
|
||||
},
|
||||
{
|
||||
"$ref": "../parameters/domain.json"
|
||||
|
||||
@@ -11,13 +11,7 @@
|
||||
"$ref": "../parameters/version.json"
|
||||
},
|
||||
{
|
||||
"name": "shortCode",
|
||||
"in": "path",
|
||||
"description": "The short code for the short URL from which we want to get the visits.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "../parameters/shortCode.json"
|
||||
},
|
||||
{
|
||||
"$ref": "../parameters/domain.json"
|
||||
@@ -172,5 +166,79 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"delete": {
|
||||
"operationId": "deleteShortUrlVisits",
|
||||
"tags": [
|
||||
"Visits"
|
||||
],
|
||||
"summary": "Delete visits for short URL",
|
||||
"description": "Delete all existing visits on the short URL behind provided short code.",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
},
|
||||
{
|
||||
"$ref": "../parameters/shortCode.json"
|
||||
},
|
||||
{
|
||||
"$ref": "../parameters/domain.json"
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Deleted visits",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deletedVisits": {
|
||||
"description": "Amount of affected visits",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"deletedVisits": 536
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "The short code does not belong to any short URL.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
},
|
||||
"examples": {
|
||||
"Short URL not found with API v3 and newer": {
|
||||
"$ref": "../examples/short-url-not-found-v3.json"
|
||||
},
|
||||
"Short URL not found previous to API v3": {
|
||||
"$ref": "../examples/short-url-not-found-v2.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Unexpected error.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,5 +148,55 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"delete": {
|
||||
"operationId": "deleteOrphanVisits",
|
||||
"tags": [
|
||||
"Visits"
|
||||
],
|
||||
"summary": "Delete orphan visits",
|
||||
"description": "Delete all visits to invalid short URLs, the base URL or any other 404.",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Deleted visits",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deletedVisits": {
|
||||
"description": "Amount of affected visits",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"deletedVisits": 536
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Unexpected error.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,7 @@
|
||||
"description": "Represents a short URL. Tracks the visit and redirects tio the corresponding long URL",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "shortCode",
|
||||
"in": "path",
|
||||
"description": "The short code to resolve.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "../parameters/shortCode.json"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
||||
@@ -8,13 +8,7 @@
|
||||
"description": "Generates a QR code image pointing to a short URL.<br />Since this is not an API endpoint but an image one, when an invalid value is provided for any of the query params, they will fall to their default values instead of throwing an error.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "shortCode",
|
||||
"in": "path",
|
||||
"description": "The short code to resolve.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "../parameters/shortCode.json"
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
|
||||
@@ -8,13 +8,7 @@
|
||||
"description": "Generates a 1px transparent image which can be used to track emails with a short URL",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "shortCode",
|
||||
"in": "path",
|
||||
"description": "The short code to resolve.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "../parameters/shortCode.json"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
||||
@@ -13,10 +13,12 @@ return [
|
||||
Command\ShortUrl\ListShortUrlsCommand::NAME => Command\ShortUrl\ListShortUrlsCommand::class,
|
||||
Command\ShortUrl\GetShortUrlVisitsCommand::NAME => Command\ShortUrl\GetShortUrlVisitsCommand::class,
|
||||
Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class,
|
||||
Command\ShortUrl\DeleteShortUrlVisitsCommand::NAME => Command\ShortUrl\DeleteShortUrlVisitsCommand::class,
|
||||
|
||||
Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class,
|
||||
Command\Visit\DownloadGeoLiteDbCommand::NAME => Command\Visit\DownloadGeoLiteDbCommand::class,
|
||||
Command\Visit\GetOrphanVisitsCommand::NAME => Command\Visit\GetOrphanVisitsCommand::class,
|
||||
Command\Visit\DeleteOrphanVisitsCommand::NAME => Command\Visit\DeleteOrphanVisitsCommand::class,
|
||||
Command\Visit\GetNonOrphanVisitsCommand::NAME => Command\Visit\GetNonOrphanVisitsCommand::class,
|
||||
|
||||
Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class,
|
||||
|
||||
@@ -42,10 +42,12 @@ return [
|
||||
Command\ShortUrl\ListShortUrlsCommand::class => ConfigAbstractFactory::class,
|
||||
Command\ShortUrl\GetShortUrlVisitsCommand::class => ConfigAbstractFactory::class,
|
||||
Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class,
|
||||
Command\ShortUrl\DeleteShortUrlVisitsCommand::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\Visit\DownloadGeoLiteDbCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Visit\GetOrphanVisitsCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Visit\DeleteOrphanVisitsCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Visit\GetNonOrphanVisitsCommand::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
|
||||
@@ -88,6 +90,7 @@ return [
|
||||
],
|
||||
Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class],
|
||||
Command\ShortUrl\DeleteShortUrlCommand::class => [ShortUrl\DeleteShortUrlService::class],
|
||||
Command\ShortUrl\DeleteShortUrlVisitsCommand::class => [ShortUrl\ShortUrlVisitsDeleter::class],
|
||||
|
||||
Command\Visit\DownloadGeoLiteDbCommand::class => [GeoLite\GeolocationDbUpdater::class],
|
||||
Command\Visit\LocateVisitsCommand::class => [
|
||||
@@ -96,6 +99,7 @@ return [
|
||||
LockFactory::class,
|
||||
],
|
||||
Command\Visit\GetOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class],
|
||||
Command\Visit\DeleteOrphanVisitsCommand::class => [Visit\VisitsDeleter::class],
|
||||
Command\Visit\GetNonOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
|
||||
|
||||
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Api;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
@@ -39,10 +39,10 @@ class DisableKeyCommand extends Command
|
||||
try {
|
||||
$this->apiKeyService->disable($apiKey);
|
||||
$io->success(sprintf('API key "%s" properly disabled', $apiKey));
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$io->error($e->getMessage());
|
||||
return ExitCodes::EXIT_FAILURE;
|
||||
return ExitCode::EXIT_FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ 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\ExitCode;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
@@ -109,6 +109,6 @@ class GenerateKeyCommand extends Command
|
||||
);
|
||||
}
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Api;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
@@ -77,7 +77,7 @@ class ListKeysCommand extends Command
|
||||
'Roles',
|
||||
]), $rows);
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
private function determineMessagePattern(ApiKey $apiKey): string
|
||||
|
||||
@@ -8,7 +8,7 @@ use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Platforms\SqlitePlatform;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -57,7 +57,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
|
||||
|
||||
if ($this->schemaExists()) {
|
||||
$io->success('Database already exists. Run "db:migrate" command to make sure it is up to date.');
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
// Create database
|
||||
@@ -65,7 +65,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
|
||||
$this->runPhpCommand($output, [self::DOCTRINE_SCRIPT, self::DOCTRINE_CREATE_SCHEMA_COMMAND]);
|
||||
$io->success('Database properly created!');
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
private function checkDbExists(): void
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Db;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
@@ -31,6 +31,6 @@ class MigrateDatabaseCommand extends AbstractDatabaseCommand
|
||||
$this->runPhpCommand($output, [self::DOCTRINE_MIGRATIONS_SCRIPT, self::DOCTRINE_MIGRATE_COMMAND]);
|
||||
$io->success('Database properly migrated!');
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Domain;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
@@ -109,6 +109,6 @@ class DomainRedirectsCommand extends Command
|
||||
|
||||
$io->success(sprintf('"Not found" redirects properly set for "%s"', $domainAuthority));
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Domain;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
@@ -59,7 +59,7 @@ class ListDomainsCommand extends Command
|
||||
}),
|
||||
);
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
private function notFoundRedirectsToString(NotFoundRedirectConfigInterface $config): string
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
@@ -31,7 +31,6 @@ class CreateShortUrlCommand extends Command
|
||||
public const NAME = 'short-url:create';
|
||||
|
||||
private ?SymfonyStyle $io;
|
||||
private string $defaultDomain;
|
||||
|
||||
public function __construct(
|
||||
private readonly UrlShortenerInterface $urlShortener,
|
||||
@@ -39,7 +38,6 @@ class CreateShortUrlCommand extends Command
|
||||
private readonly UrlShortenerOptions $options,
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->defaultDomain = $this->options->domain['hostname'] ?? '';
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
@@ -121,7 +119,6 @@ class CreateShortUrlCommand extends Command
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$this->verifyLongUrlArgument($input, $output);
|
||||
$this->verifyDomainArgument($input);
|
||||
}
|
||||
|
||||
private function verifyLongUrlArgument(InputInterface $input, OutputInterface $output): void
|
||||
@@ -138,19 +135,13 @@ class CreateShortUrlCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
private function verifyDomainArgument(InputInterface $input): void
|
||||
{
|
||||
$domain = $input->getOption('domain');
|
||||
$input->setOption('domain', $domain === $this->defaultDomain ? null : $domain);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$io = $this->getIO($input, $output);
|
||||
$longUrl = $input->getArgument('longUrl');
|
||||
if (empty($longUrl)) {
|
||||
$io->error('A URL was not provided!');
|
||||
return ExitCodes::EXIT_FAILURE;
|
||||
return ExitCode::EXIT_FAILURE;
|
||||
}
|
||||
|
||||
$explodeWithComma = curry(explode(...))(',');
|
||||
@@ -161,7 +152,7 @@ class CreateShortUrlCommand extends Command
|
||||
$doValidateUrl = $input->getOption('validate-url');
|
||||
|
||||
try {
|
||||
$shortUrl = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([
|
||||
$result = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([
|
||||
ShortUrlInputFilter::LONG_URL => $longUrl,
|
||||
ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'),
|
||||
ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'),
|
||||
@@ -176,14 +167,19 @@ class CreateShortUrlCommand extends Command
|
||||
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
|
||||
], $this->options));
|
||||
|
||||
$result->onEventDispatchingError(static fn () => $io->isVerbose() && $io->warning(
|
||||
'Short URL properly created, but the real-time updates cannot be notified when generating the '
|
||||
. 'short URL from the command line. Migrate to roadrunner in order to bypass this limitation.',
|
||||
));
|
||||
|
||||
$io->writeln([
|
||||
sprintf('Processed long URL: <info>%s</info>', $longUrl),
|
||||
sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($shortUrl)),
|
||||
sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($result->shortUrl)),
|
||||
]);
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
} catch (InvalidUrlException | NonUniqueSlugException $e) {
|
||||
$io->error($e->getMessage());
|
||||
return ExitCodes::EXIT_FAILURE;
|
||||
return ExitCode::EXIT_FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
@@ -55,10 +55,10 @@ class DeleteShortUrlCommand extends Command
|
||||
|
||||
try {
|
||||
$this->runDelete($io, $identifier, $ignoreThreshold);
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
} catch (Exception\ShortUrlNotFoundException $e) {
|
||||
$io->error($e->getMessage());
|
||||
return ExitCodes::EXIT_FAILURE;
|
||||
return ExitCode::EXIT_FAILURE;
|
||||
} catch (Exception\DeleteShortUrlException $e) {
|
||||
return $this->retry($io, $identifier, $e->getMessage());
|
||||
}
|
||||
@@ -75,7 +75,7 @@ class DeleteShortUrlCommand extends Command
|
||||
$io->warning('Short URL was not deleted.');
|
||||
}
|
||||
|
||||
return $forceDelete ? ExitCodes::EXIT_SUCCESS : ExitCodes::EXIT_WARNING;
|
||||
return $forceDelete ? ExitCode::EXIT_SUCCESS : ExitCode::EXIT_WARNING;
|
||||
}
|
||||
|
||||
private function runDelete(SymfonyStyle $io, ShortUrlIdentifier $identifier, bool $ignoreThreshold): void
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\AbstractDeleteVisitsCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleterInterface;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand
|
||||
{
|
||||
public const NAME = 'short-url:visits-delete';
|
||||
|
||||
public function __construct(private readonly ShortUrlVisitsDeleterInterface $deleter)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Deletes visits from a short URL')
|
||||
->addArgument(
|
||||
'shortCode',
|
||||
InputArgument::REQUIRED,
|
||||
'The short code for the short URL which visits will be deleted',
|
||||
)
|
||||
->addOption(
|
||||
'domain',
|
||||
'd',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The domain if the short code does not belong to the default one',
|
||||
);
|
||||
}
|
||||
|
||||
protected function doExecute(InputInterface $input, SymfonyStyle $io): ?int
|
||||
{
|
||||
$identifier = ShortUrlIdentifier::fromCli($input);
|
||||
try {
|
||||
$result = $this->deleter->deleteShortUrlVisits($identifier);
|
||||
$io->success(sprintf('Successfully deleted %s visits', $result->affectedItems));
|
||||
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
} catch (ShortUrlNotFoundException) {
|
||||
$io->warning(sprintf('Short URL not found for "%s"', $identifier->__toString()));
|
||||
return ExitCode::EXIT_WARNING;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getWarningMessage(): string
|
||||
{
|
||||
return 'You are about to delete all visits for a short URL. This operation cannot be undone.';
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Option\EndDateOption;
|
||||
use Shlinkio\Shlink\CLI\Option\StartDateOption;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Input\EndDateOption;
|
||||
use Shlinkio\Shlink\CLI\Input\StartDateOption;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
|
||||
@@ -102,6 +102,12 @@ class ListShortUrlsCommand extends Command
|
||||
InputOption::VALUE_NONE,
|
||||
'Whether to display the tags or not.',
|
||||
)
|
||||
->addOption(
|
||||
'show-domain',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Whether to display the domain or not. Those belonging to default domain will have value "DEFAULT".',
|
||||
)
|
||||
->addOption(
|
||||
'show-api-key',
|
||||
'k',
|
||||
@@ -167,7 +173,7 @@ class ListShortUrlsCommand extends Command
|
||||
$io->newLine();
|
||||
$io->success('Short URLs properly listed');
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
private function renderPage(
|
||||
@@ -217,6 +223,10 @@ class ListShortUrlsCommand extends Command
|
||||
if ($input->getOption('show-tags')) {
|
||||
$columnsMap['Tags'] = static fn (array $shortUrl): string => implode(', ', $shortUrl['tags']);
|
||||
}
|
||||
if ($input->getOption('show-domain')) {
|
||||
$columnsMap['Domain'] = static fn (array $_, ShortUrl $shortUrl): string =>
|
||||
$shortUrl->getDomain()?->authority ?? 'DEFAULT';
|
||||
}
|
||||
if ($input->getOption('show-api-key')) {
|
||||
$columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string =>
|
||||
$shortUrl->authorApiKey()?->__toString() ?? '';
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
|
||||
@@ -56,10 +56,10 @@ class ResolveUrlCommand extends Command
|
||||
try {
|
||||
$url = $this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromCli($input));
|
||||
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
} catch (ShortUrlNotFoundException $e) {
|
||||
$io->error($e->getMessage());
|
||||
return ExitCodes::EXIT_FAILURE;
|
||||
return ExitCode::EXIT_FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Tag;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -41,11 +41,11 @@ class DeleteTagsCommand extends Command
|
||||
|
||||
if (empty($tagNames)) {
|
||||
$io->warning('You have to provide at least one tag name');
|
||||
return ExitCodes::EXIT_WARNING;
|
||||
return ExitCode::EXIT_WARNING;
|
||||
}
|
||||
|
||||
$this->tagService->deleteTags($tagNames);
|
||||
$io->success('Tags properly deleted');
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Tag;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
|
||||
@@ -34,7 +34,7 @@ class ListTagsCommand extends Command
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
ShlinkTable::default($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
private function getTagsRows(): array
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Tag;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
|
||||
@@ -42,10 +42,10 @@ class RenameTagCommand extends Command
|
||||
try {
|
||||
$this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName));
|
||||
$io->success('Tag properly renamed.');
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
} catch (TagNotFoundException | TagConflictException $e) {
|
||||
$io->error($e->getMessage());
|
||||
return ExitCodes::EXIT_FAILURE;
|
||||
return ExitCode::EXIT_FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Util;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -28,7 +28,7 @@ abstract class AbstractLockedCommand extends Command
|
||||
$output->writeln(
|
||||
sprintf('<comment>Command "%s" is already in progress. Skipping.</comment>', $lockConfig->lockName),
|
||||
);
|
||||
return ExitCodes::EXIT_WARNING;
|
||||
return ExitCode::EXIT_WARNING;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
35
module/CLI/src/Command/Visit/AbstractDeleteVisitsCommand.php
Normal file
35
module/CLI/src/Command/Visit/AbstractDeleteVisitsCommand.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
abstract class AbstractDeleteVisitsCommand extends Command
|
||||
{
|
||||
final protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
if (! $this->confirm($io)) {
|
||||
$io->info('Operation aborted');
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
return $this->doExecute($input, $io);
|
||||
}
|
||||
|
||||
private function confirm(SymfonyStyle $io): bool
|
||||
{
|
||||
$io->warning($this->getWarningMessage());
|
||||
return $io->confirm('<comment>Continue deleting visits?</comment>', false);
|
||||
}
|
||||
|
||||
abstract protected function doExecute(InputInterface $input, SymfonyStyle $io): ?int;
|
||||
|
||||
abstract protected function getWarningMessage(): string;
|
||||
}
|
||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Option\EndDateOption;
|
||||
use Shlinkio\Shlink\CLI\Option\StartDateOption;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Input\EndDateOption;
|
||||
use Shlinkio\Shlink\CLI\Input\StartDateOption;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
@@ -43,7 +43,7 @@ abstract class AbstractVisitsListCommand extends Command
|
||||
|
||||
ShlinkTable::default($output)->render($headers, $rows);
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
private function resolveRowsAndHeaders(Paginator $paginator): array
|
||||
|
||||
42
module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php
Normal file
42
module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsDeleterInterface;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class DeleteOrphanVisitsCommand extends AbstractDeleteVisitsCommand
|
||||
{
|
||||
public const NAME = 'visit:orphan-delete';
|
||||
|
||||
public function __construct(private readonly VisitsDeleterInterface $deleter)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Deletes all orphan visits');
|
||||
}
|
||||
|
||||
protected function doExecute(InputInterface $input, SymfonyStyle $io): ?int
|
||||
{
|
||||
$result = $this->deleter->deleteOrphanVisits();
|
||||
$io->success(sprintf('Successfully deleted %s visits', $result->affectedItems));
|
||||
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
protected function getWarningMessage(): string
|
||||
{
|
||||
return 'You are about to delete all orphan visits. This operation cannot be undone.';
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -56,7 +56,7 @@ class DownloadGeoLiteDbCommand extends Command
|
||||
$io->success('GeoLite2 db file properly downloaded.');
|
||||
}
|
||||
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
} catch (GeolocationDbUpdateFailedException $e) {
|
||||
$olderDbExists = $e->olderDbExists();
|
||||
|
||||
@@ -72,7 +72,7 @@ class DownloadGeoLiteDbCommand extends Command
|
||||
$this->getApplication()?->renderThrowable($e, $io);
|
||||
}
|
||||
|
||||
return $olderDbExists ? ExitCodes::EXIT_WARNING : ExitCodes::EXIT_FAILURE;
|
||||
return $olderDbExists ? ExitCode::EXIT_WARNING : ExitCode::EXIT_FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
|
||||
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Common\Util\IpAddress;
|
||||
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
@@ -116,14 +116,14 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||
}
|
||||
|
||||
$this->io->success('Finished locating visits');
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$this->io->error($e->getMessage());
|
||||
if ($this->io->isVerbose()) {
|
||||
$this->getApplication()?->renderThrowable($e, $this->io);
|
||||
}
|
||||
|
||||
return ExitCodes::EXIT_FAILURE;
|
||||
return ExitCode::EXIT_FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
|
||||
$downloadDbCommand = $cliApp->find(DownloadGeoLiteDbCommand::NAME);
|
||||
$exitCode = $downloadDbCommand->run(new ArrayInput([]), $this->io);
|
||||
|
||||
if ($exitCode === ExitCodes::EXIT_FAILURE) {
|
||||
if ($exitCode === ExitCode::EXIT_FAILURE) {
|
||||
throw new RuntimeException('It is not possible to locate visits without a GeoLite2 db file.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Option;
|
||||
namespace Shlinkio\Shlink\CLI\Input;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Option;
|
||||
namespace Shlinkio\Shlink\CLI\Input;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Option;
|
||||
namespace Shlinkio\Shlink\CLI\Input;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Util;
|
||||
|
||||
final class ExitCodes
|
||||
final class ExitCode
|
||||
{
|
||||
public const EXIT_SUCCESS = 0;
|
||||
public const EXIT_FAILURE = -1;
|
||||
31
module/CLI/test-cli/Command/CreateShortUrlTest.php
Normal file
31
module/CLI/test-cli/Command/CreateShortUrlTest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioCliTest\Shlink\CLI\Command;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
|
||||
|
||||
class CreateShortUrlTest extends CliTestCase
|
||||
{
|
||||
#[Test]
|
||||
public function defaultDomainIsIgnoredWhenExplicitlyProvided(): void
|
||||
{
|
||||
$slug = 'testing-default-domain';
|
||||
$defaultDomain = 's.test';
|
||||
|
||||
[$output, $exitCode] = $this->exec(
|
||||
[CreateShortUrlCommand::NAME, 'https://example.com', '--domain', $defaultDomain, '--custom-slug', $slug],
|
||||
);
|
||||
|
||||
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
|
||||
self::assertStringContainsString('Generated short URL: http://' . $defaultDomain . '/' . $slug, $output);
|
||||
|
||||
[$listOutput] = $this->exec([ListShortUrlsCommand::NAME, '--show-domain', '--search-term', $slug]);
|
||||
self::assertStringContainsString('DEFAULT', $listOutput);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace ShlinkioCliTest\Shlink\CLI\Command;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
|
||||
|
||||
class GenerateApiKeyTest extends CliTestCase
|
||||
@@ -17,6 +17,6 @@ class GenerateApiKeyTest extends CliTestCase
|
||||
[$output, $exitCode] = $this->exec([GenerateKeyCommand::NAME]);
|
||||
|
||||
self::assertStringContainsString('[OK] Generated API key', $output);
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $exitCode);
|
||||
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
79
module/CLI/test-cli/Command/ImportShortUrlsTest.php
Normal file
79
module/CLI/test-cli/Command/ImportShortUrlsTest.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioCliTest\Shlink\CLI\Command;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
|
||||
use Shlinkio\Shlink\Importer\Command\ImportCommand;
|
||||
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
|
||||
|
||||
use function fclose;
|
||||
use function fopen;
|
||||
use function fwrite;
|
||||
use function is_string;
|
||||
use function sys_get_temp_dir;
|
||||
use function tempnam;
|
||||
use function unlink;
|
||||
|
||||
class ImportShortUrlsTest extends CliTestCase
|
||||
{
|
||||
/**
|
||||
* @var false|string|null
|
||||
* @todo Use native type once PHP 8.1 support is dropped
|
||||
*/
|
||||
private mixed $tempCsvFile = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tempCsvFile = tempnam(sys_get_temp_dir(), 'shlink_csv');
|
||||
if (! $this->tempCsvFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
$handle = fopen($this->tempCsvFile, 'w+');
|
||||
if (! $handle) {
|
||||
$this->fail('It was not possible to open the temporary file to write CSV on it');
|
||||
}
|
||||
|
||||
fwrite(
|
||||
$handle,
|
||||
<<<CSV
|
||||
longURL;tags;domain;short code;Title
|
||||
https://shlink.io;foo,baz;s.test;testing-default-domain-import-1;
|
||||
https://example.com;foo;s.test;testing-default-domain-import-2;
|
||||
CSV,
|
||||
);
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (is_string($this->tempCsvFile)) {
|
||||
unlink($this->tempCsvFile);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function defaultDomainIsIgnoredWhenExplicitlyProvided(): void
|
||||
{
|
||||
if (! $this->tempCsvFile) {
|
||||
$this->fail('It was not possible to create a temporary CSV file');
|
||||
}
|
||||
|
||||
[$output] = $this->exec([ImportCommand::NAME, 'csv'], [$this->tempCsvFile, ';']);
|
||||
|
||||
self::assertStringContainsString('https://shlink.io: Imported', $output);
|
||||
self::assertStringContainsString('https://example.com: Imported', $output);
|
||||
|
||||
[$listOutput1] = $this->exec(
|
||||
[ListShortUrlsCommand::NAME, '--show-domain', '--search-term', 'testing-default-domain-import-1'],
|
||||
);
|
||||
self::assertStringContainsString('DEFAULT', $listOutput1);
|
||||
[$listOutput1] = $this->exec(
|
||||
[ListShortUrlsCommand::NAME, '--show-domain', '--search-term', 'testing-default-domain-import-2'],
|
||||
);
|
||||
self::assertStringContainsString('DEFAULT', $listOutput1);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use Cake\Chronos\Chronos;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
|
||||
|
||||
class ListApiKeysTest extends CliTestCase
|
||||
@@ -19,7 +19,7 @@ class ListApiKeysTest extends CliTestCase
|
||||
[$output, $exitCode] = $this->exec([ListKeysCommand::NAME, ...$flags]);
|
||||
|
||||
self::assertEquals($expectedOutput, $output);
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $exitCode);
|
||||
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
|
||||
}
|
||||
|
||||
public static function provideFlags(): iterable
|
||||
|
||||
@@ -9,7 +9,7 @@ use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
@@ -53,7 +53,7 @@ class ListDomainsCommandTest extends TestCase
|
||||
$this->commandTester->execute($input);
|
||||
|
||||
self::assertEquals($expectedOutput, $this->commandTester->getDisplay());
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
self::assertEquals(ExitCode::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
}
|
||||
|
||||
public static function provideInputsAndOutputs(): iterable
|
||||
|
||||
@@ -4,29 +4,30 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Laminas\ServiceManager\Exception\ServiceNotFoundException;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\UrlShorteningResult;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class CreateShortUrlCommandTest extends TestCase
|
||||
{
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private const DEFAULT_DOMAIN = 'default.com';
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private MockObject & UrlShortenerInterface $urlShortener;
|
||||
private MockObject & ShortUrlStringifierInterface $stringifier;
|
||||
@@ -40,7 +41,7 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
$this->urlShortener,
|
||||
$this->stringifier,
|
||||
new UrlShortenerOptions(
|
||||
domain: ['hostname' => self::DEFAULT_DOMAIN, 'schema' => ''],
|
||||
domain: ['hostname' => 'example.com', 'schema' => ''],
|
||||
defaultShortCodesLength: 5,
|
||||
),
|
||||
);
|
||||
@@ -51,7 +52,9 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn($shortUrl);
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn(
|
||||
UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl),
|
||||
);
|
||||
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
|
||||
'stringified_short_url',
|
||||
);
|
||||
@@ -59,11 +62,12 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
$this->commandTester->execute([
|
||||
'longUrl' => 'http://domain.com/foo/bar',
|
||||
'--max-visits' => '3',
|
||||
]);
|
||||
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
self::assertEquals(ExitCode::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
self::assertStringContainsString('stringified_short_url', $output);
|
||||
self::assertStringNotContainsString('but the real-time updates cannot', $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -78,7 +82,7 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
$this->commandTester->execute(['longUrl' => $url]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
|
||||
self::assertEquals(ExitCode::EXIT_FAILURE, $this->commandTester->getStatusCode());
|
||||
self::assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output);
|
||||
}
|
||||
|
||||
@@ -93,7 +97,7 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
|
||||
self::assertEquals(ExitCode::EXIT_FAILURE, $this->commandTester->getStatusCode());
|
||||
self::assertStringContainsString('Provided slug "my-slug" is already in use', $output);
|
||||
}
|
||||
|
||||
@@ -106,7 +110,7 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $creation->tags);
|
||||
return true;
|
||||
}),
|
||||
)->willReturn($shortUrl);
|
||||
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl));
|
||||
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
|
||||
'stringified_short_url',
|
||||
);
|
||||
@@ -117,7 +121,7 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
self::assertEquals(ExitCode::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
self::assertStringContainsString('stringified_short_url', $output);
|
||||
}
|
||||
|
||||
@@ -129,21 +133,20 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
Assert::assertEquals($expectedDomain, $meta->domain);
|
||||
return true;
|
||||
}),
|
||||
)->willReturn(ShortUrl::createFake());
|
||||
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching(ShortUrl::createFake()));
|
||||
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
|
||||
|
||||
$input['longUrl'] = 'http://domain.com/foo/bar';
|
||||
$this->commandTester->execute($input);
|
||||
|
||||
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
self::assertEquals(ExitCode::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
}
|
||||
|
||||
public static function provideDomains(): iterable
|
||||
{
|
||||
yield 'no domain' => [[], null];
|
||||
yield 'non-default domain foo' => [['--domain' => 'foo.com'], 'foo.com'];
|
||||
yield 'non-default domain bar' => [['-d' => 'bar.com'], 'bar.com'];
|
||||
yield 'default domain' => [['--domain' => self::DEFAULT_DOMAIN], null];
|
||||
yield 'domain foo' => [['--domain' => 'foo.com'], 'foo.com'];
|
||||
yield 'domain bar' => [['-d' => 'bar.com'], 'bar.com'];
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideFlags')]
|
||||
@@ -155,7 +158,7 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl());
|
||||
return true;
|
||||
}),
|
||||
)->willReturn($shortUrl);
|
||||
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl));
|
||||
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
|
||||
|
||||
$options['longUrl'] = 'http://domain.com/foo/bar';
|
||||
@@ -167,4 +170,40 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
yield 'no flags' => [[], null];
|
||||
yield 'validate-url' => [['--validate-url' => true], true];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(string $output): void $assert
|
||||
*/
|
||||
#[Test, DataProvider('provideDispatchBehavior')]
|
||||
public function warningIsPrintedInVerboseModeWhenDispatchErrors(int $verbosity, callable $assert): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn(
|
||||
UrlShorteningResult::withErrorOnEventDispatching($shortUrl, new ServiceNotFoundException()),
|
||||
);
|
||||
$this->stringifier->method('stringify')->willReturn('stringified_short_url');
|
||||
|
||||
$this->commandTester->execute(['longUrl' => 'http://domain.com/foo/bar'], ['verbosity' => $verbosity]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$assert($output);
|
||||
}
|
||||
|
||||
public static function provideDispatchBehavior(): iterable
|
||||
{
|
||||
$containsAssertion = static fn (string $output) => self::assertStringContainsString(
|
||||
'but the real-time updates cannot',
|
||||
$output,
|
||||
);
|
||||
$doesNotContainAssertion = static fn (string $output) => self::assertStringNotContainsString(
|
||||
'but the real-time updates cannot',
|
||||
$output,
|
||||
);
|
||||
|
||||
yield 'quiet' => [OutputInterface::VERBOSITY_QUIET, $doesNotContainAssertion];
|
||||
yield 'normal' => [OutputInterface::VERBOSITY_NORMAL, $doesNotContainAssertion];
|
||||
yield 'verbose' => [OutputInterface::VERBOSITY_VERBOSE, $containsAssertion];
|
||||
yield 'very verbose' => [OutputInterface::VERBOSITY_VERY_VERBOSE, $containsAssertion];
|
||||
yield 'debug' => [OutputInterface::VERBOSITY_DEBUG, $containsAssertion];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlVisitsCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\BulkDeleteResult;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleterInterface;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class DeleteShortUrlVisitsCommandTest extends TestCase
|
||||
{
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private MockObject & ShortUrlVisitsDeleterInterface $deleter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->deleter = $this->createMock(ShortUrlVisitsDeleterInterface::class);
|
||||
$this->commandTester = $this->testerForCommand(new DeleteShortUrlVisitsCommand($this->deleter));
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideCancellingInputs')]
|
||||
public function executionIsAbortedIfManuallyCancelled(array $input): void
|
||||
{
|
||||
$this->deleter->expects($this->never())->method('deleteShortUrlVisits');
|
||||
$this->commandTester->setInputs($input);
|
||||
|
||||
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
|
||||
self::assertStringContainsString('Operation aborted', $output);
|
||||
}
|
||||
|
||||
public static function provideCancellingInputs(): iterable
|
||||
{
|
||||
yield 'default input' => [[]];
|
||||
yield 'no' => [['no']];
|
||||
yield 'n' => [['n']];
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideErrorArgs')]
|
||||
public function warningIsPrintedInCaseOfNotFoundShortUrl(array $args, string $expectedError): void
|
||||
{
|
||||
$this->deleter->expects($this->once())->method('deleteShortUrlVisits')->willThrowException(
|
||||
new ShortUrlNotFoundException(),
|
||||
);
|
||||
$this->commandTester->setInputs(['yes']);
|
||||
|
||||
$exitCode = $this->commandTester->execute($args);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(ExitCode::EXIT_WARNING, $exitCode);
|
||||
self::assertStringContainsString($expectedError, $output);
|
||||
}
|
||||
|
||||
public static function provideErrorArgs(): iterable
|
||||
{
|
||||
yield 'domain' => [['shortCode' => 'foo'], 'Short URL not found for "foo"'];
|
||||
yield 'no domain' => [['shortCode' => 'foo', '--domain' => 's.test'], 'Short URL not found for "s.test/foo"'];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function successMessageIsPrintedForValidShortUrls(): void
|
||||
{
|
||||
$this->deleter->expects($this->once())->method('deleteShortUrlVisits')->willReturn(new BulkDeleteResult(5));
|
||||
$this->commandTester->setInputs(['yes']);
|
||||
|
||||
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
|
||||
self::assertStringContainsString('Successfully deleted 5 visits', $output);
|
||||
}
|
||||
}
|
||||
@@ -144,13 +144,19 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
yield 'tags only' => [
|
||||
['--show-tags' => true],
|
||||
['| Tags ', '| foo, bar, baz'],
|
||||
['| API Key ', '| API Key Name |', $key, '| my api key'],
|
||||
['| API Key ', '| API Key Name |', $key, '| my api key', '| Domain', '| DEFAULT'],
|
||||
$apiKey,
|
||||
];
|
||||
yield 'domain only' => [
|
||||
['--show-domain' => true],
|
||||
['| Domain', '| DEFAULT'],
|
||||
['| Tags ', '| foo, bar, baz', '| API Key ', '| API Key Name |', $key, '| my api key'],
|
||||
$apiKey,
|
||||
];
|
||||
yield 'api key only' => [
|
||||
['--show-api-key' => true],
|
||||
['| API Key ', $key],
|
||||
['| Tags ', '| foo, bar, baz', '| API Key Name |', '| my api key'],
|
||||
['| Tags ', '| foo, bar, baz', '| API Key Name |', '| my api key', '| Domain', '| DEFAULT'],
|
||||
$apiKey,
|
||||
];
|
||||
yield 'api key name only' => [
|
||||
@@ -165,9 +171,24 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
['| API Key Name |', '| my api key'],
|
||||
$apiKey,
|
||||
];
|
||||
yield 'tags and domain' => [
|
||||
['--show-tags' => true, '--show-domain' => true],
|
||||
['| Tags ', '| foo, bar, baz', '| Domain', '| DEFAULT'],
|
||||
['| API Key Name |', '| my api key'],
|
||||
$apiKey,
|
||||
];
|
||||
yield 'all' => [
|
||||
['--show-tags' => true, '--show-api-key' => true, '--show-api-key-name' => true],
|
||||
['| API Key ', '| Tags ', '| API Key Name |', '| foo, bar, baz', $key, '| my api key'],
|
||||
['--show-tags' => true, '--show-domain' => true, '--show-api-key' => true, '--show-api-key-name' => true],
|
||||
[
|
||||
'| API Key ',
|
||||
'| Tags ',
|
||||
'| API Key Name |',
|
||||
'| foo, bar, baz',
|
||||
$key,
|
||||
'| my api key',
|
||||
'| Domain',
|
||||
'| DEFAULT',
|
||||
],
|
||||
[],
|
||||
$apiKey,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Visit;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\DeleteOrphanVisitsCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Model\BulkDeleteResult;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsDeleterInterface;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class DeleteOrphanVisitsCommandTest extends TestCase
|
||||
{
|
||||
use CliTestUtilsTrait;
|
||||
|
||||
private CommandTester $commandTester;
|
||||
private MockObject & VisitsDeleterInterface $deleter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->deleter = $this->createMock(VisitsDeleterInterface::class);
|
||||
$this->commandTester = $this->testerForCommand(new DeleteOrphanVisitsCommand($this->deleter));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function successMessageIsPrintedAfterDeletion(): void
|
||||
{
|
||||
$this->deleter->expects($this->once())->method('deleteOrphanVisits')->willReturn(new BulkDeleteResult(5));
|
||||
$this->commandTester->setInputs(['yes']);
|
||||
|
||||
$exitCode = $this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
|
||||
self::assertStringContainsString('You are about to delete all orphan visits.', $output);
|
||||
self::assertStringContainsString('Successfully deleted 5 visits', $output);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
@@ -65,12 +65,12 @@ class DownloadGeoLiteDbCommandTest extends TestCase
|
||||
yield 'existing db' => [
|
||||
true,
|
||||
'[WARNING] GeoLite2 db file update failed. Visits will continue to be located',
|
||||
ExitCodes::EXIT_WARNING,
|
||||
ExitCode::EXIT_WARNING,
|
||||
];
|
||||
yield 'not existing db' => [
|
||||
false,
|
||||
'[ERROR] GeoLite2 db file download failed. It will not be possible to locate',
|
||||
ExitCodes::EXIT_FAILURE,
|
||||
ExitCode::EXIT_FAILURE,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ class DownloadGeoLiteDbCommandTest extends TestCase
|
||||
$exitCode = $this->commandTester->getStatusCode();
|
||||
|
||||
self::assertStringContainsString($expectedMessage, $output);
|
||||
self::assertSame(ExitCodes::EXIT_SUCCESS, $exitCode);
|
||||
self::assertSame(ExitCode::EXIT_SUCCESS, $exitCode);
|
||||
}
|
||||
|
||||
public static function provideSuccessParams(): iterable
|
||||
|
||||
@@ -10,7 +10,7 @@ use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand;
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
@@ -85,7 +85,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
$this->visitToLocation->expects(
|
||||
$this->exactly($expectedUnlocatedCalls + $expectedEmptyCalls + $expectedAllCalls),
|
||||
)->method('resolveVisitLocation')->withAnyParameters()->willReturn(Location::emptyInstance());
|
||||
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS);
|
||||
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
|
||||
|
||||
$this->commandTester->setInputs(['y']);
|
||||
$this->commandTester->execute($args);
|
||||
@@ -118,7 +118,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
->withAnyParameters()
|
||||
->willReturnCallback($this->invokeHelperMethods($visit, $location));
|
||||
$this->visitToLocation->expects($this->once())->method('resolveVisitLocation')->willThrowException($e);
|
||||
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS);
|
||||
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
|
||||
|
||||
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
|
||||
|
||||
@@ -147,7 +147,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
$this->visitToLocation->expects($this->once())->method('resolveVisitLocation')->willThrowException(
|
||||
IpCannotBeLocatedException::forError(WrongIpException::fromIpAddress('1.2.3.4')),
|
||||
);
|
||||
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS);
|
||||
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
|
||||
|
||||
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
|
||||
|
||||
@@ -171,7 +171,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
|
||||
$this->visitService->expects($this->never())->method('locateUnlocatedVisits');
|
||||
$this->visitToLocation->expects($this->never())->method('resolveVisitLocation');
|
||||
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS);
|
||||
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
|
||||
|
||||
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
@@ -186,7 +186,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
public function showsProperMessageWhenGeoLiteUpdateFails(): void
|
||||
{
|
||||
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
|
||||
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_FAILURE);
|
||||
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_FAILURE);
|
||||
$this->visitService->expects($this->never())->method('locateUnlocatedVisits');
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
@@ -199,7 +199,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
public function providingAllFlagOnItsOwnDisplaysNotice(): void
|
||||
{
|
||||
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
|
||||
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS);
|
||||
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
|
||||
|
||||
$this->commandTester->execute(['--all' => true]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
@@ -210,7 +210,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
#[Test, DataProvider('provideAbortInputs')]
|
||||
public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void
|
||||
{
|
||||
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS);
|
||||
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Execution aborted');
|
||||
|
||||
@@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\ErrorHandler;
|
||||
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
||||
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
use Symfony\Component\Lock;
|
||||
|
||||
return [
|
||||
|
||||
@@ -38,6 +39,7 @@ return [
|
||||
ShortUrl\ShortUrlListService::class => ConfigAbstractFactory::class,
|
||||
ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
|
||||
ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
|
||||
ShortUrl\ShortUrlVisitsDeleter::class => ConfigAbstractFactory::class,
|
||||
ShortUrl\Helper\ShortCodeUniquenessHelper::class => ConfigAbstractFactory::class,
|
||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ConfigAbstractFactory::class,
|
||||
ShortUrl\Helper\ShortUrlStringifier::class => ConfigAbstractFactory::class,
|
||||
@@ -61,6 +63,7 @@ return [
|
||||
|
||||
Visit\VisitsTracker::class => ConfigAbstractFactory::class,
|
||||
Visit\RequestTracker::class => ConfigAbstractFactory::class,
|
||||
Visit\VisitsDeleter::class => ConfigAbstractFactory::class,
|
||||
Visit\Geolocation\VisitLocator::class => ConfigAbstractFactory::class,
|
||||
Visit\Geolocation\VisitToLocationHelper::class => ConfigAbstractFactory::class,
|
||||
Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
|
||||
@@ -69,6 +72,10 @@ return [
|
||||
EntityRepositoryFactory::class,
|
||||
Visit\Entity\Visit::class,
|
||||
],
|
||||
Visit\Repository\VisitDeleterRepository::class => [
|
||||
EntityRepositoryFactory::class,
|
||||
Visit\Entity\Visit::class,
|
||||
],
|
||||
|
||||
Util\UrlValidator::class => ConfigAbstractFactory::class,
|
||||
Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class,
|
||||
@@ -117,6 +124,7 @@ return [
|
||||
Options\TrackingOptions::class,
|
||||
],
|
||||
Visit\RequestTracker::class => [Visit\VisitsTracker::class, Options\TrackingOptions::class],
|
||||
Visit\VisitsDeleter::class => [Visit\Repository\VisitDeleterRepository::class],
|
||||
ShortUrl\ShortUrlService::class => [
|
||||
'em',
|
||||
ShortUrl\ShortUrlResolver::class,
|
||||
@@ -137,6 +145,10 @@ return [
|
||||
ShortUrl\ShortUrlResolver::class,
|
||||
],
|
||||
ShortUrl\ShortUrlResolver::class => ['em', Options\UrlShortenerOptions::class],
|
||||
ShortUrl\ShortUrlVisitsDeleter::class => [
|
||||
Visit\Repository\VisitDeleterRepository::class,
|
||||
ShortUrl\ShortUrlResolver::class,
|
||||
],
|
||||
ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Options\UrlShortenerOptions::class],
|
||||
Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'],
|
||||
|
||||
@@ -161,7 +173,11 @@ return [
|
||||
],
|
||||
Action\RobotsAction::class => [Crawling\CrawlingHelper::class],
|
||||
|
||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em'],
|
||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => [
|
||||
'em',
|
||||
Options\UrlShortenerOptions::class,
|
||||
Lock\LockFactory::class,
|
||||
],
|
||||
ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'],
|
||||
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class],
|
||||
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [Options\TrackingOptions::class],
|
||||
|
||||
17
module/Core/src/Model/BulkDeleteResult.php
Normal file
17
module/Core/src/Model/BulkDeleteResult.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
final class BulkDeleteResult
|
||||
{
|
||||
public function __construct(public readonly int $affectedItems)
|
||||
{
|
||||
}
|
||||
|
||||
public function toArray(string $fieldName): array
|
||||
{
|
||||
return [$fieldName => $this->affectedItems];
|
||||
}
|
||||
}
|
||||
@@ -26,4 +26,9 @@ final class UrlShortenerOptions
|
||||
{
|
||||
return $this->mode === ShortUrlMode::LOOSE;
|
||||
}
|
||||
|
||||
public function defaultDomain(): string
|
||||
{
|
||||
return $this->domain['hostname'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ShortUrlIdentifier
|
||||
{
|
||||
private function __construct(public readonly string $shortCode, public readonly ?string $domain = null)
|
||||
@@ -54,4 +56,13 @@ final class ShortUrlIdentifier
|
||||
{
|
||||
return new self($shortCode, $domain);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
if ($this->domain === null) {
|
||||
return $this->shortCode;
|
||||
}
|
||||
|
||||
return sprintf('%s/%s', $this->domain, $this->shortCode);
|
||||
}
|
||||
}
|
||||
|
||||
37
module/Core/src/ShortUrl/Model/UrlShorteningResult.php
Normal file
37
module/Core/src/ShortUrl/Model/UrlShorteningResult.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
|
||||
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Throwable;
|
||||
|
||||
final class UrlShorteningResult
|
||||
{
|
||||
private function __construct(
|
||||
public readonly ShortUrl $shortUrl,
|
||||
private readonly ?Throwable $errorOnEventDispatching,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(Throwable $errorOnEventDispatching): mixed $handler
|
||||
*/
|
||||
public function onEventDispatchingError(callable $handler): void
|
||||
{
|
||||
if ($this->errorOnEventDispatching !== null) {
|
||||
$handler($this->errorOnEventDispatching);
|
||||
}
|
||||
}
|
||||
|
||||
public static function withoutErrorOnEventDispatching(ShortUrl $shortUrl): self
|
||||
{
|
||||
return new self($shortUrl, null);
|
||||
}
|
||||
|
||||
public static function withErrorOnEventDispatching(ShortUrl $shortUrl, Throwable $errorOnEventDispatching): self
|
||||
{
|
||||
return new self($shortUrl, $errorOnEventDispatching);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,13 @@ use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Events;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
|
||||
use Symfony\Component\Lock\Lock;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Lock\Store\InMemoryStore;
|
||||
|
||||
use function Functional\invoke;
|
||||
use function Functional\map;
|
||||
use function Functional\unique;
|
||||
|
||||
@@ -20,31 +25,43 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
|
||||
private array $memoizedNewDomains = [];
|
||||
/** @var array<string, Tag> */
|
||||
private array $memoizedNewTags = [];
|
||||
/** @var array<string, Lock> */
|
||||
private array $tagLocks = [];
|
||||
/** @var array<string, Lock> */
|
||||
private array $domainLocks = [];
|
||||
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly UrlShortenerOptions $options = new UrlShortenerOptions(),
|
||||
private readonly LockFactory $locker = new LockFactory(new InMemoryStore()),
|
||||
) {
|
||||
// Registering this as an event listener will make the postFlush method to be called automatically
|
||||
$this->em->getEventManager()->addEventListener(Events::postFlush, $this);
|
||||
}
|
||||
|
||||
public function resolveDomain(?string $domain): ?Domain
|
||||
{
|
||||
if ($domain === null) {
|
||||
if ($domain === null || $domain === $this->options->defaultDomain()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->lock($this->domainLocks, 'domain_' . $domain);
|
||||
|
||||
/** @var Domain|null $existingDomain */
|
||||
$existingDomain = $this->em->getRepository(Domain::class)->findOneBy(['authority' => $domain]);
|
||||
if ($existingDomain) {
|
||||
// The lock can be released immediately of the domain is not new
|
||||
$this->releaseLock($this->domainLocks, 'domain_' . $domain);
|
||||
return $existingDomain;
|
||||
}
|
||||
|
||||
// Memoize only new domains, and let doctrine handle objects hydrated from persistence
|
||||
return $existingDomain ?? $this->memoizeNewDomain($domain);
|
||||
return $this->memoizeNewDomain($domain);
|
||||
}
|
||||
|
||||
private function memoizeNewDomain(string $domain): Domain
|
||||
{
|
||||
return $this->memoizedNewDomains[$domain] = $this->memoizedNewDomains[$domain] ?? Domain::withAuthority(
|
||||
$domain,
|
||||
);
|
||||
return $this->memoizedNewDomains[$domain] ??= Domain::withAuthority($domain);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,8 +78,16 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
|
||||
$repo = $this->em->getRepository(Tag::class);
|
||||
|
||||
return new Collections\ArrayCollection(map($tags, function (string $tagName) use ($repo): Tag {
|
||||
$this->lock($this->tagLocks, 'tag_' . $tagName);
|
||||
|
||||
$existingTag = $repo->findOneBy(['name' => $tagName]);
|
||||
if ($existingTag) {
|
||||
$this->releaseLock($this->tagLocks, 'tag_' . $tagName);
|
||||
return $existingTag;
|
||||
}
|
||||
|
||||
// Memoize only new tags, and let doctrine handle objects hydrated from persistence
|
||||
$tag = $repo->findOneBy(['name' => $tagName]) ?? $this->memoizeNewTag($tagName);
|
||||
$tag = $this->memoizeNewTag($tagName);
|
||||
$this->em->persist($tag);
|
||||
|
||||
return $tag;
|
||||
@@ -71,12 +96,39 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
|
||||
|
||||
private function memoizeNewTag(string $tagName): Tag
|
||||
{
|
||||
return $this->memoizedNewTags[$tagName] = $this->memoizedNewTags[$tagName] ?? new Tag($tagName);
|
||||
return $this->memoizedNewTags[$tagName] ??= new Tag($tagName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, Lock> $locks
|
||||
*/
|
||||
private function lock(array &$locks, string $name): void
|
||||
{
|
||||
// Lock dependency creation for up to 5 seconds. This will prevent errors when trying to create the same one
|
||||
// more than once in parallel.
|
||||
$locks[$name] = $lock = $this->locker->createLock($name, 5);
|
||||
$lock->acquire(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, Lock> $locks
|
||||
*/
|
||||
private function releaseLock(array &$locks, string $name): void
|
||||
{
|
||||
$locks[$name]->release();
|
||||
unset($locks[$name]);
|
||||
}
|
||||
|
||||
public function postFlush(): void
|
||||
{
|
||||
// Reset memoized domains and tags
|
||||
$this->memoizedNewDomains = [];
|
||||
$this->memoizedNewTags = [];
|
||||
|
||||
// Release all locks
|
||||
invoke($this->tagLocks, 'release');
|
||||
invoke($this->domainLocks, 'release');
|
||||
$this->tagLocks = [];
|
||||
$this->domainLocks = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class ShortUrlListService implements ShortUrlListServiceInterface
|
||||
*/
|
||||
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||
{
|
||||
$defaultDomain = $this->urlShortenerOptions->domain['hostname'] ?? '';
|
||||
$defaultDomain = $this->urlShortenerOptions->defaultDomain();
|
||||
$paginator = new Paginator(new ShortUrlRepositoryAdapter($this->repo, $params, $apiKey, $defaultDomain));
|
||||
$paginator->setMaxPerPage($params->itemsPerPage)
|
||||
->setCurrentPage($params->page);
|
||||
|
||||
29
module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php
Normal file
29
module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\BulkDeleteResult;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Visit\Repository\VisitDeleterRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class ShortUrlVisitsDeleter implements ShortUrlVisitsDeleterInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly VisitDeleterRepositoryInterface $repository,
|
||||
private readonly ShortUrlResolverInterface $resolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): BulkDeleteResult
|
||||
{
|
||||
$shortUrl = $this->resolver->resolveShortUrl($identifier, $apiKey);
|
||||
return new BulkDeleteResult($this->repository->deleteShortUrlVisits($shortUrl));
|
||||
}
|
||||
}
|
||||
18
module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php
Normal file
18
module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\BulkDeleteResult;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface ShortUrlVisitsDeleterInterface
|
||||
{
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): BulkDeleteResult;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlCreated;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
@@ -13,6 +14,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelperInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\UrlShorteningResult;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
|
||||
|
||||
@@ -31,12 +33,12 @@ class UrlShortener implements UrlShortenerInterface
|
||||
* @throws NonUniqueSlugException
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function shorten(ShortUrlCreation $creation): ShortUrl
|
||||
public function shorten(ShortUrlCreation $creation): UrlShorteningResult
|
||||
{
|
||||
// First, check if a short URL exists for all provided params
|
||||
$existingShortUrl = $this->findExistingShortUrlIfExists($creation);
|
||||
if ($existingShortUrl !== null) {
|
||||
return $existingShortUrl;
|
||||
return UrlShorteningResult::withoutErrorOnEventDispatching($existingShortUrl);
|
||||
}
|
||||
|
||||
$creation = $this->titleResolutionHelper->processTitleAndValidateUrl($creation);
|
||||
@@ -51,9 +53,17 @@ class UrlShortener implements UrlShortenerInterface
|
||||
return $shortUrl;
|
||||
});
|
||||
|
||||
$this->eventDispatcher->dispatch(new ShortUrlCreated($newShortUrl->getId()));
|
||||
try {
|
||||
$this->eventDispatcher->dispatch(new ShortUrlCreated($newShortUrl->getId()));
|
||||
} catch (ContainerExceptionInterface $e) {
|
||||
// Ignore container errors when dispatching the event.
|
||||
// When using openswoole, this event will try to enqueue a task, which cannot be done outside an HTTP
|
||||
// request.
|
||||
// If the short URL is created from CLI, the event dispatching will fail.
|
||||
return UrlShorteningResult::withErrorOnEventDispatching($newShortUrl, $e);
|
||||
}
|
||||
|
||||
return $newShortUrl;
|
||||
return UrlShorteningResult::withoutErrorOnEventDispatching($newShortUrl);
|
||||
}
|
||||
|
||||
private function findExistingShortUrlIfExists(ShortUrlCreation $creation): ?ShortUrl
|
||||
|
||||
@@ -6,8 +6,8 @@ namespace Shlinkio\Shlink\Core\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\UrlShorteningResult;
|
||||
|
||||
interface UrlShortenerInterface
|
||||
{
|
||||
@@ -15,5 +15,5 @@ interface UrlShortenerInterface
|
||||
* @throws NonUniqueSlugException
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function shorten(ShortUrlCreation $creation): ShortUrl;
|
||||
public function shorten(ShortUrlCreation $creation): UrlShorteningResult;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface
|
||||
{
|
||||
private const MAX_REDIRECTS = 15;
|
||||
private const CHROME_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
. 'Chrome/108.0.0.0 Safari/537.36';
|
||||
. 'Chrome/112.0.0.0 Safari/537.36';
|
||||
|
||||
public function __construct(private ClientInterface $httpClient, private UrlShortenerOptions $options)
|
||||
{
|
||||
|
||||
31
module/Core/src/Visit/Repository/VisitDeleterRepository.php
Normal file
31
module/Core/src/Visit/Repository/VisitDeleterRepository.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit\Repository;
|
||||
|
||||
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
|
||||
class VisitDeleterRepository extends EntitySpecificationRepository implements VisitDeleterRepositoryInterface
|
||||
{
|
||||
public function deleteShortUrlVisits(ShortUrl $shortUrl): int
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->delete(Visit::class, 'v')
|
||||
->where($qb->expr()->eq('v.shortUrl', ':shortUrl'))
|
||||
->setParameter('shortUrl', $shortUrl);
|
||||
|
||||
return $qb->getQuery()->execute();
|
||||
}
|
||||
|
||||
public function deleteOrphanVisits(): int
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->delete(Visit::class, 'v')
|
||||
->where($qb->expr()->isNull('v.shortUrl'));
|
||||
|
||||
return $qb->getQuery()->execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit\Repository;
|
||||
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
|
||||
interface VisitDeleterRepositoryInterface
|
||||
{
|
||||
public function deleteShortUrlVisits(ShortUrl $shortUrl): int;
|
||||
|
||||
public function deleteOrphanVisits(): int;
|
||||
}
|
||||
22
module/Core/src/Visit/VisitsDeleter.php
Normal file
22
module/Core/src/Visit/VisitsDeleter.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit;
|
||||
|
||||
use Shlinkio\Shlink\Core\Model\BulkDeleteResult;
|
||||
use Shlinkio\Shlink\Core\Visit\Repository\VisitDeleterRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class VisitsDeleter implements VisitsDeleterInterface
|
||||
{
|
||||
public function __construct(private readonly VisitDeleterRepositoryInterface $repository)
|
||||
{
|
||||
}
|
||||
|
||||
public function deleteOrphanVisits(?ApiKey $apiKey = null): BulkDeleteResult
|
||||
{
|
||||
// TODO Check API key has permissions for orphan visits
|
||||
return new BulkDeleteResult($this->repository->deleteOrphanVisits());
|
||||
}
|
||||
}
|
||||
13
module/Core/src/Visit/VisitsDeleterInterface.php
Normal file
13
module/Core/src/Visit/VisitsDeleterInterface.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit;
|
||||
|
||||
use Shlinkio\Shlink\Core\Model\BulkDeleteResult;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface VisitsDeleterInterface
|
||||
{
|
||||
public function deleteOrphanVisits(?ApiKey $apiKey = null): BulkDeleteResult;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioDbTest\Shlink\Core\Visit\Repository;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Visit\Repository\VisitDeleterRepository;
|
||||
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
|
||||
|
||||
class VisitDeleterRepositoryTest extends DatabaseTestCase
|
||||
{
|
||||
private VisitDeleterRepository $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
$this->repo = new VisitDeleterRepository($em, $em->getClassMetadata(Visit::class));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deletesExpectedShortUrlVisits(): void
|
||||
{
|
||||
$shortUrl1 = ShortUrl::withLongUrl('https://foo.com');
|
||||
$this->getEntityManager()->persist($shortUrl1);
|
||||
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl1, Visitor::emptyInstance()));
|
||||
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl1, Visitor::emptyInstance()));
|
||||
|
||||
$shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([
|
||||
ShortUrlInputFilter::LONG_URL => 'https://foo.com',
|
||||
ShortUrlInputFilter::DOMAIN => 's.test',
|
||||
ShortUrlInputFilter::CUSTOM_SLUG => 'foo',
|
||||
]), new PersistenceShortUrlRelationResolver($this->getEntityManager()));
|
||||
$this->getEntityManager()->persist($shortUrl2);
|
||||
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance()));
|
||||
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance()));
|
||||
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance()));
|
||||
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance()));
|
||||
|
||||
$shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([
|
||||
ShortUrlInputFilter::LONG_URL => 'https://foo.com',
|
||||
ShortUrlInputFilter::CUSTOM_SLUG => 'foo',
|
||||
]), new PersistenceShortUrlRelationResolver($this->getEntityManager()));
|
||||
$this->getEntityManager()->persist($shortUrl3);
|
||||
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl3, Visitor::emptyInstance()));
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
self::assertEquals(2, $this->repo->deleteShortUrlVisits($shortUrl1));
|
||||
self::assertEquals(0, $this->repo->deleteShortUrlVisits($shortUrl1));
|
||||
self::assertEquals(4, $this->repo->deleteShortUrlVisits($shortUrl2));
|
||||
self::assertEquals(0, $this->repo->deleteShortUrlVisits($shortUrl2));
|
||||
self::assertEquals(1, $this->repo->deleteShortUrlVisits($shortUrl3));
|
||||
self::assertEquals(0, $this->repo->deleteShortUrlVisits($shortUrl3));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deletesExpectedOrphanVisits(): void
|
||||
{
|
||||
$visitor = Visitor::emptyInstance();
|
||||
$this->getEntityManager()->persist(Visit::forBasePath($visitor));
|
||||
$this->getEntityManager()->persist(Visit::forInvalidShortUrl($visitor));
|
||||
$this->getEntityManager()->persist(Visit::forRegularNotFound($visitor));
|
||||
$this->getEntityManager()->persist(Visit::forBasePath($visitor));
|
||||
$this->getEntityManager()->persist(Visit::forInvalidShortUrl($visitor));
|
||||
$this->getEntityManager()->persist(Visit::forRegularNotFound($visitor));
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
self::assertEquals(6, $this->repo->deleteOrphanVisits());
|
||||
self::assertEquals(0, $this->repo->deleteOrphanVisits());
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,21 @@ class ShortUrlCreationTest extends TestCase
|
||||
yield ['гугл', 'гугл'];
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideValidLongUrls')]
|
||||
public function supportsDifferentTypesOfSchemas(string $longUrl): void
|
||||
{
|
||||
$creation = ShortUrlCreation::fromRawData(['longUrl' => $longUrl]);
|
||||
self::assertEquals($longUrl, $creation->longUrl);
|
||||
}
|
||||
|
||||
public static function provideValidLongUrls(): iterable
|
||||
{
|
||||
yield 'mailto' => ['mailto:foo@example.com'];
|
||||
yield 'file' => ['file:///foo/bar'];
|
||||
yield 'https' => ['https://example.com'];
|
||||
yield 'deeplink' => ['shlink://some/path'];
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideTitles')]
|
||||
public function titleIsCroppedIfTooLong(?string $title, ?string $expectedTitle): void
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver;
|
||||
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Tag\Repository\TagRepositoryInterface;
|
||||
@@ -28,14 +29,22 @@ class PersistenceShortUrlRelationResolverTest extends TestCase
|
||||
$this->em = $this->createMock(EntityManagerInterface::class);
|
||||
$this->em->method('getEventManager')->willReturn(new EventManager());
|
||||
|
||||
$this->resolver = new PersistenceShortUrlRelationResolver($this->em);
|
||||
$this->resolver = new PersistenceShortUrlRelationResolver($this->em, new UrlShortenerOptions(
|
||||
domain: ['schema' => 'https', 'hostname' => 'default.com'],
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsEmptyWhenNoDomainIsProvided(): void
|
||||
#[Test, DataProvider('provideDomainsThatEmpty')]
|
||||
public function returnsEmptyInSomeCases(?string $domain): void
|
||||
{
|
||||
$this->em->expects($this->never())->method('getRepository')->with(Domain::class);
|
||||
self::assertNull($this->resolver->resolveDomain(null));
|
||||
self::assertNull($this->resolver->resolveDomain($domain));
|
||||
}
|
||||
|
||||
public static function provideDomainsThatEmpty(): iterable
|
||||
{
|
||||
yield 'null' => [null];
|
||||
yield 'default domain' => ['default.com'];
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideFoundDomains')]
|
||||
@@ -65,10 +74,12 @@ class PersistenceShortUrlRelationResolverTest extends TestCase
|
||||
#[Test, DataProvider('provideTags')]
|
||||
public function findsAndPersistsTagsWrappedIntoCollection(array $tags, array $expectedTags): void
|
||||
{
|
||||
$expectedPersistedTags = count($expectedTags);
|
||||
$expectedLookedOutTags = count($expectedTags);
|
||||
// One of the tags will already exist. The rest will be new
|
||||
$expectedPersistedTags = $expectedLookedOutTags - 1;
|
||||
|
||||
$tagRepo = $this->createMock(TagRepositoryInterface::class);
|
||||
$tagRepo->expects($this->exactly($expectedPersistedTags))->method('findOneBy')->with(
|
||||
$tagRepo->expects($this->exactly($expectedLookedOutTags))->method('findOneBy')->with(
|
||||
$this->isType('array'),
|
||||
)->willReturnCallback(function (array $criteria): ?Tag {
|
||||
['name' => $name] = $criteria;
|
||||
@@ -81,7 +92,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase
|
||||
|
||||
$result = $this->resolver->resolveTags($tags);
|
||||
|
||||
self::assertCount($expectedPersistedTags, $result);
|
||||
self::assertCount($expectedLookedOutTags, $result);
|
||||
self::assertEquals($expectedTags, $result->toArray());
|
||||
}
|
||||
|
||||
|
||||
55
module/Core/test/ShortUrl/ShortUrlVisitsDeleterTest.php
Normal file
55
module/Core/test/ShortUrl/ShortUrlVisitsDeleterTest.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\ShortUrl;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleter;
|
||||
use Shlinkio\Shlink\Core\Visit\Repository\VisitDeleterRepositoryInterface;
|
||||
|
||||
class ShortUrlVisitsDeleterTest extends TestCase
|
||||
{
|
||||
private ShortUrlVisitsDeleter $deleter;
|
||||
private MockObject & VisitDeleterRepositoryInterface $repository;
|
||||
private MockObject & ShortUrlResolverInterface $resolver;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = $this->createMock(VisitDeleterRepositoryInterface::class);
|
||||
$this->resolver = $this->createMock(ShortUrlResolverInterface::class);
|
||||
|
||||
$this->deleter = new ShortUrlVisitsDeleter($this->repository, $this->resolver);
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideVisitsCounts')]
|
||||
public function returnsDeletedVisitsFromRepo(int $visitsCount): void
|
||||
{
|
||||
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain('');
|
||||
$shortUrl = ShortUrl::withLongUrl('https://example.com');
|
||||
|
||||
$this->resolver->expects($this->once())->method('resolveShortUrl')->with($identifier, null)->willReturn(
|
||||
$shortUrl,
|
||||
);
|
||||
$this->repository->expects($this->once())->method('deleteShortUrlVisits')->with($shortUrl)->willReturn(
|
||||
$visitsCount,
|
||||
);
|
||||
|
||||
$result = $this->deleter->deleteShortUrlVisits($identifier, null);
|
||||
|
||||
self::assertEquals($visitsCount, $result->affectedItems);
|
||||
}
|
||||
|
||||
public static function provideVisitsCounts(): iterable
|
||||
{
|
||||
yield '45' => [45];
|
||||
yield '5000' => [5000];
|
||||
yield '0' => [0];
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Laminas\ServiceManager\Exception\ServiceNotFoundException;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
@@ -26,6 +27,7 @@ class UrlShortenerTest extends TestCase
|
||||
private MockObject & EntityManager $em;
|
||||
private MockObject & ShortUrlTitleResolutionHelperInterface $titleResolutionHelper;
|
||||
private MockObject & ShortCodeUniquenessHelperInterface $shortCodeHelper;
|
||||
private MockObject & EventDispatcherInterface $dispatcher;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
@@ -39,17 +41,19 @@ class UrlShortenerTest extends TestCase
|
||||
fn (callable $callback) => $callback(),
|
||||
);
|
||||
|
||||
$this->dispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
|
||||
$this->urlShortener = new UrlShortener(
|
||||
$this->titleResolutionHelper,
|
||||
$this->em,
|
||||
new SimpleShortUrlRelationResolver(),
|
||||
$this->shortCodeHelper,
|
||||
$this->createMock(EventDispatcherInterface::class),
|
||||
$this->dispatcher,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function urlIsProperlyShortened(): void
|
||||
#[Test, DataProvider('provideDispatchBehavior')]
|
||||
public function urlIsProperlyShortened(bool $expectDispatchError, callable $dispatchBehavior): void
|
||||
{
|
||||
$longUrl = 'http://foobar.com/12345/hello?foo=bar';
|
||||
$meta = ShortUrlCreation::fromRawData(['longUrl' => $longUrl]);
|
||||
@@ -57,10 +61,25 @@ class UrlShortenerTest extends TestCase
|
||||
$meta,
|
||||
)->willReturnArgument(0);
|
||||
$this->shortCodeHelper->method('ensureShortCodeUniqueness')->willReturn(true);
|
||||
$this->dispatcher->expects($this->once())->method('dispatch')->willReturnCallback($dispatchBehavior);
|
||||
|
||||
$shortUrl = $this->urlShortener->shorten($meta);
|
||||
$result = $this->urlShortener->shorten($meta);
|
||||
$thereIsError = false;
|
||||
$result->onEventDispatchingError(function () use (&$thereIsError): void {
|
||||
$thereIsError = true;
|
||||
});
|
||||
|
||||
self::assertEquals($longUrl, $shortUrl->getLongUrl());
|
||||
self::assertEquals($longUrl, $result->shortUrl->getLongUrl());
|
||||
self::assertEquals($expectDispatchError, $thereIsError);
|
||||
}
|
||||
|
||||
public static function provideDispatchBehavior(): iterable
|
||||
{
|
||||
yield 'no dispatch error' => [false, static function (): void {
|
||||
}];
|
||||
yield 'dispatch error' => [true, static function (): void {
|
||||
throw new ServiceNotFoundException();
|
||||
}];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -91,7 +110,7 @@ class UrlShortenerTest extends TestCase
|
||||
|
||||
$result = $this->urlShortener->shorten($meta);
|
||||
|
||||
self::assertSame($expected, $result);
|
||||
self::assertSame($expected, $result->shortUrl);
|
||||
}
|
||||
|
||||
public static function provideExistingShortUrls(): iterable
|
||||
|
||||
41
module/Core/test/Visit/VisitsDeleterTest.php
Normal file
41
module/Core/test/Visit/VisitsDeleterTest.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Visit;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Visit\Repository\VisitDeleterRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsDeleter;
|
||||
|
||||
class VisitsDeleterTest extends TestCase
|
||||
{
|
||||
private VisitsDeleter $visitsDeleter;
|
||||
private MockObject & VisitDeleterRepositoryInterface $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repo = $this->createMock(VisitDeleterRepositoryInterface::class);
|
||||
$this->visitsDeleter = new VisitsDeleter($this->repo);
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideVisitsCounts')]
|
||||
public function returnsDeletedVisitsFromRepo(int $visitsCount): void
|
||||
{
|
||||
$this->repo->expects($this->once())->method('deleteOrphanVisits')->willReturn($visitsCount);
|
||||
|
||||
$result = $this->visitsDeleter->deleteOrphanVisits();
|
||||
|
||||
self::assertEquals($visitsCount, $result->affectedItems);
|
||||
}
|
||||
|
||||
public static function provideVisitsCounts(): iterable
|
||||
{
|
||||
yield '45' => [45];
|
||||
yield '5000' => [5000];
|
||||
yield '0' => [0];
|
||||
}
|
||||
}
|
||||
@@ -32,11 +32,13 @@ return [
|
||||
Action\ShortUrl\DeleteShortUrlAction::class => ConfigAbstractFactory::class,
|
||||
Action\ShortUrl\ResolveShortUrlAction::class => ConfigAbstractFactory::class,
|
||||
Action\ShortUrl\ListShortUrlsAction::class => ConfigAbstractFactory::class,
|
||||
Action\ShortUrl\DeleteShortUrlVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\ShortUrlVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\TagVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\DomainVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\GlobalVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\OrphanVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\DeleteOrphanVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\NonOrphanVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Tag\ListTagsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Tag\TagsStatsAction::class => ConfigAbstractFactory::class,
|
||||
@@ -89,11 +91,13 @@ return [
|
||||
Visit\VisitsStatsHelper::class,
|
||||
Visit\Transformer\OrphanVisitDataTransformer::class,
|
||||
],
|
||||
Action\Visit\DeleteOrphanVisitsAction::class => [Visit\VisitsDeleter::class],
|
||||
Action\Visit\NonOrphanVisitsAction::class => [Visit\VisitsStatsHelper::class],
|
||||
Action\ShortUrl\ListShortUrlsAction::class => [
|
||||
ShortUrl\ShortUrlListService::class,
|
||||
ShortUrlDataTransformer::class,
|
||||
],
|
||||
Action\ShortUrl\DeleteShortUrlVisitsAction::class => [ShortUrl\ShortUrlVisitsDeleter::class],
|
||||
Action\Tag\ListTagsAction::class => [TagService::class],
|
||||
Action\Tag\TagsStatsAction::class => [TagService::class],
|
||||
Action\Tag\DeleteTagsAction::class => [TagService::class],
|
||||
|
||||
@@ -26,9 +26,9 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$shortUrlMeta = $this->buildShortUrlData($request);
|
||||
$shortUrl = $this->urlShortener->shorten($shortUrlMeta);
|
||||
$result = $this->urlShortener->shorten($shortUrlMeta);
|
||||
|
||||
return new JsonResponse($this->transformer->transform($shortUrl));
|
||||
return new JsonResponse($this->transformer->transform($result->shortUrl));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
||||
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleterInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
|
||||
|
||||
class DeleteShortUrlVisitsAction extends AbstractRestAction
|
||||
{
|
||||
protected const ROUTE_PATH = '/short-urls/{shortCode}/visits';
|
||||
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_DELETE];
|
||||
|
||||
public function __construct(private readonly ShortUrlVisitsDeleterInterface $deleter)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$identifier = ShortUrlIdentifier::fromApiRequest($request);
|
||||
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
|
||||
|
||||
$result = $this->deleter->deleteShortUrlVisits($identifier, $apiKey);
|
||||
|
||||
return new JsonResponse($result->toArray('deletedVisits'));
|
||||
}
|
||||
}
|
||||
33
module/Rest/src/Action/Visit/DeleteOrphanVisitsAction.php
Normal file
33
module/Rest/src/Action/Visit/DeleteOrphanVisitsAction.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest\Action\Visit;
|
||||
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsDeleterInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
|
||||
|
||||
class DeleteOrphanVisitsAction extends AbstractRestAction
|
||||
{
|
||||
use PagerfantaUtilsTrait;
|
||||
|
||||
protected const ROUTE_PATH = '/visits/orphan';
|
||||
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_DELETE];
|
||||
|
||||
public function __construct(private readonly VisitsDeleterInterface $visitsDeleter)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
|
||||
$result = $this->visitsDeleter->deleteOrphanVisits($apiKey);
|
||||
|
||||
return new JsonResponse($result->toArray('deletedVisits'));
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,8 @@ class OrphanVisitsAction extends AbstractRestAction
|
||||
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
|
||||
|
||||
public function __construct(
|
||||
private VisitsStatsHelperInterface $visitsHelper,
|
||||
private DataTransformerInterface $orphanVisitTransformer,
|
||||
private readonly VisitsStatsHelperInterface $visitsHelper,
|
||||
private readonly DataTransformerInterface $orphanVisitTransformer,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\MalformedBodyException;
|
||||
|
||||
use function Functional\contains;
|
||||
use function Shlinkio\Shlink\Common\json_decode;
|
||||
use function Shlinkio\Shlink\Json\json_decode;
|
||||
|
||||
class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterface
|
||||
{
|
||||
|
||||
@@ -319,21 +319,6 @@ class CreateShortUrlTest extends ApiTestCase
|
||||
yield 'example domain' => ['example.com'];
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideTwitterUrls')]
|
||||
public function urlsWithBothProtectionCanBeShortenedWithUrlValidationEnabled(string $longUrl): void
|
||||
{
|
||||
[$statusCode] = $this->createShortUrl(['longUrl' => $longUrl, 'validateUrl' => true]);
|
||||
self::assertEquals(self::STATUS_OK, $statusCode);
|
||||
}
|
||||
|
||||
public static function provideTwitterUrls(): iterable
|
||||
{
|
||||
yield ['https://twitter.com/shlinkio'];
|
||||
yield ['https://mobile.twitter.com/shlinkio'];
|
||||
yield ['https://twitter.com/shlinkio/status/1360637738421268481'];
|
||||
yield ['https://mobile.twitter.com/shlinkio/status/1360637738421268481'];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function canCreateShortUrlsWithEmojis(): void
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user