diff --git a/.dockerignore b/.dockerignore index beca6373..e3aff686 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,7 +19,6 @@ indocker docker-* phpstan.neon php*xml* -infection* **/test* build* **/.* diff --git a/.github/DISCUSSION_TEMPLATE/help-wanted.yml b/.github/DISCUSSION_TEMPLATE/help-wanted.yml index 1283f43d..08444522 100644 --- a/.github/DISCUSSION_TEMPLATE/help-wanted.yml +++ b/.github/DISCUSSION_TEMPLATE/help-wanted.yml @@ -20,10 +20,8 @@ body: options: - Self-hosted Apache - Self-hosted nginx - - Self-hosted openswoole - Self-hosted RoadRunner - - Openswoole Docker image - - RoadRunner Docker image + - Docker image - Other (explain in summary) - type: dropdown validations: diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 53e16c98..41f36795 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,2 @@ github: ['acelaya'] -custom: ['https://acel.me/donate'] +custom: ['https://slnk.to/donate'] diff --git a/.github/ISSUE_TEMPLATE/Bug.yml b/.github/ISSUE_TEMPLATE/Bug.yml index 1f715088..2fce5cf4 100644 --- a/.github/ISSUE_TEMPLATE/Bug.yml +++ b/.github/ISSUE_TEMPLATE/Bug.yml @@ -22,10 +22,8 @@ body: options: - Self-hosted Apache - Self-hosted nginx - - Self-hosted openswoole - Self-hosted RoadRunner - - Openswoole Docker image - - RoadRunner Docker image + - Docker image - Other (explain in summary) - type: dropdown validations: diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml index 054575eb..227578f5 100644 --- a/.github/actions/ci-setup/action.yml +++ b/.github/actions/ci-setup/action.yml @@ -12,7 +12,6 @@ inputs: php-extensions: description: 'The PHP extensions to install' required: false - default: '' extensions-cache-key: description: 'The key used to cache PHP extensions. If empty value is provided, extension caching is disabled' required: true @@ -21,6 +20,7 @@ runs: using: composite steps: - name: Setup cache environment + if: ${{ inputs.php-extensions }} id: extcache uses: shivammathur/cache-extensions@v1 with: @@ -28,7 +28,8 @@ runs: extensions: ${{ inputs.php-extensions }} key: ${{ inputs.extensions-cache-key }} - name: Cache extensions - uses: actions/cache@v3 + if: ${{ inputs.php-extensions }} + uses: actions/cache@v4 with: path: ${{ steps.extcache.outputs.dir }} key: ${{ steps.extcache.outputs.key }} @@ -43,5 +44,5 @@ runs: ini-values: pcov.directory=module - name: Install dependencies if: ${{ inputs.install-deps == 'yes' }} - run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.3' && '--ignore-platform-reqs' || '' }} + run: composer install --no-interaction --prefer-dist shell: bash diff --git a/.github/workflows/ci-db-tests.yml b/.github/workflows/ci-db-tests.yml index cc653315..dd797e83 100644 --- a/.github/workflows/ci-db-tests.yml +++ b/.github/workflows/ci-db-tests.yml @@ -14,7 +14,6 @@ jobs: strategy: matrix: php-version: ['8.2', '8.3'] - continue-on-error: ${{ matrix.php-version == '8.3' }} env: LC_ALL: C steps: @@ -28,7 +27,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.1.0, pdo_sqlsrv-5.11.1 + php-extensions: pdo_sqlsrv-5.12.0 extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }} - name: Create test database if: ${{ inputs.platform == 'ms' }} diff --git a/.github/workflows/ci-mutation-tests.yml b/.github/workflows/ci-mutation-tests.yml deleted file mode 100644 index d0d18c15..00000000 --- a/.github/workflows/ci-mutation-tests.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Mutation tests - -on: - workflow_call: - inputs: - test-group: - type: string - required: true - description: One of unit, db, api or cli - -jobs: - mutation-tests: - runs-on: ubuntu-22.04 - strategy: - matrix: - php-version: ['8.2', '8.3'] - continue-on-error: ${{ matrix.php-version == '8.3' }} - steps: - - uses: actions/checkout@v4 - - uses: './.github/actions/ci-setup' - with: - php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.1.0 - extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} - - uses: actions/download-artifact@v4 - with: - name: coverage-${{ inputs.test-group }} - path: build - - name: Resolve infection args - id: infection_args - run: echo "args=--logger-github=false" >> $GITHUB_OUTPUT -# TODO Try to filter mutation tests to improve execution times. Investigate why --git-diff-lines --git-diff-base=develop does not work -# run: | -# BRANCH="${GITHUB_REF#refs/heads/}" | -# if [[ $BRANCH == 'main' || $BRANCH == 'develop' ]]; then -# echo "args=--logger-github=false" >> $GITHUB_OUTPUT -# else -# echo "args=--logger-github=false --git-diff-lines --git-diff-base=develop" >> $GITHUB_OUTPUT -# fi; - shell: bash - - if: ${{ inputs.test-group == 'unit' }} - run: composer infect:ci:unit -- ${{ steps.infection_args.outputs.args }} - env: - INFECTION_BADGE_API_KEY: ${{ secrets.INFECTION_BADGE_API_KEY }} - - if: ${{ inputs.test-group != 'unit' }} - run: composer infect:ci:${{ inputs.test-group }} -- ${{ steps.infection_args.outputs.args }} diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 77c055bf..ea26ccd7 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -14,7 +14,8 @@ jobs: strategy: matrix: php-version: ['8.2', '8.3'] - continue-on-error: ${{ matrix.php-version == '8.3' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically steps: - uses: actions/checkout@v4 - name: Start postgres database server @@ -26,8 +27,10 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.1.0 extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} + - name: Download RoadRunner binary + if: ${{ inputs.test-group == 'api' }} + run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr - run: composer test:${{ inputs.test-group }}:ci - uses: actions/upload-artifact@v4 if: ${{ matrix.php-version == '8.2' }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcbe0a48..933d71b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,6 @@ on: - '*.md' - '*.xml' - '*.yml*' - - '*.json5' - '*.neon' push: branches: @@ -21,7 +20,6 @@ on: - '*.md' - '*.xml' - '*.yml*' - - '*.json5' - '*.neon' jobs: @@ -36,7 +34,6 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.1.0 extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ matrix.command }} - run: composer ${{ matrix.command }} @@ -50,89 +47,25 @@ jobs: with: test-group: cli - openswoole-api-tests: + api-tests: uses: './.github/workflows/ci-tests.yml' with: test-group: api - roadrunner-api-tests: - runs-on: ubuntu-22.04 + db-tests: strategy: matrix: - php-version: ['8.2', '8.3'] - continue-on-error: ${{ matrix.php-version == '8.3' }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically - steps: - - uses: actions/checkout@v4 - - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres - - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - tools: composer - - run: composer install --no-interaction --prefer-dist --ignore-platform-req=ext-openswoole ${{ matrix.php-version == '8.3' && '--ignore-platform-reqs' || '' }} - - run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr - - run: composer test:api:rr - - sqlite-db-tests: + platform: ['sqlite:ci', 'mysql', 'maria', 'postgres', 'ms'] uses: './.github/workflows/ci-db-tests.yml' with: - platform: 'sqlite:ci' - - mysql-db-tests: - uses: './.github/workflows/ci-db-tests.yml' - with: - platform: 'mysql' - - maria-db-tests: - uses: './.github/workflows/ci-db-tests.yml' - with: - platform: 'maria' - - postgres-db-tests: - uses: './.github/workflows/ci-db-tests.yml' - with: - platform: 'postgres' - - ms-db-tests: - uses: './.github/workflows/ci-db-tests.yml' - with: - platform: 'ms' - - unit-mutation-tests: - needs: - - unit-tests - uses: './.github/workflows/ci-mutation-tests.yml' - with: - test-group: unit - - db-mutation-tests: - needs: - - sqlite-db-tests - uses: './.github/workflows/ci-mutation-tests.yml' - with: - test-group: db - - api-mutation-tests: - needs: - - openswoole-api-tests - uses: './.github/workflows/ci-mutation-tests.yml' - with: - test-group: api - - cli-mutation-tests: - needs: - - cli-tests - uses: './.github/workflows/ci-mutation-tests.yml' - with: - test-group: cli + platform: ${{ matrix.platform }} upload-coverage: needs: - unit-tests - - openswoole-api-tests + - api-tests - cli-tests - - sqlite-db-tests + - db-tests runs-on: ubuntu-22.04 strategy: matrix: @@ -141,11 +74,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - name: Use PHP - uses: shivammathur/setup-php@v2 + uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - coverage: pcov - ini-values: pcov.directory=module + extensions-cache-key: tests-extensions-${{ matrix.php-version }} - uses: actions/download-artifact@v4 with: path: build @@ -153,19 +85,14 @@ jobs: - run: mv build/coverage-db/coverage-db.cov build/coverage-db.cov - run: mv build/coverage-api/coverage-api.cov build/coverage-api.cov - run: mv build/coverage-cli/coverage-cli.cov build/coverage-cli.cov - - run: wget https://phar.phpunit.de/phpcov-9.0.0.phar - - run: php phpcov-9.0.0.phar merge build --clover build/clover.xml + - run: vendor/bin/phpcov merge build --clover build/clover.xml - name: Publish coverage - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v4 with: file: ./build/clover.xml delete-artifacts: needs: - - unit-mutation-tests - - db-mutation-tests - - api-mutation-tests - - cli-mutation-tests - upload-coverage runs-on: ubuntu-22.04 steps: diff --git a/.github/workflows/publish-docker-image.yml b/.github/workflows/publish-docker-image.yml index ee9276fd..a57ebe41 100644 --- a/.github/workflows/publish-docker-image.yml +++ b/.github/workflows/publish-docker-image.yml @@ -15,13 +15,6 @@ jobs: - 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: @@ -31,4 +24,3 @@ jobs: tags-suffix: ${{ matrix.tag-suffix }} extra-build-args: | SHLINK_RUNTIME=${{ matrix.runtime }} - SHLINK_USER_ID=${{ matrix.user-id && matrix.user-id || 'root' }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 3fe1a1a4..7875c07b 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -11,22 +11,17 @@ jobs: strategy: matrix: php-version: ['8.2', '8.3'] - swoole: ['yes', 'no'] steps: - uses: actions/checkout@v4 - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.1.0 extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} install-deps: 'no' - - if: ${{ matrix.swoole == 'yes' }} - run: ./build.sh ${GITHUB_REF#refs/tags/v} - - if: ${{ matrix.swoole == 'no' }} - run: ./build.sh ${GITHUB_REF#refs/tags/v} --no-swoole + - run: ./build.sh ${GITHUB_REF#refs/tags/v} - uses: actions/upload-artifact@v4 with: - name: dist-files-${{ matrix.php-version }}-${{ matrix.swoole }} + name: dist-files-${{ matrix.php-version }} path: build publish: diff --git a/.github/workflows/publish-swagger-spec.yml b/.github/workflows/publish-swagger-spec.yml index 2ecf8d49..beebf57f 100644 --- a/.github/workflows/publish-swagger-spec.yml +++ b/.github/workflows/publish-swagger-spec.yml @@ -20,7 +20,6 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.1.0 extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} - run: composer swagger:inline - run: mkdir ${{ steps.determine_version.outputs.version }} diff --git a/.gitignore b/.gitignore index b07b73d1..a7f9b895 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ .idea bin/rr -config/roadrunner/.pid +.pid build !docker/build composer.lock @@ -15,3 +15,4 @@ docs/mercure.html docker-compose.override.yml .phpunit.result.cache docs/swagger/swagger-inlined.json +phpcov* diff --git a/CHANGELOG.md b/CHANGELOG.md index 81281802..c59a0a53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,52 @@ 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). +## [4.0.0] - 2024-03-03 +### Added +* [#1914](https://github.com/shlinkio/shlink/issues/1914) Add new dynamic redirects engine based on rules. Rules are conditions checked against the visitor's request, and when matching, they can result in a redirect to a different long URL. + + Rules can be based on things like the presence of specific params, headers, locations, etc. This version ships with three initial rule condition types: device, query param and language. + +* [#1902](https://github.com/shlinkio/shlink/issues/1902) Add dynamic redirects based on query parameters. + + This is implemented on top of the new [rule-based redirects](https://github.com/shlinkio/shlink/discussions/1912). + +* [#1915](https://github.com/shlinkio/shlink/issues/1915) Add dynamic redirects based on accept language. + + This is implemented on top of the new [rule-based redirects](https://github.com/shlinkio/shlink/discussions/1912). + +* [#1868](https://github.com/shlinkio/shlink/issues/1868) Add support for [docker compose secrets](https://docs.docker.com/compose/use-secrets/) to the docker image. +* [#1979](https://github.com/shlinkio/shlink/issues/1979) Allow orphan visits lists to be filtered by type. + + This is supported both by the `GET /visits/orphan` API endpoint via `type=...` query param, and by the `visit:orphan` CLI command, via `--type` flag. + +* [#1904](https://github.com/shlinkio/shlink/issues/1904) Allow to customize QR codes foreground color, background color and logo. +* [#1884](https://github.com/shlinkio/shlink/issues/1884) Allow a path prefix to be provided during short URL creation. + + This can be useful to let Shlink generate partially random URLs, but with a known prefix. + + Path prefixes are validated and filtered taking multi-segment slugs into consideration, which means slashes are replaced with dashes as long as multi-segment slugs are disabled. + +### Changed +* [#1935](https://github.com/shlinkio/shlink/issues/1935) Replace dependency on abandoned `php-middleware/request-id` with userland simple middleware. +* [#1988](https://github.com/shlinkio/shlink/issues/1988) Remove dependency on `league\uri` package. +* [#1909](https://github.com/shlinkio/shlink/issues/1909) Update docker image to PHP 8.3. +* [#1786](https://github.com/shlinkio/shlink/issues/1786) Run API tests with RoadRunner by default. +* [#2008](https://github.com/shlinkio/shlink/issues/2008) Update to Doctrine ORM 3.0. +* [#2010](https://github.com/shlinkio/shlink/issues/2010) Update to Symfony 7.0 components. +* [#2016](https://github.com/shlinkio/shlink/issues/2016) Simplify and improve how code coverage is generated in API and CLI tests. +* [#1674](https://github.com/shlinkio/shlink/issues/1674) Database columns persisting long URLs have now `TEXT` type, which allows for much longer values. + +### Deprecated +* *Nothing* + +### Removed +* [#1908](https://github.com/shlinkio/shlink/issues/1908) Remove support for openswoole (and swoole). + +### Fixed +* [#2000](https://github.com/shlinkio/shlink/issues/2000) Fix short URL creation/edition getting stuck when trying to resolve the title of a long URL which never returns a response. + + ## [3.7.3] - 2024-01-04 ### Added * *Nothing* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4b2f36d2..4ee94c70 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,7 +31,7 @@ Then you will have to follow these steps: * Run `./indocker bin/cli db:migrate` to get database migrations up to date. * Run `./indocker bin/cli api-key:generate` to get your first API key generated. -Once you finish this, you will have the project exposed in ports `8800` through RoadRunner, `8080` through openswoole and `8000` through nginx+php-fpm. +Once you finish this, you will have the project exposed in ports `8800` through RoadRunner and `8000` through nginx+php-fpm. > Note: The `indocker` shell script is a helper tool used to run commands inside the main docker container. @@ -80,7 +80,7 @@ The purposes of every folder are: * `data`: Common git-ignored assets, like logs, caches, lock files, GeoLite DB files, etc. It's the only location where Shlink may need to write at runtime. * `docs`: Any project documentation is stored here, like API spec definitions or architectural decision records. * `module`: Contains a sub-folder for every module in the project. Modules contain the source code, tests and configurations for every context in the project. -* `public`: Few assets (like `favicon.ico` or `robots.txt`) and the web entry point are stored here. This web entry point is not used when serving the app with RoadRunner or openswoole. +* `public`: Few assets (like `favicon.ico` or `robots.txt`) and the web entry point are stored here. This web entry point is not used when serving the app with RoadRunner. ## Project tests @@ -96,7 +96,7 @@ In order to ensure stability and no regressions are introduced while developing The project provides some tooling to run them against any of the supported database engines. -* **API tests**: These are E2E tests that spin up an instance of the app with RoadRunner or openswoole, and test it from the outside by interacting with the REST API. +* **API tests**: These are E2E tests that spin up an instance of the app with RoadRunner, and test it from the outside by interacting with the REST API. These are the best tests to catch regressions, and to verify everything behaves as expected. @@ -124,7 +124,6 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed, * Run `./indocker composer test:api` to run API E2E tests. For these, the Postgres database engine is used. * Run `./indocker composer test:cli` to run CLI E2E tests. For these, the Maria DB database engine is used. -* Run `./indocker composer infect:test` to run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/). * Run `./indocker composer ci` to run all previous commands together, parallelizing non-conflicting tasks as much as possible. ## Testing endpoints diff --git a/Dockerfile b/Dockerfile index 0916b10b..4251b3e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,12 @@ -FROM php:8.2-alpine3.17 as base +FROM php:8.3-alpine3.19 as base ARG SHLINK_VERSION=latest ENV SHLINK_VERSION ${SHLINK_VERSION} ARG SHLINK_RUNTIME=rr ENV SHLINK_RUNTIME ${SHLINK_RUNTIME} -ARG SHLINK_USER_ID='root' -ENV SHLINK_USER_ID ${SHLINK_USER_ID} -ENV OPENSWOOLE_VERSION 22.1.0 -ENV PDO_SQLSRV_VERSION 5.11.1 +ENV USER_ID '1001' +ENV PDO_SQLSRV_VERSION 5.12.0 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' @@ -26,13 +24,8 @@ RUN \ apk del .dev-deps && \ apk add --no-cache postgresql icu libzip libpng -# Install openswoole and sqlsrv driver for x86_64 builds +# Install sqlsrv driver for x86_64 builds RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \ - if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \ - # Openswoole is deprecated. Remove in v4.0.0 - pecl install openswoole-${OPENSWOOLE_VERSION} && \ - docker-php-ext-enable openswoole ; \ - fi; \ if [ $(uname -m) == "x86_64" ]; then \ wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ @@ -47,14 +40,7 @@ FROM base as builder COPY . . COPY --from=composer:2 /usr/bin/composer ./composer.phar RUN apk add --no-cache git && \ - # 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 \ - # Openswoole is deprecated. Remove in v4.0.0 - 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 install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction && \ php composer.phar clear-cache && \ rm -r docker composer.* && \ sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php @@ -64,7 +50,7 @@ RUN apk add --no-cache git && \ FROM base LABEL maintainer="Alejandro Celaya " -COPY --from=builder --chown=${SHLINK_USER_ID} /etc/shlink . +COPY --from=builder --chown=${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 --no-config --location bin/ && chmod +x bin/rr ; \ @@ -78,6 +64,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/ -USER ${SHLINK_USER_ID} +USER ${USER_ID} ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"] diff --git a/LICENSE b/LICENSE index c245a4e0..e58a6f71 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016-2023 Alejandro Celaya +Copyright (c) 2016-2024 Alejandro Celaya Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 7e84d5ae..7f8cc164 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,13 @@ [![Build Status](https://img.shields.io/github/actions/workflow/status/shlinkio/shlink/ci.yml?branch=develop&logo=github&style=flat-square)](https://github.com/shlinkio/shlink/actions/workflows/ci.yml?query=workflow%3A%22Continuous+integration%22) [![Code Coverage](https://img.shields.io/codecov/c/gh/shlinkio/shlink/develop?style=flat-square)](https://app.codecov.io/gh/shlinkio/shlink) -[![Infection MSI](https://img.shields.io/endpoint?style=flat-square&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fshlinkio%2Fshlink%2Fdevelop)](https://dashboard.stryker-mutator.io/reports/github.com/shlinkio/shlink/develop) [![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink) [![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/) [![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/main/LICENSE) -[![Twitter](https://img.shields.io/badge/follow-shlinkio-blue.svg?style=flat-square&logo=x&color=black)](https://twitter.com/shlinkio) + [![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio) +[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlinkio.bsky.social) +[![Twitter](https://img.shields.io/badge/follow-shlinkio-blue.svg?style=flat-square&logo=x&color=black)](https://twitter.com/shlinkio) [![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate) A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own domain. @@ -38,12 +39,11 @@ First, make sure the host where you are going to run shlink fulfills these requi * PHP 8.2 or 8.3 * The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath. - * apcu extension is recommended if you don't plan to use openswoole. + * apcu extension is recommended if you don't plan to use RoadRunner. * xml extension is required if you want to generate QR codes in svg format. * sockets and bcmath extensions are required if you want to integrate with a RabbitMQ instance. * MySQL, MariaDB, PostgreSQL, MicrosoftSQL or SQLite. * You will also need the corresponding pdo variation for the database you are planning to use: `pdo_mysql`, `pdo_pgsql`, `pdo_sqlsrv` or `pdo_sqlite`. -* The [openswoole](https://openswoole.com/) PHP extension (if you plan to serve Shlink with openswoole) or the web server of your choice with PHP integration (like Apache or Nginx). ### Download @@ -53,7 +53,7 @@ In order to run Shlink, you will need a built version of the project. There are The easiest way to install shlink is by using one of the pre-bundled distributable packages. - Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink*_dist.zip` file that suits your needs. You will find one for every supported PHP version and with/without openswoole integration. + Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink*_dist.zip` file that suits your needs. You will find one for every supported PHP version. Finally, decompress the file in the location of your choice. diff --git a/UPGRADE.md b/UPGRADE.md index 6bef9dbc..bbb7c3a4 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,55 @@ # Upgrading +## From v3.x to v4.x + +### General + +* Swoole and Openswoole are no longer officially supported runtimes. The recommended alternative is RoadRunner. +* Dist files for swoole/openswoole are no longer published. +* Webhooks are no longer supported. Migrate to one of the other [real-time updates](https://shlink.io/documentation/advanced/real-time-updates/) mechanisms. +* When using RoadRunner, the amount of web workers, task workers and the port number can no longer be provided via config options. Use `WEB_WORKER_NUM`, `TASK_WORKER_NUM` and `PORT` env vars instead. + +### Changes in URL shortener + +* The short URLs `loosely` mode is no longer supported, as it was a typo. Use `loose` mode instead. +* QR codes URLs now work by default, even for short URLs that cannot be visited due to max visits or date range limitations. + If you want to keep previous behavior, pass `QR_CODE_FOR_DISABLED_SHORT_URLS=false` or the equivalent configuration option. +* Long URL title resolution is now enabled by default. You can still disable it by passing `AUTO_RESOLVE_TITLES=false` or the equivalent configuration option. +* Shlink no longer allows to opt-in for long URL verification. Long URLs are unconditionally considered correct during short URL creation/edition. +* Device long URLs have been migrated to the new Dynamic rule-based redirects system and will continue to work as expected, but the API surface has changed. + If you use shlink-web-client and rely on this feature when creating/updating short URLs, **DO NOT UPDATE YET**. Support for dynamic rule-based redirects will be added to shlink-web-client soon, in v4.1.0 + +### Changes in REST API + +* REST API v1/v2 now behave like v3. This only affects error codes, which are now proper URIs. + * `INVALID_ARGUMENT` -> `https://shlink.io/api/error/invalid-data` + * `INVALID_SHORT_URL_DELETION` -> `https://shlink.io/api/error/invalid-short-url-deletion` + * `DOMAIN_NOT_FOUND` -> `https://shlink.io/api/error/domain-not-found` + * `FORBIDDEN_OPERATION` -> `https://shlink.io/api/error/forbidden-tag-operation` + * `INVALID_SLUG` -> `https://shlink.io/api/error/non-unique-slug` + * `INVALID_SHORTCODE` -> `https://shlink.io/api/error/short-url-not-found` + * `TAG_CONFLICT` -> `https://shlink.io/api/error/tag-conflict` + * `TAG_NOT_FOUND` -> `https://shlink.io/api/error/tag-not-found` + * `MERCURE_NOT_CONFIGURED` -> `https://shlink.io/api/error/mercure-not-configured` + * `INVALID_AUTHORIZATION` -> `https://shlink.io/api/error/missing-authentication` + * `INVALID_API_KEY` -> `https://shlink.io/api/error/invalid-api-key` +* Endpoints previously returning props like `"visitsCount": {number}` no longer do it. There should be an alternative `"visitsSummary": {}` object with the amount nested on it. +* It is no longer possible to order the short URLs list with `orderBy=visitsCount-ASC`/`orderBy=visitsCount-DESC`. Use `orderBy=visits-ASC`/`orderBy=visits-DESC` instead. +* It is no longer possible to get tags with stats using `GET /tags?withStats=true`. Use `GET /tags/stats` endpoint instead. +* The `deviceLongUrls` are ignored when calling `POST /short-urls` or `PATCH /short-urls/{shortCode}`. These should now be configured as dynamic rule-based redirects via `POST /short-urls/{shortCode}/redirect-rules`. + +### Changes in Docker image + +* Since openswoole is no longer supported, there are no longer image tags suffixed with `openswoole`. You should migrate to the default or `roadrunner` ones. +* The `non-root` docker tag is no longer published, as all docker images are now running without super-user permissions. +* Due to previous point, it is no longer possible to pass `ENABLE_PERIODIC_VISIT_LOCATE=true` in order to configure a cron job that locates visits periodically. + This was not really needed in the docker image, as visits are located on the fly. + +### Changes in integrations + +* Credentials in redis URLs should now be URL-encoded, as they are unconditionally url-decoded before being used. Previously, it was possible to customize this behavior via `REDIS_DECODE_CREDENTIALS=true|false`. +* Providing redis URIs in the form of `tcp://password@6.6.6.6:6379` is no longer supported. If you want to provide password with no username, do `tcp://:password@6.6.6.6:6379` instead. + ## From v2.x to v3.x ### Changes in REST API diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index b22a974e..e39d564d 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -2,7 +2,7 @@ export APP_ENV=test export TEST_ENV=api -export TEST_RUNTIME="${TEST_RUNTIME:-"openswoole"}" # Openswoole is deprecated. Remove in v4.0.0 +export TEST_RUNTIME="${TEST_RUNTIME:-"rr"}" # rr is the only runtime currently supported export DB_DRIVER="${DB_DRIVER:-"postgres"}" export GENERATE_COVERAGE="${GENERATE_COVERAGE:-"no"}" @@ -13,26 +13,19 @@ mkdir data/log/api-tests touch $OUTPUT_LOGS # Try to stop server just in case it hanged in last execution -[ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:stop -[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -f +[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -f -w . echo 'Starting server...' -[ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:start -d -[ "$TEST_RUNTIME" = 'rr' ] && bin/rr serve -p -c=config/roadrunner/.rr.dev.yml \ - -o=http.address=0.0.0.0:9999 \ - -o=logs.encoding=json \ - -o=logs.channels.http.encoding=json \ - -o=logs.channels.server.encoding=json \ +[ "$TEST_RUNTIME" = 'rr' ] && bin/rr serve -p -w . -c=config/roadrunner/.rr.test.yml \ -o=logs.output="${PWD}/${OUTPUT_LOGS}" \ -o=logs.channels.http.output="${PWD}/${OUTPUT_LOGS}" \ -o=logs.channels.server.output="${PWD}/${OUTPUT_LOGS}" & sleep 2 # Let's give the server a couple of seconds to start -vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always --log-junit=build/coverage-api/junit.xml $* -testsExitCode=$? +vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $* +TESTS_EXIT_CODE=$? -[ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:stop -[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -c config/roadrunner/.rr.dev.yml -o=http.address=0.0.0.0:9999 +[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -w . # Exit this script with the same code as the tests. If tests failed, this script has to fail -exit $testsExitCode +exit $TESTS_EXIT_CODE diff --git a/build.sh b/build.sh index db607172..7b77295f 100755 --- a/build.sh +++ b/build.sh @@ -1,18 +1,15 @@ #!/usr/bin/env bash set -e -if [ "$#" -lt 1 ] || [ "$#" -gt 2 ] || ([ "$#" == 2 ] && [ "$2" != "--no-swoole" ]); then +if [ "$#" -lt 1 ]; then echo "Usage:" >&2 - echo " $0 {version} [--no-swoole]" >&2 + echo " $0 {version}" >&2 exit 1 fi version=$1 -noSwoole=$2 phpVersion=$(php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;') -# Openswoole is deprecated. Remove in v4.0.0 -[[ $noSwoole ]] && swooleSuffix="" || swooleSuffix="_openswoole" -distId="shlink${version}_php${phpVersion}${swooleSuffix}_dist" +distId="shlink${version}_php${phpVersion}_dist" builtContent="./build/${distId}" projectdir=$(pwd) [[ -f ./composer.phar ]] && composerBin='./composer.phar' || composerBin='composer' @@ -31,19 +28,8 @@ cd "${builtContent}" # Install dependencies echo "Installing dependencies with $composerBin..." -# Deprecated. Do not ignore PHP platform req for Shlink v4.0.0 -composerFlags="--optimize-autoloader --no-progress --no-interaction --ignore-platform-req=php+" ${composerBin} self-update -${composerBin} install --no-dev --prefer-dist $composerFlags - -if [[ $noSwoole ]]; then - # If generating a dist not for openswoole, uninstall mezzio-swoole - ${composerBin} remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev $composerFlags -else - # Deprecated. Remove in Shlink v4.0.0 - # If generating a dist for openswoole, uninstall RoadRunner - ${composerBin} remove spiral/roadrunner spiral/roadrunner-jobs spiral/roadrunner-cli spiral/roadrunner-http --with-all-dependencies --update-no-dev $composerFlags -fi +${composerBin} install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction # Delete development files echo 'Deleting dev files...' diff --git a/composer.json b/composer.json index 7c336b05..a2cb2131 100644 --- a/composer.json +++ b/composer.json @@ -19,13 +19,13 @@ "ext-pdo": "*", "akrabat/ip-address-middleware": "^2.1", "cakephp/chronos": "^3.0.2", + "doctrine/dbal": "^4.0", "doctrine/migrations": "^3.6", - "doctrine/orm": "^2.16", - "endroid/qr-code": "^4.8", + "doctrine/orm": "^3.0", + "endroid/qr-code": "^5.0", "friendsofphp/proxy-manager-lts": "^1.0", "geoip2/geoip2": "^3.0", "guzzlehttp/guzzle": "^7.5", - "happyr/doctrine-specification": "^2.0", "jaybizzle/crawler-detect": "^1.2.116", "laminas/laminas-config": "^3.8", "laminas/laminas-config-aggregator": "^1.13", @@ -33,50 +33,47 @@ "laminas/laminas-inputfilter": "^2.27", "laminas/laminas-servicemanager": "^3.21", "laminas/laminas-stdlib": "^3.17", - "league/uri": "^6.8", "matomo/matomo-php-tracker": "^3.2", "mezzio/mezzio": "^3.17", - "mezzio/mezzio-fastroute": "^3.10", + "mezzio/mezzio-fastroute": "^3.11", "mezzio/mezzio-problem-details": "^1.13", - "mezzio/mezzio-swoole": "^4.7", "mlocati/ip-lib": "^1.18", "mobiledetect/mobiledetectlib": "^4.8", "pagerfanta/core": "^3.8", - "php-middleware/request-id": "^4.1", "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", - "shlinkio/shlink-common": "^5.7.1", - "shlinkio/shlink-config": "^2.5", - "shlinkio/shlink-event-dispatcher": "^3.1", - "shlinkio/shlink-importer": "^5.2.1", - "shlinkio/shlink-installer": "^8.7", - "shlinkio/shlink-ip-geolocation": "^3.4", + "shlinkio/doctrine-specification": "^2.1.1", + "shlinkio/shlink-common": "^6.0", + "shlinkio/shlink-config": "^3.0", + "shlinkio/shlink-event-dispatcher": "^4.0", + "shlinkio/shlink-importer": "^5.3", + "shlinkio/shlink-installer": "^9.0", + "shlinkio/shlink-ip-geolocation": "^3.5", "shlinkio/shlink-json": "^1.1", - "spiral/roadrunner": "^2023.2", - "spiral/roadrunner-cli": "^2.5", - "spiral/roadrunner-http": "^3.1", - "spiral/roadrunner-jobs": "^4.0", - "symfony/console": "^6.3", - "symfony/filesystem": "^6.3", - "symfony/lock": "^6.3", - "symfony/process": "^6.3", - "symfony/string": "^6.3" + "spiral/roadrunner": "^2023.3", + "spiral/roadrunner-cli": "^2.6", + "spiral/roadrunner-http": "^3.3", + "spiral/roadrunner-jobs": "^4.3", + "symfony/console": "^7.0", + "symfony/filesystem": "^7.0", + "symfony/lock": "^7.0", + "symfony/process": "^7.0", + "symfony/string": "^7.0" }, "require-dev": { "devizzent/cebe-php-openapi": "^1.0.1", "devster/ubench": "^2.1", - "infection/infection": "^0.27", - "openswoole/ide-helper": "~22.0.0", "phpstan/phpstan": "^1.10", "phpstan/phpstan-doctrine": "^1.3", "phpstan/phpstan-phpunit": "^1.3", "phpstan/phpstan-symfony": "^1.3", "phpunit/php-code-coverage": "^10.1", + "phpunit/phpcov": "^9.0", "phpunit/phpunit": "^10.4", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", - "shlinkio/shlink-test-utils": "^3.8.1", - "symfony/var-dumper": "^6.3", + "shlinkio/shlink-test-utils": "^4.1", + "symfony/var-dumper": "^7.0", "veewee/composer-run-parallel": "^1.3" }, "conflict": { @@ -111,8 +108,8 @@ }, "scripts": { "ci": [ - "@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" + "@parallel cs stan swagger:validate test:unit:ci test:db:sqlite:ci test:db:postgres test:db:mysql test:db:maria test:db:ms", + "@parallel test:api:ci test:cli:ci" ], "cs": "phpcs -s", "cs:fix": "phpcbf", @@ -122,54 +119,27 @@ "@parallel test:api test:cli" ], "test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --testdox", - "test:unit:ci": "@test:unit --coverage-php=build/coverage-unit.cov --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml", + "test:unit:ci": "@test:unit --coverage-php=build/coverage-unit.cov", "test:unit:pretty": "@test:unit --coverage-html build/coverage-unit/coverage-html", "test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms", "test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml", - "test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml", + "test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov", "test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite", "test:db:maria": "DB_DRIVER=maria composer test:db:sqlite", "test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite", "test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite", "test:api": "bin/test/run-api-tests.sh", - "test:api:rr": "TEST_RUNTIME=rr bin/test/run-api-tests.sh", - "test:api:ci": "GENERATE_COVERAGE=yes composer test:api", - "test:api:pretty": "GENERATE_COVERAGE=pretty composer test:api", - "test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml --log-junit=build/coverage-cli/junit.xml", - "test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli", - "test:cli:pretty": "GENERATE_COVERAGE=pretty composer test:cli", - "infect:ci:base": "infection --threads=max --only-covered --skip-initial-tests", - "infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80", - "infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json5", - "infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=95 --configuration=infection-api.json5", - "infect:ci:cli": "@infect:ci:base --coverage=build/coverage-cli --min-msi=90 --configuration=infection-cli.json5", - "infect:ci": "@parallel infect:ci:unit infect:ci:db infect:ci:api infect:ci:cli", - "infect:test": [ - "@parallel test:unit:ci test:db:sqlite:ci test:api:ci", - "@infect:ci" - ], - "infect:test:unit": [ - "@test:unit:ci", - "@infect:ci:unit" - ], - "infect:test:db": [ - "@test:db:sqlite:ci", - "@infect:ci:db" - ], - "infect:test:api": [ - "@test:api:ci", - "@infect:ci:api" - ], - "infect:test:cli": [ - "@test:cli:ci", - "@infect:ci:cli" - ], + "test:api:ci": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --php build/coverage-api.cov && rm build/coverage-api/*.cov", + "test:api:pretty": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --html build/coverage-api/coverage-html && rm build/coverage-api/*.cov", + "test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml", + "test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli && vendor/bin/phpcov merge build/coverage-cli --php build/coverage-cli.cov && rm build/coverage-cli/*.cov", + "test:cli:pretty": "GENERATE_COVERAGE=yes composer test:cli && vendor/bin/phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov", "swagger:validate": "php-openapi validate docs/swagger/swagger.json", "swagger:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/swagger-inlined.json", "clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php" }, "scripts-descriptions": { - "ci": "Alias for \"cs\", \"stan\", \"swagger:validate\", \"test:ci\" and \"infect:ci\"", + "ci": "Alias for \"cs\", \"stan\", \"swagger:validate\" and \"test:ci\"", "cs": "Checks coding styles", "cs:fix": "Fixes coding styles, when possible", "stan": "Inspects code with phpstan", @@ -190,10 +160,6 @@ "test:cli": "Runs CLI test suites", "test:cli:ci": "Runs CLI test suites, and generates code coverage for CI", "test:cli:pretty": "Runs CLI test suites, and generates code coverage in HTML format", - "infect:ci": "Checks unit and db tests quality applying mutation testing with existing reports and logs", - "infect:ci:unit": "Checks unit tests quality applying mutation testing with existing reports and logs", - "infect:ci:db": "Checks db tests quality applying mutation testing with existing reports and logs", - "infect:test": "Runs unit and db tests, then checks tests quality applying mutation testing", "swagger:validate": "Validates the swagger docs, making sure they fulfil the spec", "swagger:inline": "Inlines swagger docs in a single file", "clean:dev": "Deletes artifacts which are gitignored and could affect dev env" @@ -204,7 +170,6 @@ "allow-plugins": { "composer/package-versions-deprecated": true, "dealerdirect/phpcodesniffer-composer-installer": true, - "infection/extension-installer": true, "veewee/composer-run-parallel": true } } diff --git a/config/autoload/cache.global.php b/config/autoload/cache.global.php index 30db2c0a..94a9a183 100644 --- a/config/autoload/cache.global.php +++ b/config/autoload/cache.global.php @@ -11,7 +11,6 @@ return (static function (): array { 'redis' => [ 'servers' => $redisServers, 'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(), - 'decode_credentials' => (bool) EnvVars::REDIS_DECODE_CREDENTIALS->loadFromEnv(false), ], ]; diff --git a/config/autoload/common.global.php b/config/autoload/common.global.php index 19404d8c..c7db57f1 100644 --- a/config/autoload/common.global.php +++ b/config/autoload/common.global.php @@ -8,7 +8,7 @@ return [ 'debug' => false, - // Disabling config cache for cli, ensures it's never used for openswoole/RoadRunner, and also that console + // Disabling config cache for cli, ensures it's never used for RoadRunner, and also that console // commands don't generate a cache file that's then used by php-fpm web executions ConfigAggregator::ENABLE_CACHE => PHP_SAPI !== 'cli', diff --git a/config/autoload/dependencies.global.php b/config/autoload/dependencies.global.php index a0014ef6..469171ca 100644 --- a/config/autoload/dependencies.global.php +++ b/config/autoload/dependencies.global.php @@ -4,6 +4,7 @@ declare(strict_types=1); use GuzzleHttp\Client; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; +use Laminas\ServiceManager\Factory\InvokableFactory; use Mezzio\Application; use Mezzio\Container; use Psr\Http\Client\ClientInterface; @@ -12,12 +13,14 @@ use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\UploadedFileFactoryInterface; use Spiral\RoadRunner\Http\PSR7Worker; use Spiral\RoadRunner\WorkerInterface; +use Symfony\Component\Filesystem\Filesystem; return [ 'dependencies' => [ 'factories' => [ PSR7Worker::class => ConfigAbstractFactory::class, + Filesystem::class => InvokableFactory::class, ], 'delegators' => [ diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 45f92153..b6a79679 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -21,8 +21,6 @@ return [ Option\Database\DatabaseUnixSocketConfigOption::class, Option\UrlShortener\ShortDomainHostConfigOption::class, Option\UrlShortener\ShortDomainSchemaConfigOption::class, - Option\Visit\VisitsWebhooksConfigOption::class, - Option\Visit\OrphanVisitsWebhooksConfigOption::class, Option\Redirect\BaseUrlRedirectConfigOption::class, Option\Redirect\InvalidShortUrlRedirectConfigOption::class, Option\Redirect\Regular404RedirectConfigOption::class, @@ -30,10 +28,7 @@ return [ Option\BasePathConfigOption::class, Option\TimezoneConfigOption::class, Option\Cache\CacheNamespaceConfigOption::class, - Option\Worker\TaskWorkerNumConfigOption::class, - Option\Worker\WebWorkerNumConfigOption::class, Option\Redis\RedisServersConfigOption::class, - Option\Redis\RedisDecodeCredentialsConfigOption::class, Option\Redis\RedisSentinelServiceConfigOption::class, Option\Redis\RedisPubSubConfigOption::class, Option\UrlShortener\ShortCodeLengthOption::class, @@ -62,6 +57,9 @@ return [ Option\QrCode\DefaultFormatConfigOption::class, Option\QrCode\DefaultErrorCorrectionConfigOption::class, Option\QrCode\DefaultRoundBlockSizeConfigOption::class, + Option\QrCode\DefaultColorConfigOption::class, + Option\QrCode\DefaultBgColorConfigOption::class, + Option\QrCode\DefaultLogoUrlConfigOption::class, Option\QrCode\EnabledForDisabledShortUrlsConfigOption::class, Option\RabbitMq\RabbitMqEnabledConfigOption::class, Option\RabbitMq\RabbitMqHostConfigOption::class, diff --git a/config/autoload/logger.global.php b/config/autoload/logger.global.php index 01ec40ab..67b737ae 100644 --- a/config/autoload/logger.global.php +++ b/config/autoload/logger.global.php @@ -7,20 +7,21 @@ 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; +use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware; use function Shlinkio\Shlink\Config\runningInRoadRunner; return (static function (): array { $common = [ 'level' => Level::Info->value, - 'processors' => [RequestId\MonologProcessor::class], - 'line_format' => '[%datetime%] [%extra.request_id%] %channel%.%level_name% - %message%', + 'processors' => [RequestIdMiddleware::class], + 'line_format' => + '[%datetime%] [%extra.' . RequestIdMiddleware::ATTRIBUTE . '%] %channel%.%level_name% - %message%', ]; return [ @@ -52,16 +53,5 @@ return (static function (): array { ], ], - // Deprecated. Remove in Shlink 4.0.0 - '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, - ], - ], - ], - ]; })(); diff --git a/config/autoload/mercure.local.php.dist b/config/autoload/mercure.local.php.dist index e818404b..13a74022 100644 --- a/config/autoload/mercure.local.php.dist +++ b/config/autoload/mercure.local.php.dist @@ -5,7 +5,7 @@ declare(strict_types=1); return [ 'mercure' => [ - 'public_hub_url' => 'http://localhost:8001', + 'public_hub_url' => 'http://localhost:8002', 'internal_hub_url' => 'http://shlink_mercure_proxy', 'jwt_secret' => 'mercure_jwt_key_long_enough_to_avoid_error', ], diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index cb8045e9..99f71bce 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -7,10 +7,10 @@ namespace Shlinkio\Shlink; use Laminas\Stratigility\Middleware\ErrorHandler; 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; +use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware; return [ @@ -47,7 +47,6 @@ return [ 'rest' => [ 'path' => '/rest', 'middleware' => [ - Rest\Middleware\ErrorHandler\BackwardsCompatibleProblemDetailsHandler::class, Router\Middleware\ImplicitOptionsMiddleware::class, Rest\Middleware\BodyParserMiddleware::class, Rest\Middleware\AuthenticationMiddleware::class, diff --git a/config/autoload/qr-codes.global.php b/config/autoload/qr-codes.global.php index 808ff961..919beffa 100644 --- a/config/autoload/qr-codes.global.php +++ b/config/autoload/qr-codes.global.php @@ -4,6 +4,8 @@ declare(strict_types=1); use Shlinkio\Shlink\Core\Config\EnvVars; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION; use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT; @@ -26,6 +28,9 @@ return [ 'enabled_for_disabled_short_urls' => (bool) EnvVars::QR_CODE_FOR_DISABLED_SHORT_URLS->loadFromEnv( DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS, ), + 'color' => EnvVars::DEFAULT_QR_CODE_COLOR->loadFromEnv(DEFAULT_QR_CODE_COLOR), + 'bg_color' => EnvVars::DEFAULT_QR_CODE_BG_COLOR->loadFromEnv(DEFAULT_QR_CODE_BG_COLOR), + 'logo_url' => EnvVars::DEFAULT_QR_CODE_LOGO_URL->loadFromEnv(), ], ]; diff --git a/config/autoload/rabbit.global.php b/config/autoload/rabbit.global.php index bf9591e5..fd8cda68 100644 --- a/config/autoload/rabbit.global.php +++ b/config/autoload/rabbit.global.php @@ -14,9 +14,6 @@ return [ 'user' => EnvVars::RABBITMQ_USER->loadFromEnv(), 'password' => EnvVars::RABBITMQ_PASSWORD->loadFromEnv(), 'vhost' => EnvVars::RABBITMQ_VHOST->loadFromEnv('/'), - - // Deprecated - 'legacy_visits_publishing' => (bool) EnvVars::RABBITMQ_LEGACY_VISITS_PUBLISHING->loadFromEnv(false), ], ]; diff --git a/config/autoload/rabbit.local.php.dist b/config/autoload/rabbit.local.php.dist index 83cd4a88..b758528e 100644 --- a/config/autoload/rabbit.local.php.dist +++ b/config/autoload/rabbit.local.php.dist @@ -7,6 +7,7 @@ return [ 'rabbitmq' => [ 'enabled' => true, 'host' => 'shlink_rabbitmq', + 'port' => '5673', 'user' => 'rabbit', 'password' => 'rabbit', ], diff --git a/config/autoload/request_id.global.php b/config/autoload/request_id.global.php deleted file mode 100644 index 5525849a..00000000 --- a/config/autoload/request_id.global.php +++ /dev/null @@ -1,44 +0,0 @@ - [ - 'allow_override' => true, - 'header_name' => 'X-Request-Id', - ], - - 'dependencies' => [ - 'factories' => [ - RequestId\Generator\RamseyUuid4StaticGenerator::class => InvokableFactory::class, - RequestId\RequestIdProviderFactory::class => ConfigAbstractFactory::class, - RequestId\RequestIdMiddleware::class => ConfigAbstractFactory::class, - RequestId\MonologProcessor::class => ConfigAbstractFactory::class, - ], - 'delegators' => [ - RequestId\MonologProcessor::class => [ - BackwardsCompatibleMonologProcessorDelegator::class, - ], - ], - ], - - ConfigAbstractFactory::class => [ - RequestId\RequestIdProviderFactory::class => [ - RequestId\Generator\RamseyUuid4StaticGenerator::class, - 'config.request_id.allow_override', - 'config.request_id.header_name', - ], - RequestId\RequestIdMiddleware::class => [ - RequestId\RequestIdProviderFactory::class, - 'config.request_id.header_name', - ], - RequestId\MonologProcessor::class => [RequestId\RequestIdMiddleware::class], - ], - -]; diff --git a/config/autoload/router.global.php b/config/autoload/router.global.php index 831a7523..d13bf7d4 100644 --- a/config/autoload/router.global.php +++ b/config/autoload/router.global.php @@ -11,7 +11,7 @@ return [ 'base_path' => EnvVars::BASE_PATH->loadFromEnv(''), 'fastroute' => [ - // Disabling config cache for cli, ensures it's never used for openswoole/RoadRunner, and also that console + // Disabling config cache for cli, ensures it's never used for RoadRunner, and also that console // commands don't generate a cache file that's then used by php-fpm web executions FastRouteRouter::CONFIG_CACHE_ENABLED => PHP_SAPI !== 'cli', FastRouteRouter::CONFIG_CACHE_FILE => 'data/cache/fastroute_cached_routes.php', diff --git a/config/autoload/routes.config.php b/config/autoload/routes.config.php index 051e18dd..6d072228 100644 --- a/config/autoload/routes.config.php +++ b/config/autoload/routes.config.php @@ -17,7 +17,6 @@ use Shlinkio\Shlink\Rest\Middleware\Mercure\NotConfiguredMercureErrorHandler; use function sprintf; return (static function (): array { - $contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class; $dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class; $overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class; @@ -32,9 +31,10 @@ return (static function (): array { ...ConfigProvider::applyRoutesPrefix([ Action\HealthAction::getRouteDef(), + // Visits and rules routes must go first, as they have a more specific path, otherwise, when + // multi-segment slugs are enabled, routes with a less-specific path might match first + // Visits. - // These routes must go first, as they have a more specific path, otherwise, when multi-segment slugs - // are enabled, routes with a less-specific path might match first Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), Action\ShortUrl\DeleteShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), Action\Visit\TagVisitsAction::getRouteDef(), @@ -44,15 +44,18 @@ return (static function (): array { Action\Visit\DeleteOrphanVisitsAction::getRouteDef(), Action\Visit\NonOrphanVisitsAction::getRouteDef(), + //Redirect rules + Action\RedirectRule\ListRedirectRulesAction::getRouteDef([$dropDomainMiddleware]), + Action\RedirectRule\SetRedirectRulesAction::getRouteDef([$dropDomainMiddleware]), + // Short URLs Action\ShortUrl\CreateShortUrlAction::getRouteDef([ - $contentNegotiationMiddleware, $dropDomainMiddleware, $overrideDomainMiddleware, Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class, ]), Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([ - $contentNegotiationMiddleware, + Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class, $overrideDomainMiddleware, ]), Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]), diff --git a/config/autoload/swoole.global.php b/config/autoload/swoole.global.php deleted file mode 100644 index 494e3cf2..00000000 --- a/config/autoload/swoole.global.php +++ /dev/null @@ -1,34 +0,0 @@ -loadFromEnv(16); - - return [ - - 'mezzio-swoole' => [ - // Setting this to true can have unexpected behaviors when running several concurrent slow DB queries - 'enable_coroutine' => false, - - 'swoole-http-server' => [ - 'host' => '0.0.0.0', - 'port' => (int) EnvVars::PORT->loadFromEnv(8080), - 'process-name' => 'shlink', - - 'options' => [ - ...getOpenswooleConfigFromEnv(), - 'worker_num' => (int) EnvVars::WEB_WORKER_NUM->loadFromEnv(16), - 'task_worker_num' => max($taskWorkers, MIN_TASK_WORKERS), - ], - ], - ], - - ]; -})(); diff --git a/config/autoload/swoole.local.php.dist b/config/autoload/swoole.local.php.dist deleted file mode 100644 index f30b3610..00000000 --- a/config/autoload/swoole.local.php.dist +++ /dev/null @@ -1,13 +0,0 @@ - [ - 'hot-code-reload' => [ - 'enable' => true, - ], - ], - -]; diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 2a121bee..43bd5a74 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -14,7 +14,7 @@ return (static function (): array { MIN_SHORT_CODES_LENGTH, ); $modeFromEnv = EnvVars::SHORT_URL_MODE->loadFromEnv(ShortUrlMode::STRICT->value); - $mode = ShortUrlMode::tryDeprecated($modeFromEnv) ?? ShortUrlMode::STRICT; + $mode = ShortUrlMode::tryFrom($modeFromEnv) ?? ShortUrlMode::STRICT; return [ @@ -24,7 +24,7 @@ return (static function (): array { 'hostname' => EnvVars::DEFAULT_DOMAIN->loadFromEnv(''), ], 'default_short_codes_length' => $shortCodesLength, - 'auto_resolve_titles' => (bool) EnvVars::AUTO_RESOLVE_TITLES->loadFromEnv(false), + 'auto_resolve_titles' => (bool) EnvVars::AUTO_RESOLVE_TITLES->loadFromEnv(true), 'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(false), 'multi_segment_slugs_enabled' => (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false), 'trailing_slash_enabled' => (bool) EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false), diff --git a/config/autoload/url-shortener.local.php.dist b/config/autoload/url-shortener.local.php.dist index 2d129625..715d2822 100644 --- a/config/autoload/url-shortener.local.php.dist +++ b/config/autoload/url-shortener.local.php.dist @@ -2,7 +2,6 @@ declare(strict_types=1); -use function Shlinkio\Shlink\Config\runningInOpenswoole; use function Shlinkio\Shlink\Config\runningInRoadRunner; return [ @@ -12,11 +11,9 @@ return [ 'schema' => 'http', 'hostname' => sprintf('localhost:%s', match (true) { runningInRoadRunner() => '8800', - runningInOpenswoole() => '8080', default => '8000', }), ], - 'auto_resolve_titles' => true, // 'multi_segment_slugs_enabled' => true, // 'trailing_slash_enabled' => true, ], diff --git a/config/autoload/webhooks.global.php b/config/autoload/webhooks.global.php deleted file mode 100644 index e72c4904..00000000 --- a/config/autoload/webhooks.global.php +++ /dev/null @@ -1,20 +0,0 @@ -loadFromEnv(); - - return [ - - 'visits_webhooks' => [ - 'webhooks' => $webhooks === null ? [] : explode(',', $webhooks), - 'notify_orphan_visits_to_webhooks' => - (bool) EnvVars::NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS->loadFromEnv(false), - ], - - ]; -})(); diff --git a/config/config.php b/config/config.php index a52ade5a..78fc542a 100644 --- a/config/config.php +++ b/config/config.php @@ -8,19 +8,12 @@ use Laminas\ConfigAggregator; use Laminas\Diactoros; use Mezzio; use Mezzio\ProblemDetails; -use Mezzio\Swoole; use Shlinkio\Shlink\Config\ConfigAggregator\EnvVarLoaderProvider; -use function class_exists; use function Shlinkio\Shlink\Config\env; -use function Shlinkio\Shlink\Config\openswooleIsInstalled; -use function Shlinkio\Shlink\Config\runningInRoadRunner; use function Shlinkio\Shlink\Core\enumValues; -use const PHP_SAPI; - $isTestEnv = env('APP_ENV') === 'test'; -$enableSwoole = PHP_SAPI === 'cli' && openswooleIsInstalled() && ! runningInRoadRunner(); return (new ConfigAggregator\ConfigAggregator( providers: [ @@ -30,9 +23,6 @@ return (new ConfigAggregator\ConfigAggregator( Mezzio\ConfigProvider::class, Mezzio\Router\ConfigProvider::class, Mezzio\Router\FastRouteRouter\ConfigProvider::class, - $enableSwoole && class_exists(Swoole\ConfigProvider::class) - ? Swoole\ConfigProvider::class - : new ConfigAggregator\ArrayProvider([]), ProblemDetails\ConfigProvider::class, Diactoros\ConfigProvider::class, Common\ConfigProvider::class, diff --git a/config/constants.php b/config/constants.php index f08c135c..51ee0476 100644 --- a/config/constants.php +++ b/config/constants.php @@ -9,7 +9,7 @@ use Shlinkio\Shlink\Core\Util\RedirectStatus; const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15; const DEFAULT_SHORT_CODES_LENGTH = 5; const MIN_SHORT_CODES_LENGTH = 4; -const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; // Deprecated. Default to 307 for Shlink v4 +const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; const DEFAULT_REDIRECT_CACHE_LIFETIME = 30; const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory'; const TITLE_TAG_VALUE = '/]*>(.*?)<\/title>/i'; // Matches the value inside a html title tag @@ -19,6 +19,6 @@ const DEFAULT_QR_CODE_MARGIN = 0; const DEFAULT_QR_CODE_FORMAT = 'png'; const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l'; const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true; -// Deprecated. Shlink 4.0.0 should change default value to `true` -const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = false; -const MIN_TASK_WORKERS = 4; +const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true; +const DEFAULT_QR_CODE_COLOR = '#000000'; // Black +const DEFAULT_QR_CODE_BG_COLOR = '#ffffff'; // White diff --git a/config/container.php b/config/container.php index e7574fe6..5d263173 100644 --- a/config/container.php +++ b/config/container.php @@ -12,17 +12,6 @@ chdir(dirname(__DIR__)); require 'vendor/autoload.php'; -// Workaround to make this compatible with both openswoole 22 and earlier versions. -// Openswoole support is deprecated. Remove in v4.0.0 -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())); diff --git a/config/roadrunner/.rr.test.yml b/config/roadrunner/.rr.test.yml new file mode 100644 index 00000000..f3e8bb78 --- /dev/null +++ b/config/roadrunner/.rr.test.yml @@ -0,0 +1,49 @@ +version: '3' + +############################################################################################ +# Routes here need to be relative to the project root, as API tests are run with `-w .` # +# See https://github.com/orgs/roadrunner-server/discussions/1440#discussioncomment-8486186 # +############################################################################################ + +rpc: + listen: tcp://127.0.0.1:6001 + +server: + command: 'php ./bin/roadrunner-worker.php' + +http: + address: '0.0.0.0:9999' + middleware: ['static'] + static: + dir: './public' + forbid: ['.php', '.htaccess'] + pool: + num_workers: 1 + debug: false + +jobs: + pool: + num_workers: 1 + debug: false + timeout: 300 + consume: ['shlink'] + pipelines: + shlink: + driver: memory + config: + priority: 10 + prefetch: 10 + +logs: + encoding: json + mode: development + channels: + http: + mode: 'off' # Disable logging as Shlink handles it internally + server: + encoding: json + level: info + metrics: + level: panic + jobs: + level: panic diff --git a/config/test/bootstrap_api_tests.php b/config/test/bootstrap_api_tests.php index b82e5bc6..8f757c05 100644 --- a/config/test/bootstrap_api_tests.php +++ b/config/test/bootstrap_api_tests.php @@ -7,12 +7,6 @@ namespace Shlinkio\Shlink\TestUtils; use Doctrine\ORM\EntityManager; use Psr\Container\ContainerInterface; -use function register_shutdown_function; -use function sprintf; - -use const ShlinkioTest\Shlink\API_TESTS_HOST; -use const ShlinkioTest\Shlink\API_TESTS_PORT; - /** @var ContainerInterface $container */ $container = require __DIR__ . '/../container.php'; $testHelper = $container->get(Helper\TestHelper::class); @@ -20,14 +14,6 @@ $config = $container->get('config'); $em = $container->get(EntityManager::class); $httpClient = $container->get('shlink_test_api_client'); -// Dump code coverage when process shuts down -register_shutdown_function(function () use ($httpClient): void { - $httpClient->request( - 'GET', - sprintf('http://%s:%s/api-tests/stop-coverage', API_TESTS_HOST, API_TESTS_PORT), - ); -}); - $testHelper->createTestDb( createDbCommand: ['bin/cli', 'db:create'], migrateDbCommand: ['bin/cli', 'db:migrate'], diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index 8ae64d7a..aad5e9d0 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -6,75 +6,38 @@ namespace Shlinkio\Shlink; use GuzzleHttp\Client; use Laminas\ConfigAggregator\ConfigAggregator; -use Laminas\Diactoros\Response\EmptyResponse; +use Laminas\Diactoros\Response\HtmlResponse; use Laminas\ServiceManager\Factory\InvokableFactory; -use League\Event\EventDispatcher; +use Mezzio\Router\FastRouteRouter; use Monolog\Level; -use PHPUnit\Runner\Version; -use Psr\Container\ContainerInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -use SebastianBergmann\CodeCoverage\CodeCoverage; -use SebastianBergmann\CodeCoverage\Driver\Selector; -use SebastianBergmann\CodeCoverage\Filter; -use SebastianBergmann\CodeCoverage\Report\Html\Facade as Html; -use SebastianBergmann\CodeCoverage\Report\PHP; -use SebastianBergmann\CodeCoverage\Report\Xml\Facade as Xml; use Shlinkio\Shlink\Common\Logger\LoggerType; +use Shlinkio\Shlink\TestUtils\ApiTest\CoverageMiddleware; +use Shlinkio\Shlink\TestUtils\CliTest\CliCoverageDelegator; +use Shlinkio\Shlink\TestUtils\Helper\CoverageHelper; use Symfony\Component\Console\Application; -use Symfony\Component\Console\Event\ConsoleCommandEvent; -use Symfony\Component\Console\Event\ConsoleTerminateEvent; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; -use function file_exists; use function Laminas\Stratigility\middleware; use function Shlinkio\Shlink\Config\env; -use function Shlinkio\Shlink\Core\ArrayUtils\contains; +use function sleep; use function sprintf; -use function sys_get_temp_dir; use const ShlinkioTest\Shlink\API_TESTS_HOST; use const ShlinkioTest\Shlink\API_TESTS_PORT; -$isApiTest = env('TEST_ENV') === 'api'; -$isCliTest = env('TEST_ENV') === 'cli'; +$testEnv = env('TEST_ENV'); +$isApiTest = $testEnv === 'api'; +$isCliTest = $testEnv === 'cli'; $isE2eTest = $isApiTest || $isCliTest; + $coverageType = env('GENERATE_COVERAGE'); -$generateCoverage = contains($coverageType, ['yes', 'pretty']); - -$coverage = null; -if ($isE2eTest && $generateCoverage) { - $filter = new Filter(); - $filter->includeDirectory(__DIR__ . '/../../module/Core/src'); - $filter->includeDirectory(__DIR__ . '/../../module/' . ($isApiTest ? 'Rest' : 'CLI') . '/src'); - $coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter); -} - -/** - * @param 'api'|'cli' $type - */ -$exportCoverage = static function (string $type = 'api') use (&$coverage, $coverageType): void { - if ($coverage === null) { - return; - } - - $basePath = __DIR__ . '/../../build/coverage-' . $type; - $covPath = $basePath . '.cov'; - - // Every CLI test runs on its own process and dumps the coverage afterwards. - // Try to load it and merge it, so that we end up with the whole coverage at the end. - if ($type === 'cli' && file_exists($covPath)) { - $coverage->merge(require $covPath); - } - - if ($coverageType === 'pretty') { - (new Html())->process($coverage, $basePath . '/coverage-html'); - } else { - (new PHP())->process($coverage, $covPath); - (new Xml(Version::getVersionString()))->process($coverage, $basePath . '/coverage-xml'); - } -}; +$generateCoverage = $coverageType === 'yes'; +$coverage = $isE2eTest && $generateCoverage ? CoverageHelper::createCoverageForDirectories( + [ + __DIR__ . '/../../module/Core/src', + __DIR__ . '/../../module/' . ($isApiTest ? 'Rest' : 'CLI') . '/src', + ], + __DIR__ . '/../../build/coverage-' . $testEnv, +) : null; $buildDbConnection = static function (): array { $driver = env('DB_DRIVER', 'sqlite'); @@ -89,7 +52,7 @@ $buildDbConnection = static function (): array { 'postgres' => [ 'driver' => 'pdo_pgsql', 'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres', - 'port' => $isCi ? '5433' : '5432', + 'port' => $isCi ? '5434' : '5432', 'user' => 'postgres', 'password' => 'root', 'dbname' => 'shlink_test', @@ -128,6 +91,7 @@ return [ 'debug' => true, ConfigAggregator::ENABLE_CACHE => false, + FastRouteRouter::CONFIG_CACHE_ENABLED => false, 'url_shortener' => [ 'domain' => [ @@ -136,52 +100,27 @@ return [ ], ], - 'mezzio-swoole' => [ - 'enable_coroutine' => false, - 'swoole-http-server' => [ - 'host' => API_TESTS_HOST, - 'port' => API_TESTS_PORT, - 'process-name' => 'shlink_test', - 'options' => [ - 'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid', - 'log_file' => __DIR__ . '/../../data/log/api-tests/output.log', - 'enable_coroutine' => false, - ], - ], - ], - - 'routes' => !$isApiTest ? [] : [ + 'routes' => [ + // This route is used to test that title resolution is skipped if the long URL times out [ - 'name' => 'dump_coverage', - 'path' => '/api-tests/stop-coverage', - 'middleware' => middleware(static function () use ($exportCoverage) { - // TODO I have tried moving this block to a listener so that it's invoked automatically, - // but then the coverage is generated empty ¯\_(ツ)_/¯ - $exportCoverage(); - return new EmptyResponse(); - }), + 'name' => 'long_url_with_timeout', + 'path' => '/api-tests/long-url-with-timeout', 'allowed_methods' => ['GET'], + 'middleware' => middleware(static function () { + sleep(5); // Title resolution times out at 3 seconds + return new HtmlResponse('The title'); + }), ], ], 'middleware_pipeline' => !$isApiTest ? [] : [ 'capture_code_coverage' => [ - 'middleware' => middleware(static function ( - ServerRequestInterface $req, - RequestHandlerInterface $handler, - ) use (&$coverage): ResponseInterface { - $coverage?->start($req->getHeaderLine('x-coverage-id')); - - try { - return $handler->handle($req); - } finally { - $coverage?->stop(); - } - }), + 'middleware' => new CoverageMiddleware($coverage), 'priority' => 9999, ], ], + // Disable mercure integration during E2E tests 'mercure' => [ 'public_hub_url' => null, 'internal_hub_url' => null, @@ -200,58 +139,7 @@ return [ ], 'delegators' => $isCliTest ? [ Application::class => [ - static function ( - ContainerInterface $c, - string $serviceName, - callable $callback, - ) use ( - &$coverage, - $exportCoverage, - ) { - /** @var Application $app */ - $app = $callback(); - $wrappedEventDispatcher = new EventDispatcher(); - - // When the command starts, start collecting coverage - $wrappedEventDispatcher->subscribeTo( - ConsoleCommandEvent::class, - static function () use (&$coverage): void { - $id = env('COVERAGE_ID'); - if ($id === null) { - return; - } - - $coverage?->start($id); - }, - ); - // When the command ends, stop collecting coverage - $wrappedEventDispatcher->subscribeTo( - ConsoleTerminateEvent::class, - static function () use (&$coverage, $exportCoverage): void { - $id = env('COVERAGE_ID'); - if ($id === null) { - return; - } - - $coverage?->stop(); - $exportCoverage('cli'); - }, - ); - - $app->setDispatcher(new class ($wrappedEventDispatcher) implements EventDispatcherInterface { - public function __construct(private EventDispatcher $wrappedDispatcher) - { - } - - public function dispatch(object $event, ?string $eventName = null): object - { - $this->wrappedDispatcher->dispatch($event); - return $event; - } - }); - - return $app; - }, + new CliCoverageDelegator($coverage), ], ] : [], ], @@ -262,7 +150,7 @@ return [ 'data_fixtures' => [ 'paths' => [ - // TODO These are used for CLI tests too, so maybe should be somewhere else + // TODO These are used for other module's tests, so maybe should be somewhere else __DIR__ . '/../../module/Rest/test-api/Fixtures', ], ], diff --git a/data/infra/examples/nginx-vhost.conf b/data/infra/examples/nginx-vhost.conf index 0cd3ff4b..b7a5d4fa 100644 --- a/data/infra/examples/nginx-vhost.conf +++ b/data/infra/examples/nginx-vhost.conf @@ -11,7 +11,7 @@ server { location ~ \.php$ { fastcgi_split_path_info ^(.+\.php)(/.+)$; - fastcgi_pass unix:/var/run/php/php8.2-fpm.sock; + fastcgi_pass unix:/var/run/php/php8.3-fpm.sock; fastcgi_index index.php; include fastcgi.conf; } diff --git a/data/infra/examples/shlink-daemon-logrotate.conf b/data/infra/examples/shlink-daemon-logrotate.conf deleted file mode 100644 index 2a11ed0b..00000000 --- a/data/infra/examples/shlink-daemon-logrotate.conf +++ /dev/null @@ -1,13 +0,0 @@ -/var/log/shlink/shlink_openswoole.log { - su root root - daily - missingok - rotate 120 - compress - delaycompress - notifempty - create 0640 root root - postrotate - /etc/init.d/shlink_openswoole restart - endscript -} diff --git a/data/infra/examples/shlink-daemon.sh b/data/infra/examples/shlink-daemon.sh deleted file mode 100644 index c32590f9..00000000 --- a/data/infra/examples/shlink-daemon.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash -### BEGIN INIT INFO -# Provides: shlink_openswoole -# Required-Start: $local_fs $network $named $time $syslog -# Required-Stop: $local_fs $network $named $time $syslog -# Default-Start: 2 3 4 5 -# Default-Stop: 0 1 6 -# Description: Shlink non-blocking server with openswoole -### END INIT INFO - -SCRIPT=/path/to/shlink/vendor/bin/laminas\ mezzio:swoole:start -RUNAS=root - -PIDFILE=/var/run/shlink_openswoole.pid -LOGDIR=/var/log/shlink -LOGFILE=${LOGDIR}/shlink_openswoole.log - -start() { - if [[ -f "$PIDFILE" ]] && kill -0 $(cat "$PIDFILE"); then - echo 'Shlink with openswoole already running' >&2 - return 1 - fi - echo 'Starting shlink with openswoole' >&2 - mkdir -p "$LOGDIR" - touch "$LOGFILE" - local CMD="$SCRIPT &> \"$LOGFILE\" & echo \$!" - su -c "$CMD" $RUNAS > "$PIDFILE" - echo 'Shlink started' >&2 -} - -stop() { - if [[ ! -f "$PIDFILE" ]] || ! kill -0 $(cat "$PIDFILE"); then - echo 'Shlink with openswoole not running' >&2 - return 1 - fi - echo 'Stopping shlink with openswoole' >&2 - kill -15 $(cat "$PIDFILE") && rm -f "$PIDFILE" - echo 'Shlink stopped' >&2 -} - -case "$1" in - start) - start - ;; - stop) - stop - ;; - restart) - stop - start - ;; - *) - echo "Usage: $0 {start|stop|restart}" -esac diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 14c99f95..20732e3f 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -1,8 +1,8 @@ -FROM php:8.2-fpm-alpine3.17 +FROM php:8.3-fpm-alpine3.19 MAINTAINER Alejandro Celaya -ENV APCU_VERSION 5.1.21 -ENV PDO_SQLSRV_VERSION 5.11.1 +ENV APCU_VERSION 5.1.23 +ENV PDO_SQLSRV_VERSION 5.12.0 ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 diff --git a/data/infra/roadrunner.Dockerfile b/data/infra/roadrunner.Dockerfile index 0e91d491..33768eda 100644 --- a/data/infra/roadrunner.Dockerfile +++ b/data/infra/roadrunner.Dockerfile @@ -1,8 +1,8 @@ -FROM php:8.2-alpine3.17 +FROM php:8.3-alpine3.19 MAINTAINER Alejandro Celaya -ENV APCU_VERSION 5.1.21 -ENV PDO_SQLSRV_VERSION 5.11.1 +ENV APCU_VERSION 5.1.23 +ENV PDO_SQLSRV_VERSION 5.12.0 ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile deleted file mode 100644 index 72536c75..00000000 --- a/data/infra/swoole.Dockerfile +++ /dev/null @@ -1,85 +0,0 @@ -FROM php:8.2-alpine3.17 -MAINTAINER Alejandro Celaya - -ENV APCU_VERSION 5.1.21 -ENV INOTIFY_VERSION 3.0.0 -ENV OPENSWOOLE_VERSION 22.1.0 -ENV PDO_SQLSRV_VERSION 5.11.1 -ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' -ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 - -RUN apk update - -# Install common php extensions -RUN docker-php-ext-install pdo_mysql -RUN docker-php-ext-install calendar - -RUN apk add --no-cache oniguruma-dev -RUN docker-php-ext-install mbstring - -RUN apk add --no-cache sqlite-libs -RUN apk add --no-cache sqlite-dev -RUN docker-php-ext-install pdo_sqlite - -RUN apk add --no-cache icu-dev -RUN docker-php-ext-install intl - -RUN apk add --no-cache libzip-dev zlib-dev -RUN docker-php-ext-install zip - -RUN apk add --no-cache libpng-dev -RUN docker-php-ext-install gd - -RUN apk add --no-cache postgresql-dev -RUN docker-php-ext-install pdo_pgsql - -RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \ - docker-php-ext-install sockets && \ - apk del .phpize-deps -RUN docker-php-ext-install bcmath - -# Install APCu extension -ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz -RUN mkdir -p /usr/src/php/ext/apcu \ - && tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \ - && docker-php-ext-configure apcu \ - && docker-php-ext-install apcu \ - && rm /tmp/apcu.tar.gz \ - && rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \ - && echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini - -# Install inotify extension -ADD https://pecl.php.net/get/inotify-$INOTIFY_VERSION.tgz /tmp/inotify.tar.gz -RUN mkdir -p /usr/src/php/ext/inotify \ - && tar xf /tmp/inotify.tar.gz -C /usr/src/php/ext/inotify --strip-components=1 \ - && docker-php-ext-configure inotify \ - && docker-php-ext-install inotify \ - && rm /tmp/inotify.tar.gz - -# Install openswoole, pcov and mssql driver -RUN wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ - apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ - apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ - pecl install openswoole-${OPENSWOOLE_VERSION} pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \ - docker-php-ext-enable openswoole pdo_sqlsrv pcov && \ - apk del .phpize-deps && \ - rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk - -# Install composer -COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer - -# Make home directory writable by anyone -RUN chmod 777 /home - -VOLUME /home/shlink -WORKDIR /home/shlink - -# Expose openswoole port -EXPOSE 8080 - -CMD \ - # Install dependencies if the vendor dir does not exist - if [[ ! -d "./vendor" ]]; then /usr/local/bin/composer install ; fi && \ - # When restarting the container, openswoole might think it is already in execution - # This forces the app to be started every second until the exit code is 0 - until php ./vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done diff --git a/data/infra/swoole_proxy_vhost.conf b/data/infra/swoole_proxy_vhost.conf deleted file mode 100644 index af31b1ea..00000000 --- a/data/infra/swoole_proxy_vhost.conf +++ /dev/null @@ -1,14 +0,0 @@ -server { - listen 80 default_server; - - error_log /home/shlink/www/data/infra/nginx/swoole_proxy.error.log; - - location / { - proxy_http_version 1.1; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_pass http://shlink_swoole:8080; - proxy_read_timeout 90s; - } -} diff --git a/docker-compose.override.yml.dist b/docker-compose.override.yml.dist index 1c5409c6..a3af3546 100644 --- a/docker-compose.override.yml.dist +++ b/docker-compose.override.yml.dist @@ -7,12 +7,6 @@ services: - /etc/passwd:/etc/passwd:ro - /etc/group:/etc/group:ro - shlink_swoole: - user: 1000:1000 - volumes: - - /etc/passwd:/etc/passwd:ro - - /etc/group:/etc/group:ro - shlink_roadrunner: user: 1000:1000 volumes: diff --git a/docker-compose.yml b/docker-compose.yml index f33693ad..ccc5fc2d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,44 +39,6 @@ services: extra_hosts: - 'host.docker.internal:host-gateway' - shlink_swoole_proxy: - container_name: shlink_swoole_proxy - image: nginx:1.25-alpine - ports: - - "8002:80" - volumes: - - ./:/home/shlink/www - - ./data/infra/swoole_proxy_vhost.conf:/etc/nginx/conf.d/default.conf - links: - - shlink_swoole - - shlink_swoole: - container_name: shlink_swoole - build: - context: . - dockerfile: ./data/infra/swoole.Dockerfile - ports: - - "8080:8080" - - "9001:9001" - volumes: - - ./:/home/shlink - - ./data/infra/php.ini:/usr/local/etc/php/php.ini - links: - - shlink_db_mysql - - shlink_db_postgres - - shlink_db_maria - - shlink_db_ms - - shlink_redis - - shlink_redis_acl - - shlink_mercure - - shlink_mercure_proxy - - shlink_rabbitmq - - shlink_matomo - environment: - LC_ALL: C - extra_hosts: - - 'host.docker.internal:host-gateway' - shlink_roadrunner: container_name: shlink_roadrunner build: @@ -119,7 +81,7 @@ services: container_name: shlink_db_postgres image: postgres:12.2-alpine ports: - - "5433:5432" + - "5434:5432" volumes: - ./:/home/shlink/www - ./data/infra/database_pg:/var/lib/postgresql/data @@ -169,7 +131,7 @@ services: container_name: shlink_mercure_proxy image: nginx:1.25-alpine ports: - - "8001:80" + - "8002:80" volumes: - ./:/home/shlink/www - ./data/infra/mercure_proxy_vhost.conf:/etc/nginx/conf.d/default.conf @@ -191,15 +153,15 @@ services: container_name: shlink_rabbitmq image: rabbitmq:3.11-management-alpine ports: - - "15672:15672" - - "5672:5672" + - "15673:15672" + - "5673:5672" environment: RABBITMQ_DEFAULT_USER: "rabbit" RABBITMQ_DEFAULT_PASS: "rabbit" shlink_swagger_ui: container_name: shlink_swagger_ui - image: swaggerapi/swagger-ui:v5.10.3 + image: swaggerapi/swagger-ui:v5.11.3 ports: - "8005:8080" volumes: diff --git a/docker/README.md b/docker/README.md index 13de359d..55bd8876 100644 --- a/docker/README.md +++ b/docker/README.md @@ -5,7 +5,7 @@ This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime. -It exposes a shlink instance served with [RoadRunner](https://roadrunner.dev) or [openswoole](https://openswoole.com/), which can be linked to external databases to persist data. +It exposes a shlink instance served with [RoadRunner](https://roadrunner.dev), which can be linked to external databases to persist data. ## Usage diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 6c95bee2..faa506a9 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -20,19 +20,6 @@ fi 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 -# FIXME: 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 & -fi - -if [ "$SHLINK_RUNTIME" = 'openswoole' ]; then - # Openswoole is deprecated. Remove in Shlink 4.0.0 - # When restarting the container, openswoole might think it is already in execution - # This forces the app to be started every second until the exit code is 0 - until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done -elif [ "$SHLINK_RUNTIME" = 'rr' ]; then +if [ "$SHLINK_RUNTIME" = 'rr' ]; then ./bin/rr serve -c config/roadrunner/.rr.yml fi diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json index d45dae2b..7cd838a8 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -111,9 +111,6 @@ "type": "string", "description": "The original long URL." }, - "deviceLongUrls": { - "$ref": "#/components/schemas/DeviceLongUrls" - }, "dateCreated": { "type": "string", "format": "date-time", @@ -122,11 +119,6 @@ "visitsSummary": { "$ref": "#/components/schemas/VisitsSummary" }, - "visitsCount": { - "deprecated": true, - "type": "integer", - "description": "The number of visits that this short URL has received." - }, "tags": { "type": "array", "items": { @@ -155,11 +147,6 @@ "shortCode": "12C18", "shortUrl": "https://s.test/12C18", "longUrl": "https://store.steampowered.com", - "deviceLongUrls": { - "android": "https://store.steampowered.com/android", - "ios": "https://store.steampowered.com/ios", - "desktop": null - }, "dateCreated": "2016-08-21T20:34:16+02:00", "visitsSummary": { "total": 328, @@ -223,24 +210,6 @@ } } }, - "DeviceLongUrls": { - "type": "object", - "required": ["android", "ios", "desktop"], - "properties": { - "android": { - "description": "The long URL to redirect to when the short URL is visited from a device running Android", - "type": "string" - }, - "ios": { - "description": "The long URL to redirect to when the short URL is visited from a device running iOS", - "type": "string" - }, - "desktop": { - "description": "The long URL to redirect to when the short URL is visited from a desktop browser", - "type": "string" - } - } - }, "Visit": { "type": "object", "properties": { diff --git a/docs/swagger/definitions/DeviceLongUrls.json b/docs/swagger/definitions/DeviceLongUrls.json deleted file mode 100644 index 0e8719db..00000000 --- a/docs/swagger/definitions/DeviceLongUrls.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "type": "object", - "properties": { - "android": { - "description": "The long URL to redirect to when the short URL is visited from a device running Android", - "type": ["string"] - }, - "ios": { - "description": "The long URL to redirect to when the short URL is visited from a device running iOS", - "type": ["string"] - }, - "desktop": { - "description": "The long URL to redirect to when the short URL is visited from a desktop browser", - "type": ["string"] - } - } -} diff --git a/docs/swagger/definitions/DeviceLongUrlsEdit.json b/docs/swagger/definitions/DeviceLongUrlsEdit.json deleted file mode 100644 index f1ff255f..00000000 --- a/docs/swagger/definitions/DeviceLongUrlsEdit.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "type": "object", - "allOf": [{ - "$ref": "./DeviceLongUrls.json" - }], - "properties": { - "android": { - "type": ["null"] - }, - "ios": { - "type": ["null"] - }, - "desktop": { - "type": ["null"] - } - } -} diff --git a/docs/swagger/definitions/DeviceLongUrlsResp.json b/docs/swagger/definitions/DeviceLongUrlsResp.json deleted file mode 100644 index 95724581..00000000 --- a/docs/swagger/definitions/DeviceLongUrlsResp.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "type": "object", - "required": ["android", "ios", "desktop"], - "allOf": [{ - "$ref": "./DeviceLongUrlsEdit.json" - }] -} diff --git a/docs/swagger/definitions/SetShortUrlRedirectRule.json b/docs/swagger/definitions/SetShortUrlRedirectRule.json new file mode 100644 index 00000000..fd794712 --- /dev/null +++ b/docs/swagger/definitions/SetShortUrlRedirectRule.json @@ -0,0 +1,31 @@ +{ + "type": "object", + "required": ["longUrl", "conditions"], + "properties": { + "longUrl": { + "description": "Long URL to redirect to when this condition matches", + "type": "string" + }, + "conditions": { + "description": "List of conditions that need to match in order to consider this rule matches", + "type": "array", + "items": { + "type": "object", + "required": ["type", "matchKey", "matchValue"], + "properties": { + "type": { + "type": "string", + "enum": ["device", "language", "query"], + "description": "The type of the condition, which will condition the logic used to match it" + }, + "matchKey": { + "type": ["string", "null"] + }, + "matchValue": { + "type": "string" + } + } + } + } + } +} diff --git a/docs/swagger/definitions/ShortUrl.json b/docs/swagger/definitions/ShortUrl.json index 98fd9c87..1535b65f 100644 --- a/docs/swagger/definitions/ShortUrl.json +++ b/docs/swagger/definitions/ShortUrl.json @@ -4,9 +4,7 @@ "shortCode", "shortUrl", "longUrl", - "deviceLongUrls", "dateCreated", - "visitsCount", "visitsSummary", "tags", "meta", @@ -28,19 +26,11 @@ "type": "string", "description": "The original long URL." }, - "deviceLongUrls": { - "$ref": "./DeviceLongUrlsResp.json" - }, "dateCreated": { "type": "string", "format": "date-time", "description": "The date in which the short URL was created in ISO format." }, - "visitsCount": { - "deprecated": true, - "type": "integer", - "description": "**[DEPRECATED]** Use `visitsSummary.total` instead." - }, "visitsSummary": { "$ref": "./VisitsSummary.json" }, diff --git a/docs/swagger/definitions/ShortUrlEdition.json b/docs/swagger/definitions/ShortUrlEdition.json index dda213ca..edd4c639 100644 --- a/docs/swagger/definitions/ShortUrlEdition.json +++ b/docs/swagger/definitions/ShortUrlEdition.json @@ -5,9 +5,6 @@ "description": "The long URL this short URL will redirect to", "type": "string" }, - "deviceLongUrls": { - "$ref": "./DeviceLongUrlsEdit.json" - }, "validSince": { "description": "The date (in ISO-8601 format) from which this short code will be valid", "type": ["string", "null"] @@ -20,11 +17,6 @@ "description": "The maximum number of allowed visits for this short code", "type": ["number", "null"] }, - "validateUrl": { - "deprecated": true, - "description": "**[DEPRECATED]** Tells if the long URL should or should not be validated as a reachable URL. Defaults to `false`", - "type": "boolean" - }, "tags": { "type": "array", "items": { diff --git a/docs/swagger/definitions/ShortUrlRedirectRule.json b/docs/swagger/definitions/ShortUrlRedirectRule.json new file mode 100644 index 00000000..40a478fd --- /dev/null +++ b/docs/swagger/definitions/ShortUrlRedirectRule.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "required": ["priority"], + "properties": { + "priority": { + "description": "Order in which attempting to match the rule. Lower goes first", + "type": "number" + } + }, + "allOf": [{ + "$ref": "./SetShortUrlRedirectRule.json" + }] +} diff --git a/docs/swagger/definitions/TagInfo.json b/docs/swagger/definitions/TagInfo.json index 41de1068..27658c95 100644 --- a/docs/swagger/definitions/TagInfo.json +++ b/docs/swagger/definitions/TagInfo.json @@ -1,6 +1,6 @@ { "type": "object", - "required": ["tag", "shortUrlsCount", "visitsSummary", "visitsCount"], + "required": ["tag", "shortUrlsCount", "visitsSummary"], "properties": { "tag": { "type": "string", @@ -12,11 +12,6 @@ }, "visitsSummary": { "$ref": "./VisitsSummary.json" - }, - "visitsCount": { - "deprecated": true, - "type": "number", - "description": "**[DEPRECATED]** Use visitsSummary.total instead" } } } diff --git a/docs/swagger/definitions/VisitStats.json b/docs/swagger/definitions/VisitStats.json index 2ed24375..a1d8ce19 100644 --- a/docs/swagger/definitions/VisitStats.json +++ b/docs/swagger/definitions/VisitStats.json @@ -1,22 +1,12 @@ { "type": "object", - "required": ["nonOrphanVisits", "orphanVisits", "visitsCount", "orphanVisitsCount"], + "required": ["nonOrphanVisits", "orphanVisits"], "properties": { "nonOrphanVisits": { "$ref": "./VisitsSummary.json" }, "orphanVisits": { "$ref": "./VisitsSummary.json" - }, - "visitsCount": { - "deprecated": true, - "type": "number", - "description": "**[DEPRECATED]** Use nonOrphanVisits.total instead" - }, - "orphanVisitsCount": { - "deprecated": true, - "type": "number", - "description": "**[DEPRECATED]** Use orphanVisits.total instead" } } } diff --git a/docs/swagger/examples/short-url-invalid-args-v2.json b/docs/swagger/examples/short-url-invalid-args-v2.json deleted file mode 100644 index d85a5eed..00000000 --- a/docs/swagger/examples/short-url-invalid-args-v2.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "value": { - "title": "Invalid data", - "type": "INVALID_ARGUMENT", - "detail": "Provided data is not valid", - "status": 400, - "invalidElements": ["maxVisits", "validSince"] - } -} diff --git a/docs/swagger/examples/short-url-not-found-v2.json b/docs/swagger/examples/short-url-not-found-v2.json deleted file mode 100644 index 4a58c847..00000000 --- a/docs/swagger/examples/short-url-not-found-v2.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "value": { - "detail": "No URL found with short code \"abc123\"", - "title": "Short URL not found", - "type": "INVALID_SHORTCODE", - "status": 404, - "shortCode": "abc123" - } -} diff --git a/docs/swagger/examples/tag-not-found-v2.json b/docs/swagger/examples/tag-not-found-v2.json deleted file mode 100644 index 46018121..00000000 --- a/docs/swagger/examples/tag-not-found-v2.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "value": { - "detail": "Tag with name \"foo\" could not be found", - "title": "Tag not found", - "type": "TAG_NOT_FOUND", - "status": 404, - "tag": "foo" - } -} diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index c226046f..7d172ff4 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -163,11 +163,6 @@ "shortCode": "12C18", "shortUrl": "https://s.test/12C18", "longUrl": "https://store.steampowered.com", - "deviceLongUrls": { - "android": null, - "ios": null, - "desktop": null - }, "dateCreated": "2016-08-21T20:34:16+02:00", "visitsSummary": { "total": 328, @@ -191,11 +186,6 @@ "shortCode": "12Kb3", "shortUrl": "https://s.test/12Kb3", "longUrl": "https://shlink.io", - "deviceLongUrls": { - "android": null, - "ios": "https://shlink.io/ios", - "desktop": null - }, "dateCreated": "2016-05-01T20:34:16+02:00", "visitsSummary": { "total": 1029, @@ -218,11 +208,6 @@ "shortCode": "123bA", "shortUrl": "https://example.com/123bA", "longUrl": "https://www.google.com", - "deviceLongUrls": { - "android": null, - "ios": null, - "desktop": null - }, "dateCreated": "2015-10-01T20:34:16+02:00", "visitsSummary": { "total": 25, @@ -296,13 +281,14 @@ "type": "object", "required": ["longUrl"], "properties": { - "deviceLongUrls": { - "$ref": "../definitions/DeviceLongUrls.json" - }, "customSlug": { "description": "A unique custom slug to be used instead of the generated short code", "type": "string" }, + "pathPrefix": { + "description": "A prefix that will be prepended to provided custom slug or auto-generated short code", + "type": "string" + }, "findIfExists": { "description": "Will force existing matching URL to be returned if found, instead of creating a new one", "type": "boolean" @@ -334,11 +320,6 @@ "shortCode": "12C18", "shortUrl": "https://s.test/12C18", "longUrl": "https://store.steampowered.com", - "deviceLongUrls": { - "android": null, - "ios": null, - "desktop": null - }, "dateCreated": "2016-08-21T20:34:16+02:00", "visitsSummary": { "total": 0, @@ -382,16 +363,13 @@ "validSince", "validUntil", "customSlug", + "pathPrefix", "maxVisits", "findIfExists", "domain" ] } }, - "url": { - "type": "string", - "description": "A URL that could not be verified, if the error type is https://shlink.io/api/error/invalid-url" - }, "customSlug": { "type": "string", "description": "Provided custom slug when the error type is https://shlink.io/api/error/non-unique-slug" @@ -405,19 +383,10 @@ ] }, "examples": { - "Invalid arguments with API v3 and newer": { + "Invalid arguments": { "$ref": "../examples/short-url-invalid-args-v3.json" }, - "Invalid long URL with API v3 and newer": { - "value": { - "title": "Invalid URL", - "type": "https://shlink.io/api/error/invalid-url", - "detail": "Provided URL foo is invalid. Try with a different one.", - "status": 400, - "url": "https://invalid-url.com" - } - }, - "Non-unique slug with API v3 and newer": { + "Non-unique slug": { "value": { "title": "Invalid custom slug", "type": "https://shlink.io/api/error/non-unique-slug", @@ -425,27 +394,6 @@ "status": 400, "customSlug": "my-slug" } - }, - "Invalid arguments previous to API v3": { - "$ref": "../examples/short-url-invalid-args-v2.json" - }, - "Invalid long URL previous to API v3": { - "value": { - "title": "Invalid URL", - "type": "INVALID_URL", - "detail": "Provided URL foo is invalid. Try with a different one.", - "status": 400, - "url": "https://invalid-url.com" - } - }, - "Non-unique slug previous to API v3": { - "value": { - "title": "Invalid custom slug", - "type": "INVALID_SLUG", - "detail": "Provided slug \"my-slug\" is already in use.", - "status": 400, - "customSlug": "my-slug" - } } } } diff --git a/docs/swagger/paths/v1_short-urls_shorten.json b/docs/swagger/paths/v1_short-urls_shorten.json index cacb00bb..1136aca1 100644 --- a/docs/swagger/paths/v1_short-urls_shorten.json +++ b/docs/swagger/paths/v1_short-urls_shorten.json @@ -53,11 +53,6 @@ }, "example": { "longUrl": "https://github.com/shlinkio/shlink", - "deviceLongUrls": { - "android": null, - "ios": null, - "desktop": null - }, "shortUrl": "https://s.test/abc123", "shortCode": "abc123", "dateCreated": "2016-08-21T20:34:16+02:00", @@ -88,49 +83,6 @@ } } }, - "400": { - "description": "The long URL was not provided or is invalid.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "../definitions/Error.json" - }, - "examples": { - "API v3 and newer": { - "value": { - "title": "Invalid URL", - "type": "https://shlink.io/api/error/invalid-url", - "detail": "Provided URL foo is invalid. Try with a different one.", - "status": 400, - "url": "https://invalid-url.com" - } - }, - "Previous to API v3": { - "value": { - "title": "Invalid URL", - "type": "INVALID_URL", - "detail": "Provided URL foo is invalid. Try with a different one.", - "status": 400, - "url": "https://invalid-url.com" - } - } - } - }, - "text/plain": { - "schema": { - "type": "string" - }, - "examples": { - "API v3 and newer": { - "value": "https://shlink.io/api/error/invalid-url" - }, - "Previous to API v3": { - "value": "INVALID_URL" - } - } - } - } - }, "default": { "description": "Unexpected error.", "content": { diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index 408d166c..c1a6eafc 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -34,11 +34,6 @@ "shortCode": "12Kb3", "shortUrl": "https://s.test/12Kb3", "longUrl": "https://shlink.io", - "deviceLongUrls": { - "android": null, - "ios": null, - "desktop": null - }, "dateCreated": "2016-05-01T20:34:16+02:00", "visitsSummary": { "total": 1029, @@ -86,11 +81,8 @@ ] }, "examples": { - "API v3 and newer": { + "Short URL not found": { "$ref": "../examples/short-url-not-found-v3.json" - }, - "Previous to API v3": { - "$ref": "../examples/short-url-not-found-v2.json" } } } @@ -155,11 +147,6 @@ "shortCode": "12Kb3", "shortUrl": "https://s.test/12Kb3", "longUrl": "https://shlink.io", - "deviceLongUrls": { - "android": "https://shlink.io/android", - "ios": null, - "desktop": null - }, "dateCreated": "2016-05-01T20:34:16+02:00", "visitsSummary": { "total": 1029, @@ -212,11 +199,8 @@ ] }, "examples": { - "API v3 and newer": { + "Invalid arguments": { "$ref": "../examples/short-url-invalid-args-v3.json" - }, - "Previous to API v3": { - "$ref": "../examples/short-url-invalid-args-v2.json" } } } @@ -248,11 +232,8 @@ ] }, "examples": { - "API v3 and newer": { + "Short URL not found": { "$ref": "../examples/short-url-not-found-v3.json" - }, - "Previous to API v3": { - "$ref": "../examples/short-url-not-found-v2.json" } } } @@ -378,11 +359,8 @@ ] }, "examples": { - "API v3 and newer": { + "Short URL not found": { "$ref": "../examples/short-url-not-found-v3.json" - }, - "Previous to API v3": { - "$ref": "../examples/short-url-not-found-v2.json" } } } diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json b/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json index 2f102711..71e70148 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json @@ -145,11 +145,8 @@ "$ref": "../definitions/Error.json" }, "examples": { - "Short URL not found with API v3 and newer": { + "Short URL not found": { "$ref": "../examples/short-url-not-found-v3.json" - }, - "Short URL not found previous to API v3": { - "$ref": "../examples/short-url-not-found-v2.json" } } } @@ -219,11 +216,8 @@ "$ref": "../definitions/Error.json" }, "examples": { - "Short URL not found with API v3 and newer": { + "Short URL not found": { "$ref": "../examples/short-url-not-found-v3.json" - }, - "Short URL not found previous to API v3": { - "$ref": "../examples/short-url-not-found-v2.json" } } } diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json index 0e77cf3c..752797c8 100644 --- a/docs/swagger/paths/v1_tags.json +++ b/docs/swagger/paths/v1_tags.json @@ -15,20 +15,6 @@ { "$ref": "../parameters/version.json" }, - { - "name": "withStats", - "deprecated": true, - "description": "**[Deprecated]** Use [GET /tags/stats](#/Tags/tagsWithStats) endpoint to get tags with their stats.", - "in": "query", - "required": false, - "schema": { - "type": "string", - "enum": [ - "true", - "false" - ] - } - }, { "name": "page", "in": "query", @@ -88,13 +74,6 @@ "type": "string" } }, - "stats": { - "description": "The tag stats will be returned only if the withStats param was provided with value 'true'", - "type": "array", - "items": { - "$ref": "../definitions/TagInfo.json" - } - }, "pagination": { "$ref": "../definitions/Pagination.json" } @@ -249,9 +228,6 @@ "examples": { "API v3 and newer": { "$ref": "../examples/tag-not-found-v3.json" - }, - "Previous to API v3": { - "$ref": "../examples/tag-not-found-v2.json" } } } diff --git a/docs/swagger/paths/v2_tags_{tag}_visits.json b/docs/swagger/paths/v2_tags_{tag}_visits.json index d40b7020..2a0148ec 100644 --- a/docs/swagger/paths/v2_tags_{tag}_visits.json +++ b/docs/swagger/paths/v2_tags_{tag}_visits.json @@ -148,12 +148,8 @@ "$ref": "../definitions/Error.json" }, "examples": { - - "API v3 and newer": { + "Tag not found": { "$ref": "../examples/tag-not-found-v3.json" - }, - "Previous to API v3": { - "$ref": "../examples/tag-not-found-v2.json" } } } diff --git a/docs/swagger/paths/v2_visits_orphan.json b/docs/swagger/paths/v2_visits_orphan.json index fe799934..df2ee0cd 100644 --- a/docs/swagger/paths/v2_visits_orphan.json +++ b/docs/swagger/paths/v2_visits_orphan.json @@ -55,6 +55,16 @@ "type": "string", "enum": ["true"] } + }, + { + "name": "type", + "in": "query", + "description": "The type of visits to return. All visits are returned when not provided.", + "required": false, + "schema": { + "type": "string", + "enum": ["invalid_short_url", "base_url", "regular_404"] + } } ], "security": [ @@ -137,6 +147,54 @@ } } }, + "400": { + "description": "Provided query arguments are invalid.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "allOf": [ + { + "$ref": "../definitions/Error.json" + }, + { + "type": "object", + "required": ["invalidElements"], + "properties": { + "invalidElements": { + "type": "array", + "items": { + "type": "string", + "enum": ["type"] + } + } + } + } + ] + }, + "examples": { + "API v3 and newer": { + "value": { + "title": "Invalid data", + "type": "https://shlink.io/api/error/invalid-data", + "detail": "Provided data is not valid", + "status": 400, + "invalidElements": ["type"] + } + }, + "Previous to API v3": { + "value": { + "title": "Invalid data", + "type": "INVALID_ARGUMENT", + "detail": "Provided data is not valid", + "status": 400, + "invalidElements": ["type"] + } + } + } + } + } + }, "default": { "description": "Unexpected error.", "content": { diff --git a/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json new file mode 100644 index 00000000..b87e26cb --- /dev/null +++ b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json @@ -0,0 +1,344 @@ +{ + "get": { + "operationId": "listShortUrlRedirectRules", + "tags": [ + "Redirect rules" + ], + "summary": "List short URL redirect rules", + "description": "Returns the list of redirect rules for a short URL.", + "parameters": [ + { + "$ref": "../parameters/version.json" + }, + { + "$ref": "../parameters/shortCode.json" + }, + { + "$ref": "../parameters/domain.json" + } + ], + "security": [ + { + "ApiKey": [] + } + ], + "responses": { + "200": { + "description": "The list of rules", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["defaultLongUrl", "redirectRules"], + "properties": { + "defaultLongUrl": { + "type": "string" + }, + "redirectRules": { + "type": "array", + "items": { + "$ref": "../definitions/ShortUrlRedirectRule.json" + } + } + } + }, + "example": { + "defaultLongUrl": "https://example.com", + "redirectRules": [ + { + "longUrl": "https://example.com/android-en-us", + "priority": 1, + "conditions": [ + { + "type": "device", + "matchValue": "android", + "matchKey": null + }, + { + "type": "language", + "matchValue": "en-US", + "matchKey": null + } + ] + }, + { + "longUrl": "https://example.com/fr", + "priority": 2, + "conditions": [ + { + "type": "language", + "matchValue": "fr", + "matchKey": null + } + ] + }, + { + "longUrl": "https://example.com/query-foo-bar-hello-world", + "priority": 3, + "conditions": [ + { + "type": "query", + "matchKey": "foo", + "matchValue": "bar" + }, + { + "type": "query", + "matchKey": "hello", + "matchValue": "world" + } + ] + } + ] + } + } + } + }, + "404": { + "description": "No URL was found for provided short code.", + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "../definitions/Error.json" + }, + { + "type": "object", + "required": ["shortCode"], + "properties": { + "shortCode": { + "type": "string", + "description": "The short code with which we tried to find the short URL" + }, + "domain": { + "type": "string", + "description": "The domain with which we tried to find the short URL" + } + } + } + ] + }, + "examples": { + "Short URL not found": { + "$ref": "../examples/short-url-not-found-v3.json" + } + } + } + } + }, + "default": { + "description": "Unexpected error.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + } + } + }, + + "post": { + "operationId": "setShortUrlRedirectRules", + "tags": [ + "Redirect rules" + ], + "summary": "Set short URL redirect rules", + "description": "Sets redirect rules for a short URL, with priorities matching the order in which they are provided.", + "parameters": [ + { + "$ref": "../parameters/version.json" + }, + { + "$ref": "../parameters/shortCode.json" + }, + { + "$ref": "../parameters/domain.json" + } + ], + "security": [ + { + "ApiKey": [] + } + ], + "requestBody": { + "description": "Request body.", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "redirectRules": { + "type": "array", + "items": { + "$ref": "../definitions/SetShortUrlRedirectRule.json" + } + } + } + }, + "example": { + "redirectRules": [ + { + "longUrl": "https://example.com/android-en-us", + "conditions": [ + { + "type": "device", + "matchValue": "android", + "matchKey": null + }, + { + "type": "language", + "matchValue": "en-US", + "matchKey": null + } + ] + }, + { + "longUrl": "https://example.com/fr", + "conditions": [ + { + "type": "language", + "matchValue": "fr", + "matchKey": null + } + ] + }, + { + "longUrl": "https://example.com/query-foo-bar-hello-world", + "conditions": [ + { + "type": "query", + "matchKey": "foo", + "matchValue": "bar" + }, + { + "type": "query", + "matchKey": "hello", + "matchValue": "world" + } + ] + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "The list of rules", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["defaultLongUrl", "redirectRules"], + "properties": { + "defaultLongUrl": { + "type": "string" + }, + "redirectRules": { + "type": "array", + "items": { + "$ref": "../definitions/ShortUrlRedirectRule.json" + } + } + } + }, + "example": { + "defaultLongUrl": "https://example.com", + "redirectRules": [ + { + "longUrl": "https://example.com/android-en-us", + "priority": 1, + "conditions": [ + { + "type": "device", + "matchValue": "android", + "matchKey": null + }, + { + "type": "language", + "matchValue": "en-US", + "matchKey": null + } + ] + }, + { + "longUrl": "https://example.com/fr", + "priority": 2, + "conditions": [ + { + "type": "language", + "matchValue": "fr", + "matchKey": null + } + ] + }, + { + "longUrl": "https://example.com/query-foo-bar-hello-world", + "priority": 3, + "conditions": [ + { + "type": "query", + "matchKey": "foo", + "matchValue": "bar" + }, + { + "type": "query", + "matchKey": "hello", + "matchValue": "world" + } + ] + } + ] + } + } + } + }, + "404": { + "description": "No URL was found for provided short code.", + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "../definitions/Error.json" + }, + { + "type": "object", + "required": ["shortCode"], + "properties": { + "shortCode": { + "type": "string", + "description": "The short code with which we tried to find the short URL" + }, + "domain": { + "type": "string", + "description": "The domain with which we tried to find the short URL" + } + } + } + ] + }, + "examples": { + "Short URL not found": { + "$ref": "../examples/short-url-not-found-v3.json" + } + } + } + } + }, + "default": { + "description": "Unexpected error.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + } + } + } +} diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 51655ecf..1b34b470 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -42,6 +42,10 @@ "name": "Short URLs", "description": "Operations that can be performed on short URLs" }, + { + "name": "Redirect rules", + "description": "Handle dynamic rule-based redirects" + }, { "name": "Tags", "description": "Let you handle the list of available tags" @@ -79,6 +83,10 @@ "$ref": "paths/v1_short-urls_{shortCode}.json" }, + "/rest/v{version}/short-urls/{shortCode}/redirect-rules": { + "$ref": "paths/v3_short-urls_{shortCode}_redirect-rules.json" + }, + "/rest/v{version}/tags": { "$ref": "paths/v1_tags.json" }, diff --git a/indocker b/indocker index 7cfbe2c3..f232b129 100755 --- a/indocker +++ b/indocker @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Run docker containers if they are not up yet -if ! [[ $(docker ps | grep shlink_swoole) ]]; then +if ! [[ $(docker ps | grep shlink_roadrunner) ]]; then docker compose up -d fi -docker exec -it shlink_swoole /bin/sh -c "$*" +docker exec -it shlink_roadrunner /bin/sh -c "$*" diff --git a/infection-api.json5 b/infection-api.json5 deleted file mode 100644 index e2cd08dc..00000000 --- a/infection-api.json5 +++ /dev/null @@ -1,24 +0,0 @@ -{ - source: { - directories: [ - 'module/*/src' - ] - }, - timeout: 5, - logs: { - text: 'build/infection-api/infection-log.txt', - html: 'build/infection-api/infection-log.html', - summary: 'build/infection-api/summary-log.txt', - debug: 'build/infection-api/debug-log.txt' - }, - tmpDir: 'build/infection-api/temp', - phpUnit: { - configDir: '.' - }, - testFrameworkOptions: '--configuration=phpunit-api.xml', - mutators: { - '@default': true, - IdenticalEqual: false, - NotIdenticalNotEqual: false - } -} diff --git a/infection-cli.json5 b/infection-cli.json5 deleted file mode 100644 index cc809fba..00000000 --- a/infection-cli.json5 +++ /dev/null @@ -1,24 +0,0 @@ -{ - source: { - directories: [ - 'module/*/src' - ] - }, - timeout: 5, - logs: { - text: 'build/infection-cli/infection-log.txt', - html: 'build/infection-cli/infection-log.html', - summary: 'build/infection-cli/summary-log.txt', - debug: 'build/infection-cli/debug-log.txt' - }, - tmpDir: 'build/infection-cli/temp', - phpUnit: { - configDir: '.' - }, - testFrameworkOptions: '--configuration=phpunit-cli.xml', - mutators: { - '@default': true, - IdenticalEqual: false, - NotIdenticalNotEqual: false - } -} diff --git a/infection-db.json5 b/infection-db.json5 deleted file mode 100644 index 1f484343..00000000 --- a/infection-db.json5 +++ /dev/null @@ -1,24 +0,0 @@ -{ - source: { - directories: [ - 'module/*/src' - ] - }, - timeout: 5, - logs: { - text: 'build/infection-db/infection-log.txt', - html: 'build/infection-db/infection-log.html', - summary: 'build/infection-db/summary-log.txt', - debug: 'build/infection-db/debug-log.txt' - }, - tmpDir: 'build/infection-db/temp', - phpUnit: { - configDir: '.' - }, - testFrameworkOptions: '--configuration=phpunit-db.xml', - mutators: { - '@default': true, - IdenticalEqual: false, - NotIdenticalNotEqual: false - } -} diff --git a/infection.json5 b/infection.json5 deleted file mode 100644 index 050a08e3..00000000 --- a/infection.json5 +++ /dev/null @@ -1,26 +0,0 @@ -{ - source: { - directories: [ - 'module/*/src' - ] - }, - timeout: 5, - logs: { - text: 'build/infection-unit/infection-log.txt', - html: 'build/infection-unit/infection-log.html', - summary: 'build/infection-unit/summary-log.txt', - debug: 'build/infection-unit/debug-log.txt', - stryker: { - report: 'develop' - } - }, - tmpDir: 'build/infection-unit/temp', - phpUnit: { - configDir: '.' - }, - mutators: { - '@default': true, - IdenticalEqual: false, - NotIdenticalNotEqual: false - } -} diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index bcd4fd3c..94237c15 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -37,6 +37,9 @@ return [ Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class, Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class, + + Command\RedirectRule\ManageRedirectRulesCommand::NAME => + Command\RedirectRule\ManageRedirectRulesCommand::class, ], ], diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 2736a21e..0c709788 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory; use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; +use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleService; use Shlinkio\Shlink\Core\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\Tag\TagService; @@ -33,6 +34,7 @@ return [ PhpExecutableFinder::class => InvokableFactory::class, GeoLite\GeolocationDbUpdater::class => ConfigAbstractFactory::class, + RedirectRule\RedirectRuleHandler::class => InvokableFactory::class, Util\ProcessRunner::class => ConfigAbstractFactory::class, ApiKey\RoleResolver::class => ConfigAbstractFactory::class, @@ -66,6 +68,8 @@ return [ Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class, Command\Domain\DomainRedirectsCommand::class => ConfigAbstractFactory::class, Command\Domain\GetDomainVisitsCommand::class => ConfigAbstractFactory::class, + + Command\RedirectRule\ManageRedirectRulesCommand::class => ConfigAbstractFactory::class, ], ], @@ -117,6 +121,12 @@ return [ Command\Domain\DomainRedirectsCommand::class => [DomainService::class], Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class], + Command\RedirectRule\ManageRedirectRulesCommand::class => [ + ShortUrl\ShortUrlResolver::class, + ShortUrlRedirectRuleService::class, + RedirectRule\RedirectRuleHandler::class, + ], + Command\Db\CreateDatabaseCommand::class => [ LockFactory::class, Util\ProcessRunner::class, diff --git a/module/CLI/src/Command/Api/DisableKeyCommand.php b/module/CLI/src/Command/Api/DisableKeyCommand.php index 4844121e..3da85e9e 100644 --- a/module/CLI/src/Command/Api/DisableKeyCommand.php +++ b/module/CLI/src/Command/Api/DisableKeyCommand.php @@ -31,7 +31,7 @@ class DisableKeyCommand extends Command ->addArgument('apiKey', InputArgument::REQUIRED, 'The API key to disable'); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $apiKey = $input->getArgument('apiKey'); $io = new SymfonyStyle($input, $output); diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index 1fe2f996..0a35bef7 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -98,7 +98,7 @@ class GenerateKeyCommand extends Command ->setHelp($help); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $expirationDate = $input->getOption('expiration-date'); diff --git a/module/CLI/src/Command/Api/InitialApiKeyCommand.php b/module/CLI/src/Command/Api/InitialApiKeyCommand.php index 1f5a1794..0f4945a9 100644 --- a/module/CLI/src/Command/Api/InitialApiKeyCommand.php +++ b/module/CLI/src/Command/Api/InitialApiKeyCommand.php @@ -29,7 +29,7 @@ class InitialApiKeyCommand extends Command ->addArgument('apiKey', InputArgument::REQUIRED, 'The initial API to create'); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $key = $input->getArgument('apiKey'); $result = $this->apiKeyService->createInitial($key); diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index b55dcd7d..fab02087 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -45,7 +45,7 @@ class ListKeysCommand extends Command ); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $enabledOnly = $input->getOption('enabled-only'); diff --git a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php index bf08e7f3..c2e5e60d 100644 --- a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php +++ b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php @@ -68,7 +68,7 @@ class DomainRedirectsCommand extends Command $input->setArgument('domain', str_contains($selectedOption, 'New domain') ? $askNewDomain() : $selectedOption); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $domainAuthority = $input->getArgument('domain'); diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php index 50107292..7e6b8cc3 100644 --- a/module/CLI/src/Command/Domain/ListDomainsCommand.php +++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php @@ -38,7 +38,7 @@ class ListDomainsCommand extends Command ); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $domains = $this->domainService->listDomains(); $showRedirects = $input->getOption('show-redirects'); diff --git a/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php b/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php new file mode 100644 index 00000000..13b6d1cc --- /dev/null +++ b/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php @@ -0,0 +1,66 @@ +shortUrlIdentifierInput = new ShortUrlIdentifierInput( + $this, + shortCodeDesc: 'The short code which rules we want to set.', + domainDesc: 'The domain for the short code.', + ); + } + + protected function configure(): void + { + $this + ->setName(self::NAME) + ->setDescription('Set redirect rules for a short URL'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); + + try { + $shortUrl = $this->shortUrlResolver->resolveShortUrl($identifier); + } catch (ShortUrlNotFoundException) { + $io->error(sprintf('Short URL for %s not found', $identifier->__toString())); + return ExitCode::EXIT_FAILURE; + } + + $rulesToSave = $this->ruleHandler->manageRules($io, $shortUrl, $this->ruleService->rulesForShortUrl($shortUrl)); + if ($rulesToSave !== null) { + $this->ruleService->saveRulesForShortUrl($shortUrl, $rulesToSave); + $io->success('Rules properly saved'); + } + + return ExitCode::EXIT_SUCCESS; + } +} diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index 64418aa6..4b6a088d 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; 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\Helper\ShortUrlStringifierInterface; @@ -71,6 +70,12 @@ class CreateShortUrlCommand extends Command InputOption::VALUE_REQUIRED, 'If provided, this slug will be used instead of generating a short code', ) + ->addOption( + 'path-prefix', + 'p', + InputOption::VALUE_REQUIRED, + 'Prefix to prepend before the generated short code or provided custom slug', + ) ->addOption( 'max-visits', 'm', @@ -95,12 +100,6 @@ class CreateShortUrlCommand extends Command InputOption::VALUE_REQUIRED, 'The length for generated short code (it will be ignored if --custom-slug was provided).', ) - ->addOption( - 'validate-url', - null, - InputOption::VALUE_NONE, - '[DEPRECATED] Makes the URL to be validated as publicly accessible.', - ) ->addOption( 'crawlable', 'r', @@ -134,7 +133,7 @@ class CreateShortUrlCommand extends Command } } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $io = $this->getIO($input, $output); $longUrl = $input->getArgument('longUrl'); @@ -145,22 +144,20 @@ class CreateShortUrlCommand extends Command $explodeWithComma = static fn (string $tag) => explode(',', $tag); $tags = array_unique(flatten(array_map($explodeWithComma, $input->getOption('tags')))); - $customSlug = $input->getOption('custom-slug'); $maxVisits = $input->getOption('max-visits'); $shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength; - $doValidateUrl = $input->getOption('validate-url'); try { $result = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([ ShortUrlInputFilter::LONG_URL => $longUrl, ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'), ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'), - ShortUrlInputFilter::CUSTOM_SLUG => $customSlug, ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null, + ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption('custom-slug'), + ShortUrlInputFilter::PATH_PREFIX => $input->getOption('path-prefix'), ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption('find-if-exists'), ShortUrlInputFilter::DOMAIN => $input->getOption('domain'), ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength, - ShortUrlInputFilter::VALIDATE_URL => $doValidateUrl, ShortUrlInputFilter::TAGS => $tags, ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'), ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'), @@ -176,7 +173,7 @@ class CreateShortUrlCommand extends Command sprintf('Generated short URL: %s', $this->stringifier->stringify($result->shortUrl)), ]); return ExitCode::EXIT_SUCCESS; - } catch (InvalidUrlException | NonUniqueSlugException $e) { + } catch (NonUniqueSlugException $e) { $io->error($e->getMessage()); return ExitCode::EXIT_FAILURE; } diff --git a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php index 11cfa270..63e9dab5 100644 --- a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php @@ -4,12 +4,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; +use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput; use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -21,9 +21,16 @@ class DeleteShortUrlCommand extends Command { public const NAME = 'short-url:delete'; + private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput; + public function __construct(private readonly DeleteShortUrlServiceInterface $deleteShortUrlService) { parent::__construct(); + $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput( + $this, + shortCodeDesc: 'The short code for the short URL to be deleted', + domainDesc: 'The domain if the short code does not belong to the default one', + ); } protected function configure(): void @@ -31,26 +38,19 @@ class DeleteShortUrlCommand extends Command $this ->setName(self::NAME) ->setDescription('Deletes a short URL') - ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code for the short URL to be deleted') ->addOption( 'ignore-threshold', 'i', InputOption::VALUE_NONE, 'Ignores the safety visits threshold check, which could make short URLs with many visits to be ' . 'accidentally deleted', - ) - ->addOption( - 'domain', - 'd', - InputOption::VALUE_REQUIRED, - 'The domain if the short code does not belong to the default one', ); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $identifier = ShortUrlIdentifier::fromCli($input); + $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); $ignoreThreshold = $input->getOption('ignore-threshold'); try { diff --git a/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php index 6cd04bfe..a720e12d 100644 --- a/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php @@ -5,13 +5,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; use Shlinkio\Shlink\CLI\Command\Visit\AbstractDeleteVisitsCommand; +use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput; 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; @@ -20,32 +18,28 @@ class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand { public const NAME = 'short-url:visits-delete'; + private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput; + public function __construct(private readonly ShortUrlVisitsDeleterInterface $deleter) { parent::__construct(); + $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput( + $this, + shortCodeDesc: 'The short code for the short URL which visits will be deleted', + domainDesc: 'The domain if the short code does not belong to the default one', + ); } 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', - ); + ->setDescription('Deletes visits from a short URL'); } - protected function doExecute(InputInterface $input, SymfonyStyle $io): ?int + protected function doExecute(InputInterface $input, SymfonyStyle $io): int { - $identifier = ShortUrlIdentifier::fromCli($input); + $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); try { $result = $this->deleter->deleteShortUrlVisits($identifier); $io->success(sprintf('Successfully deleted %s visits', $result->affectedItems)); diff --git a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php index a6a4f31d..8a662209 100644 --- a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php @@ -5,14 +5,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand; +use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Util\DateRange; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -20,18 +18,23 @@ class GetShortUrlVisitsCommand extends AbstractVisitsListCommand { public const NAME = 'short-url:visits'; + private ShortUrlIdentifierInput $shortUrlIdentifierInput; + protected function configure(): void { $this ->setName(self::NAME) - ->setDescription('Returns the detailed visits information for provided short code') - ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get.') - ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code.'); + ->setDescription('Returns the detailed visits information for provided short code'); + $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput( + $this, + shortCodeDesc: 'The short code which visits we want to get.', + domainDesc: 'The domain for the short code.', + ); } protected function interact(InputInterface $input, OutputInterface $output): void { - $shortCode = $input->getArgument('shortCode'); + $shortCode = $this->shortUrlIdentifierInput->shortCode($input); if (! empty($shortCode)) { return; } @@ -45,7 +48,7 @@ class GetShortUrlVisitsCommand extends AbstractVisitsListCommand protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator { - $identifier = ShortUrlIdentifier::fromCli($input); + $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); return $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange)); } diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index c9497daf..a318e6e4 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -129,7 +129,7 @@ class ListShortUrlsCommand extends Command ); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); @@ -218,7 +218,7 @@ class ListShortUrlsCommand extends Command 'Short URL' => $pickProp('shortUrl'), 'Long URL' => $pickProp('longUrl'), 'Date created' => $pickProp('dateCreated'), - 'Visits count' => $pickProp('visitsCount'), + 'Visits count' => static fn (array $shortUrl) => $shortUrl['visitsSummary']->total, ]; if ($input->getOption('show-tags')) { $columnsMap['Tags'] = static fn (array $shortUrl): string => implode(', ', $shortUrl['tags']); diff --git a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php index aec0a843..0a207b68 100644 --- a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php @@ -4,14 +4,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; +use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput; 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; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -21,23 +19,28 @@ class ResolveUrlCommand extends Command { public const NAME = 'short-url:parse'; + private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput; + public function __construct(private readonly ShortUrlResolverInterface $urlResolver) { parent::__construct(); + $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput( + $this, + shortCodeDesc: 'The short code to parse', + domainDesc: 'The domain to which the short URL is attached.', + ); } protected function configure(): void { $this ->setName(self::NAME) - ->setDescription('Returns the long URL behind a short code') - ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code to parse') - ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain to which the short URL is attached.'); + ->setDescription('Returns the long URL behind a short code'); } protected function interact(InputInterface $input, OutputInterface $output): void { - $shortCode = $input->getArgument('shortCode'); + $shortCode = $this->shortUrlIdentifierInput->shortCode($input); if (! empty($shortCode)) { return; } @@ -49,12 +52,12 @@ class ResolveUrlCommand extends Command } } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); try { - $url = $this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromCli($input)); + $url = $this->urlResolver->resolveShortUrl($this->shortUrlIdentifierInput->toShortUrlIdentifier($input)); $output->writeln(sprintf('Long URL: %s', $url->getLongUrl())); return ExitCode::EXIT_SUCCESS; } catch (ShortUrlNotFoundException $e) { diff --git a/module/CLI/src/Command/Tag/DeleteTagsCommand.php b/module/CLI/src/Command/Tag/DeleteTagsCommand.php index 151c5892..cf05f1b5 100644 --- a/module/CLI/src/Command/Tag/DeleteTagsCommand.php +++ b/module/CLI/src/Command/Tag/DeleteTagsCommand.php @@ -34,7 +34,7 @@ class DeleteTagsCommand extends Command ); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $tagNames = $input->getOption('name'); diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index d56e4101..2efeac5c 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -31,7 +31,7 @@ class ListTagsCommand extends Command ->setDescription('Lists existing tags.'); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { ShlinkTable::default($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows()); return ExitCode::EXIT_SUCCESS; diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index 1da3b983..fdc0f0ce 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -33,7 +33,7 @@ class RenameTagCommand extends Command ->addArgument('newName', InputArgument::REQUIRED, 'New name of the tag.'); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $oldName = $input->getArgument('oldName'); diff --git a/module/CLI/src/Command/Util/AbstractLockedCommand.php b/module/CLI/src/Command/Util/AbstractLockedCommand.php index ae930496..8bd728cd 100644 --- a/module/CLI/src/Command/Util/AbstractLockedCommand.php +++ b/module/CLI/src/Command/Util/AbstractLockedCommand.php @@ -19,7 +19,7 @@ abstract class AbstractLockedCommand extends Command parent::__construct(); } - final protected function execute(InputInterface $input, OutputInterface $output): ?int + final protected function execute(InputInterface $input, OutputInterface $output): int { $lockConfig = $this->getLockConfig(); $lock = $this->locker->createLock($lockConfig->lockName, $lockConfig->ttl, $lockConfig->isBlocking); diff --git a/module/CLI/src/Command/Visit/AbstractDeleteVisitsCommand.php b/module/CLI/src/Command/Visit/AbstractDeleteVisitsCommand.php index f171d59a..7cb32698 100644 --- a/module/CLI/src/Command/Visit/AbstractDeleteVisitsCommand.php +++ b/module/CLI/src/Command/Visit/AbstractDeleteVisitsCommand.php @@ -12,7 +12,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; abstract class AbstractDeleteVisitsCommand extends Command { - final protected function execute(InputInterface $input, OutputInterface $output): ?int + final protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); if (! $this->confirm($io)) { @@ -29,7 +29,7 @@ abstract class AbstractDeleteVisitsCommand extends Command return $io->confirm('Continue deleting visits?', false); } - abstract protected function doExecute(InputInterface $input, SymfonyStyle $io): ?int; + abstract protected function doExecute(InputInterface $input, SymfonyStyle $io): int; abstract protected function getWarningMessage(): string; } diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php index a15eb5e7..bd20a4ae 100644 --- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php +++ b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php @@ -34,7 +34,7 @@ abstract class AbstractVisitsListCommand extends Command $this->endDateOption = new EndDateOption($this, 'visits'); } - final protected function execute(InputInterface $input, OutputInterface $output): ?int + final protected function execute(InputInterface $input, OutputInterface $output): int { $startDate = $this->startDateOption->get($input, $output); $endDate = $this->endDateOption->get($input, $output); diff --git a/module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php index af1b7c66..2b34ae52 100644 --- a/module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php @@ -27,7 +27,7 @@ class DeleteOrphanVisitsCommand extends AbstractDeleteVisitsCommand ->setDescription('Deletes all orphan visits'); } - protected function doExecute(InputInterface $input, SymfonyStyle $io): ?int + protected function doExecute(InputInterface $input, SymfonyStyle $io): int { $result = $this->deleter->deleteOrphanVisits(); $io->success(sprintf('Successfully deleted %s visits', $result->affectedItems)); diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php index 8da6c753..ac8ee102 100644 --- a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php +++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php @@ -37,7 +37,7 @@ class DownloadGeoLiteDbCommand extends Command ); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); diff --git a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php index 618a35cd..7beae19a 100644 --- a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php @@ -7,8 +7,13 @@ namespace Shlinkio\Shlink\CLI\Command\Visit; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Visit\Entity\Visit; -use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; + +use function Shlinkio\Shlink\Core\enumToString; +use function sprintf; class GetOrphanVisitsCommand extends AbstractVisitsListCommand { @@ -18,12 +23,18 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand { $this ->setName(self::NAME) - ->setDescription('Returns the list of orphan visits.'); + ->setDescription('Returns the list of orphan visits.') + ->addOption('type', 't', InputOption::VALUE_REQUIRED, sprintf( + 'Return visits only with this type. One of %s', + enumToString(OrphanVisitType::class), + )); } protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator { - return $this->visitsHelper->orphanVisits(new VisitsParams($dateRange)); + $rawType = $input->getOption('type'); + $type = $rawType !== null ? OrphanVisitType::from($rawType) : null; + return $this->visitsHelper->orphanVisits(new OrphanVisitsParams(dateRange: $dateRange, type: $type)); } /** diff --git a/module/CLI/src/Input/DateOption.php b/module/CLI/src/Input/DateOption.php index 41407d23..6183a6c5 100644 --- a/module/CLI/src/Input/DateOption.php +++ b/module/CLI/src/Input/DateOption.php @@ -14,14 +14,10 @@ use Throwable; use function is_string; use function sprintf; -class DateOption +readonly class DateOption { - public function __construct( - private readonly Command $command, - private readonly string $name, - string $shortcut, - string $description, - ) { + public function __construct(private Command $command, private string $name, string $shortcut, string $description) + { $command->addOption($name, $shortcut, InputOption::VALUE_REQUIRED, $description); } diff --git a/module/CLI/src/Input/EndDateOption.php b/module/CLI/src/Input/EndDateOption.php index 000a135e..8e6df28a 100644 --- a/module/CLI/src/Input/EndDateOption.php +++ b/module/CLI/src/Input/EndDateOption.php @@ -11,9 +11,9 @@ use Symfony\Component\Console\Output\OutputInterface; use function sprintf; -class EndDateOption +readonly final class EndDateOption { - private readonly DateOption $dateOption; + private DateOption $dateOption; public function __construct(Command $command, string $descriptionHint) { diff --git a/module/CLI/src/Input/ShortUrlIdentifierInput.php b/module/CLI/src/Input/ShortUrlIdentifierInput.php new file mode 100644 index 00000000..c07de779 --- /dev/null +++ b/module/CLI/src/Input/ShortUrlIdentifierInput.php @@ -0,0 +1,34 @@ +addArgument('shortCode', InputArgument::REQUIRED, $shortCodeDesc) + ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, $domainDesc); + } + + public function shortCode(InputInterface $input): ?string + { + return $input->getArgument('shortCode'); + } + + public function toShortUrlIdentifier(InputInterface $input): ShortUrlIdentifier + { + $shortCode = $input->getArgument('shortCode'); + $domain = $input->getOption('domain'); + + return ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain); + } +} diff --git a/module/CLI/src/Input/StartDateOption.php b/module/CLI/src/Input/StartDateOption.php index 0954e82f..6a7857d7 100644 --- a/module/CLI/src/Input/StartDateOption.php +++ b/module/CLI/src/Input/StartDateOption.php @@ -11,9 +11,9 @@ use Symfony\Component\Console\Output\OutputInterface; use function sprintf; -class StartDateOption +readonly final class StartDateOption { - private readonly DateOption $dateOption; + private DateOption $dateOption; public function __construct(Command $command, string $descriptionHint) { diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandler.php b/module/CLI/src/RedirectRule/RedirectRuleHandler.php new file mode 100644 index 00000000..068cdc74 --- /dev/null +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -0,0 +1,225 @@ +newLine(); + $io->text('// No rules found.'); + } else { + $listing = map( + $rules, + function (ShortUrlRedirectRule $rule, string|int|float $index) use ($amountOfRules): array { + $priority = ((int) $index) + 1; + $conditions = $rule->mapConditions(static fn (RedirectCondition $condition): string => sprintf( + '%s', + $condition->toHumanFriendly(), + )); + + return [ + str_pad((string) $priority, strlen((string) $amountOfRules), '0', STR_PAD_LEFT), + implode(' AND ', $conditions), + $rule->longUrl, + ]; + }, + ); + $io->table(['Priority', 'Conditions', 'Redirect to'], $listing); + } + + $action = RedirectRuleHandlerAction::from($io->choice( + 'What do you want to do next?', + enumValues(RedirectRuleHandlerAction::class), + RedirectRuleHandlerAction::SAVE->value, + )); + + return match ($action) { + RedirectRuleHandlerAction::ADD => $this->manageRules( + $io, + $shortUrl, + $this->addRule($shortUrl, $io, $rules), + ), + RedirectRuleHandlerAction::REMOVE => $this->manageRules($io, $shortUrl, $this->removeRule($io, $rules)), + RedirectRuleHandlerAction::RE_ARRANGE => $this->manageRules( + $io, + $shortUrl, + $this->reArrangeRule($io, $rules), + ), + RedirectRuleHandlerAction::SAVE => $rules, + RedirectRuleHandlerAction::DISCARD => null, + }; + } + + /** + * @param ShortUrlRedirectRule[] $currentRules + */ + private function addRule(ShortUrl $shortUrl, StyleInterface $io, array $currentRules): array + { + $higherPriority = count($currentRules); + $priority = $this->askPriority($io, $higherPriority + 1); + $longUrl = $this->askLongUrl($io); + $conditions = []; + + do { + $type = RedirectConditionType::from( + $io->choice('Type of the condition?', enumValues(RedirectConditionType::class)), + ); + $conditions[] = match ($type) { + RedirectConditionType::DEVICE => RedirectCondition::forDevice( + DeviceType::from($io->choice('Device to match?', enumValues(DeviceType::class))), + ), + RedirectConditionType::LANGUAGE => RedirectCondition::forLanguage( + $this->askMandatory('Language to match?', $io), + ), + RedirectConditionType::QUERY_PARAM => RedirectCondition::forQueryParam( + $this->askMandatory('Query param name?', $io), + $this->askOptional('Query param value?', $io), + ), + }; + + $continue = $io->confirm('Do you want to add another condition?'); + } while ($continue); + + $newRule = new ShortUrlRedirectRule($shortUrl, $priority, $longUrl, new ArrayCollection($conditions)); + $rulesBefore = array_slice($currentRules, 0, $priority - 1); + $rulesAfter = array_slice($currentRules, $priority - 1); + + return [...$rulesBefore, $newRule, ...$rulesAfter]; + } + + /** + * @param ShortUrlRedirectRule[] $currentRules + */ + private function removeRule(StyleInterface $io, array $currentRules): array + { + if (empty($currentRules)) { + $io->warning('There are no rules to remove'); + return $currentRules; + } + + $index = $this->askRule('What rule do you want to delete?', $io, $currentRules); + unset($currentRules[$index]); + return array_values($currentRules); + } + + /** + * @param ShortUrlRedirectRule[] $currentRules + */ + private function reArrangeRule(StyleInterface $io, array $currentRules): array + { + if (empty($currentRules)) { + $io->warning('There are no rules to re-arrange'); + return $currentRules; + } + + $oldIndex = $this->askRule('What rule do you want to re-arrange?', $io, $currentRules); + $newIndex = $this->askPriority($io, count($currentRules)) - 1; + + // Temporarily get rule from array and unset it + $rule = $currentRules[$oldIndex]; + unset($currentRules[$oldIndex]); + + // Reindex remaining rules + $currentRules = array_values($currentRules); + + $rulesBefore = array_slice($currentRules, 0, $newIndex); + $rulesAfter = array_slice($currentRules, $newIndex); + + return [...$rulesBefore, $rule, ...$rulesAfter]; + } + + /** + * @param ShortUrlRedirectRule[] $currentRules + */ + private function askRule(string $message, StyleInterface $io, array $currentRules): int + { + $choices = []; + foreach ($currentRules as $index => $rule) { + $priority = $index + 1; + $key = sprintf('%s - %s', $priority, $rule->longUrl); + $choices[$key] = $priority; + } + + $resp = $io->choice($message, array_flip($choices)); + return $choices[$resp] - 1; + } + + private function askPriority(StyleInterface $io, int $max): int + { + return $io->ask( + 'Rule priority (the lower the value, the higher the priority)', + (string) $max, + function (string $answer) use ($max): int { + if (! is_numeric($answer)) { + throw new InvalidArgumentException('The priority must be a numeric positive value'); + } + + $priority = (int) $answer; + return max(1, min($max, $priority)); + }, + ); + } + + private function askLongUrl(StyleInterface $io): string + { + return $io->ask( + 'Long URL to redirect when the rule matches', + validator: function (string $answer): string { + $validator = ShortUrlInputFilter::longUrlValidators(); + if (! $validator->isValid($answer)) { + throw new InvalidArgumentException(implode(', ', $validator->getMessages())); + } + + return $answer; + }, + ); + } + + private function askMandatory(string $message, StyleInterface $io): string + { + return $io->ask($message, validator: function (?string $answer): string { + if ($answer === null) { + throw new InvalidArgumentException('The value is mandatory'); + } + return trim($answer); + }); + } + + private function askOptional(string $message, StyleInterface $io): string + { + return $io->ask($message, validator: fn (?string $answer) => $answer === null ? '' : trim($answer)); + } +} diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandlerAction.php b/module/CLI/src/RedirectRule/RedirectRuleHandlerAction.php new file mode 100644 index 00000000..a3aff06d --- /dev/null +++ b/module/CLI/src/RedirectRule/RedirectRuleHandlerAction.php @@ -0,0 +1,12 @@ +exec([ManageRedirectRulesCommand::NAME, 'abc123'], [ + '0', // Add new rule + 'not-a-number', // Invalid priority + '1', // Valid priority, to continue execution + 'invalid-long-url', // Invalid long URL + 'https://example.com', // Valid long URL, to continue execution + '1', // Language condition type + '', // Invalid required language + 'es-ES', // Valid language, to continue execution + 'no', // Do not add more conditions + '4', // Discard changes + ]); + + self::assertStringContainsString('The priority must be a numeric positive value', $output); + self::assertStringContainsString('The input is not valid', $output); + self::assertStringContainsString('The value is mandatory', $output); + } +} diff --git a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php index cece20db..b4a5c840 100644 --- a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php @@ -10,7 +10,7 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Schema\AbstractSchemaManager; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; -use Doctrine\Persistence\Mapping\ClassMetadataFactory; +use Doctrine\ORM\Mapping\ClassMetadataFactory; use Exception; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -22,7 +22,7 @@ use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Lock\LockFactory; -use Symfony\Component\Lock\LockInterface; +use Symfony\Component\Lock\SharedLockInterface; use Symfony\Component\Process\PhpExecutableFinder; class CreateDatabaseCommandTest extends TestCase @@ -37,7 +37,7 @@ class CreateDatabaseCommandTest extends TestCase protected function setUp(): void { $locker = $this->createMock(LockFactory::class); - $lock = $this->createMock(LockInterface::class); + $lock = $this->createMock(SharedLockInterface::class); $lock->method('acquire')->withAnyParameters()->willReturn(true); $locker->method('createLock')->withAnyParameters()->willReturn($lock); diff --git a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php index ac4283d7..29932202 100644 --- a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php @@ -13,7 +13,7 @@ use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Lock\LockFactory; -use Symfony\Component\Lock\LockInterface; +use Symfony\Component\Lock\SharedLockInterface; use Symfony\Component\Process\PhpExecutableFinder; class MigrateDatabaseCommandTest extends TestCase @@ -24,7 +24,7 @@ class MigrateDatabaseCommandTest extends TestCase protected function setUp(): void { $locker = $this->createMock(LockFactory::class); - $lock = $this->createMock(LockInterface::class); + $lock = $this->createMock(SharedLockInterface::class); $lock->method('acquire')->withAnyParameters()->willReturn(true); $locker->method('createLock')->withAnyParameters()->willReturn($lock); diff --git a/module/CLI/test/Command/RedirectRule/ManageRedirectRulesCommandTest.php b/module/CLI/test/Command/RedirectRule/ManageRedirectRulesCommandTest.php new file mode 100644 index 00000000..79859d23 --- /dev/null +++ b/module/CLI/test/Command/RedirectRule/ManageRedirectRulesCommandTest.php @@ -0,0 +1,95 @@ +shortUrlResolver = $this->createMock(ShortUrlResolverInterface::class); + $this->ruleService = $this->createMock(ShortUrlRedirectRuleServiceInterface::class); + $this->ruleHandler = $this->createMock(RedirectRuleHandlerInterface::class); + + $this->commandTester = CliTestUtils::testerForCommand(new ManageRedirectRulesCommand( + $this->shortUrlResolver, + $this->ruleService, + $this->ruleHandler, + )); + } + + #[Test] + public function errorIsReturnedIfShortUrlCannotBeFound(): void + { + $this->shortUrlResolver->expects($this->once())->method('resolveShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain('foo'), + )->willThrowException(new ShortUrlNotFoundException('')); + $this->ruleService->expects($this->never())->method('rulesForShortUrl'); + $this->ruleService->expects($this->never())->method('saveRulesForShortUrl'); + $this->ruleHandler->expects($this->never())->method('manageRules'); + + $exitCode = $this->commandTester->execute(['shortCode' => 'foo']); + $output = $this->commandTester->getDisplay(); + + self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode); + self::assertStringContainsString('Short URL for foo not found', $output); + } + + #[Test] + public function savesNoRulesIfManageResultIsNull(): void + { + $shortUrl = ShortUrl::withLongUrl('https://example.com'); + + $this->shortUrlResolver->expects($this->once())->method('resolveShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain('foo'), + )->willReturn($shortUrl); + $this->ruleService->expects($this->once())->method('rulesForShortUrl')->with($shortUrl)->willReturn([]); + $this->ruleHandler->expects($this->once())->method('manageRules')->willReturn(null); + $this->ruleService->expects($this->never())->method('saveRulesForShortUrl'); + + $exitCode = $this->commandTester->execute(['shortCode' => 'foo']); + $output = $this->commandTester->getDisplay(); + + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); + self::assertStringNotContainsString('Rules properly saved', $output); + } + + #[Test] + public function savesRulesIfManageResultIsAnArray(): void + { + $shortUrl = ShortUrl::withLongUrl('https://example.com'); + + $this->shortUrlResolver->expects($this->once())->method('resolveShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain('foo'), + )->willReturn($shortUrl); + $this->ruleService->expects($this->once())->method('rulesForShortUrl')->with($shortUrl)->willReturn([]); + $this->ruleHandler->expects($this->once())->method('manageRules')->willReturn([]); + $this->ruleService->expects($this->once())->method('saveRulesForShortUrl')->with($shortUrl, []); + + $exitCode = $this->commandTester->execute(['shortCode' => 'foo']); + $output = $this->commandTester->getDisplay(); + + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); + self::assertStringContainsString('Rules properly saved', $output); + } +} diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index de0fe26b..33031c6b 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -12,7 +12,6 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand; 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; @@ -68,22 +67,6 @@ class CreateShortUrlCommandTest extends TestCase self::assertStringNotContainsString('but the real-time updates cannot', $output); } - #[Test] - public function exceptionWhileParsingLongUrlOutputsError(): void - { - $url = 'http://domain.com/invalid'; - $this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willThrowException( - InvalidUrlException::fromUrl($url), - ); - $this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn(''); - - $this->commandTester->execute(['longUrl' => $url]); - $output = $this->commandTester->getDisplay(); - - self::assertEquals(ExitCode::EXIT_FAILURE, $this->commandTester->getStatusCode()); - self::assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output); - } - #[Test] public function providingNonUniqueSlugOutputsError(): void { @@ -148,12 +131,12 @@ class CreateShortUrlCommandTest extends TestCase } #[Test, DataProvider('provideFlags')] - public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedValidateUrl): void + public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedCrawlable): void { $shortUrl = ShortUrl::createFake(); $this->urlShortener->expects($this->once())->method('shorten')->with( - $this->callback(function (ShortUrlCreation $meta) use ($expectedValidateUrl) { - Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl()); + $this->callback(function (ShortUrlCreation $meta) use ($expectedCrawlable) { + Assert::assertEquals($expectedCrawlable, $meta->crawlable); return true; }), )->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl)); @@ -166,7 +149,7 @@ class CreateShortUrlCommandTest extends TestCase public static function provideFlags(): iterable { yield 'no flags' => [[], null]; - yield 'validate-url' => [['--validate-url' => true], true]; + yield 'crawlable' => [['--crawlable' => true], true]; } /** diff --git a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php index b90e6af6..a9e2a50c 100644 --- a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php @@ -6,12 +6,15 @@ namespace ShlinkioTest\Shlink\CLI\Command\Visit; use Pagerfanta\Adapter\ArrayAdapter; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\Visit\GetOrphanVisitsCommand; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; @@ -30,16 +33,20 @@ class GetOrphanVisitsCommandTest extends TestCase } #[Test] - public function outputIsProperlyGenerated(): void + #[TestWith([[], false])] + #[TestWith([['--type' => OrphanVisitType::BASE_URL->value], true])] + public function outputIsProperlyGenerated(array $args, bool $includesType): void { $visit = Visit::forBasePath(new Visitor('bar', 'foo', '', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); - $this->visitsHelper->expects($this->once())->method('orphanVisits')->withAnyParameters()->willReturn( - new Paginator(new ArrayAdapter([$visit])), - ); + $this->visitsHelper->expects($this->once())->method('orphanVisits')->with($this->callback( + fn (OrphanVisitsParams $param) => ( + ($includesType && $param->type !== null) || (!$includesType && $param->type === null) + ), + ))->willReturn(new Paginator(new ArrayAdapter([$visit]))); - $this->commandTester->execute([]); + $this->commandTester->execute($args); $output = $this->commandTester->getDisplay(); self::assertEquals( diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index 031e8e45..59c6b72f 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -46,7 +46,7 @@ class LocateVisitsCommandTest extends TestCase $this->visitToLocation = $this->createMock(VisitToLocationHelperInterface::class); $locker = $this->createMock(Lock\LockFactory::class); - $this->lock = $this->createMock(Lock\LockInterface::class); + $this->lock = $this->createMock(Lock\SharedLockInterface::class); $locker->method('createLock')->with($this->isType('string'), 600.0, false)->willReturn($this->lock); $command = new LocateVisitsCommand($this->visitService, $this->visitToLocation, $locker); diff --git a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php index 0f911db8..15db873e 100644 --- a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php +++ b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php @@ -34,7 +34,7 @@ class GeolocationDbUpdaterTest extends TestCase { $this->dbUpdater = $this->createMock(DbUpdaterInterface::class); $this->geoLiteDbReader = $this->createMock(Reader::class); - $this->lock = $this->createMock(Lock\LockInterface::class); + $this->lock = $this->createMock(Lock\SharedLockInterface::class); $this->lock->method('acquire')->with($this->isTrue())->willReturn(true); } diff --git a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php new file mode 100644 index 00000000..0c0b7d12 --- /dev/null +++ b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php @@ -0,0 +1,252 @@ +io = $this->createMock(StyleInterface::class); + $this->shortUrl = ShortUrl::withLongUrl('https://example.com'); + $this->cond1 = RedirectCondition::forLanguage('es-AR'); + $this->cond2 = RedirectCondition::forQueryParam('foo', 'bar'); + $this->cond3 = RedirectCondition::forDevice(DeviceType::ANDROID); + $this->rules = [ + new ShortUrlRedirectRule($this->shortUrl, 3, 'https://example.com/one', new ArrayCollection( + [$this->cond1], + )), + new ShortUrlRedirectRule($this->shortUrl, 8, 'https://example.com/two', new ArrayCollection( + [$this->cond2, $this->cond3], + )), + new ShortUrlRedirectRule($this->shortUrl, 5, 'https://example.com/three', new ArrayCollection( + [$this->cond1, $this->cond3], + )), + ]; + + $this->handler = new RedirectRuleHandler(); + } + + #[Test, DataProvider('provideExitActions')] + public function commentIsDisplayedWhenRulesListIsEmpty( + RedirectRuleHandlerAction $action, + ?array $expectedResult, + ): void { + $this->io->expects($this->once())->method('choice')->willReturn($action->value); + $this->io->expects($this->once())->method('newLine'); + $this->io->expects($this->once())->method('text')->with('// No rules found.'); + $this->io->expects($this->never())->method('table'); + + $result = $this->handler->manageRules($this->io, $this->shortUrl, []); + + self::assertEquals($expectedResult, $result); + } + + #[Test, DataProvider('provideExitActions')] + public function rulesAreDisplayedWhenRulesListIsEmpty( + RedirectRuleHandlerAction $action, + ): void { + $comment = fn (string $value) => sprintf('%s', $value); + + $this->io->expects($this->once())->method('choice')->willReturn($action->value); + $this->io->expects($this->never())->method('newLine'); + $this->io->expects($this->never())->method('text'); + $this->io->expects($this->once())->method('table')->with($this->isType('array'), [ + ['1', $comment($this->cond1->toHumanFriendly()), 'https://example.com/one'], + [ + '2', + $comment($this->cond2->toHumanFriendly()) . ' AND ' . $comment($this->cond3->toHumanFriendly()), + 'https://example.com/two', + ], + [ + '3', + $comment($this->cond1->toHumanFriendly()) . ' AND ' . $comment($this->cond3->toHumanFriendly()), + 'https://example.com/three', + ], + ]); + + $this->handler->manageRules($this->io, $this->shortUrl, $this->rules); + } + + public static function provideExitActions(): iterable + { + yield 'discard' => [RedirectRuleHandlerAction::DISCARD, null]; + yield 'save' => [RedirectRuleHandlerAction::SAVE, []]; + } + + #[Test, DataProvider('provideDeviceConditions')] + /** + * @param RedirectCondition[] $expectedConditions + */ + public function newRulesCanBeAdded( + RedirectConditionType $type, + array $expectedConditions, + bool $continue = false, + ): void { + $this->io->expects($this->any())->method('ask')->willReturnCallback( + fn (string $message): string|int => match ($message) { + 'Rule priority (the lower the value, the higher the priority)' => 2, // Add in between existing rules + 'Long URL to redirect when the rule matches' => 'https://example.com/new-two', + 'Language to match?' => 'en-US', + 'Query param name?' => 'foo', + 'Query param value?' => 'bar', + default => '', + }, + ); + $this->io->expects($this->any())->method('choice')->willReturnCallback( + function (string $message) use (&$callIndex, $type): string { + $callIndex++; + + if ($message === 'Type of the condition?') { + return $type->value; + } elseif ($message === 'Device to match?') { + return DeviceType::ANDROID->value; + } + + // First we select remove action to trigger code branch, then save to finish execution + $action = $callIndex === 1 ? RedirectRuleHandlerAction::ADD : RedirectRuleHandlerAction::SAVE; + return $action->value; + }, + ); + + $continueCallCount = 0; + $this->io->method('confirm')->willReturnCallback(function () use (&$continueCallCount, $continue) { + $continueCallCount++; + return $continueCallCount < 2 && $continue; + }); + + $result = $this->handler->manageRules($this->io, $this->shortUrl, $this->rules); + + self::assertEquals([ + $this->rules[0], + new ShortUrlRedirectRule($this->shortUrl, 2, 'https://example.com/new-two', new ArrayCollection( + $expectedConditions, + )), + $this->rules[1], + $this->rules[2], + ], $result); + } + + public static function provideDeviceConditions(): iterable + { + yield 'device' => [RedirectConditionType::DEVICE, [RedirectCondition::forDevice(DeviceType::ANDROID)]]; + yield 'language' => [RedirectConditionType::LANGUAGE, [RedirectCondition::forLanguage('en-US')]]; + yield 'query param' => [RedirectConditionType::QUERY_PARAM, [RedirectCondition::forQueryParam('foo', 'bar')]]; + yield 'multiple query params' => [ + RedirectConditionType::QUERY_PARAM, + [RedirectCondition::forQueryParam('foo', 'bar'), RedirectCondition::forQueryParam('foo', 'bar')], + true, + ]; + } + + #[Test] + public function existingRulesCanBeRemoved(): void + { + $callIndex = 0; + $this->io->expects($this->exactly(3))->method('choice')->willReturnCallback( + function (string $message) use (&$callIndex): string { + $callIndex++; + + if ($message === 'What rule do you want to delete?') { + return '2 - https://example.com/two'; // Second rule to be removed + } + + // First we select remove action to trigger code branch, then save to finish execution + $action = $callIndex === 1 ? RedirectRuleHandlerAction::REMOVE : RedirectRuleHandlerAction::SAVE; + return $action->value; + }, + ); + $this->io->expects($this->never())->method('warning'); + + $result = $this->handler->manageRules($this->io, $this->shortUrl, $this->rules); + + self::assertEquals([$this->rules[0], $this->rules[2]], $result); + } + + #[Test] + public function warningIsPrintedWhenTryingToRemoveRuleFromEmptyList(): void + { + $callIndex = 0; + $this->io->expects($this->exactly(2))->method('choice')->willReturnCallback( + function () use (&$callIndex): string { + $callIndex++; + $action = $callIndex === 1 ? RedirectRuleHandlerAction::REMOVE : RedirectRuleHandlerAction::DISCARD; + return $action->value; + }, + ); + $this->io->expects($this->once())->method('warning')->with('There are no rules to remove'); + + $this->handler->manageRules($this->io, $this->shortUrl, []); + } + + #[Test] + public function existingRulesCanBeReArranged(): void + { + $this->io->expects($this->any())->method('ask')->willReturnCallback( + fn (string $message): string|int => match ($message) { + 'Rule priority (the lower the value, the higher the priority)' => 1, + default => '', + }, + ); + $this->io->expects($this->exactly(3))->method('choice')->willReturnCallback( + function (string $message) use (&$callIndex): string { + $callIndex++; + + if ($message === 'What rule do you want to re-arrange?') { + return '2 - https://example.com/two'; // Second rule to be re-arrange + } + + // First we select remove action to trigger code branch, then save to finish execution + $action = $callIndex === 1 ? RedirectRuleHandlerAction::RE_ARRANGE : RedirectRuleHandlerAction::SAVE; + return $action->value; + }, + ); + $this->io->expects($this->never())->method('warning'); + + $result = $this->handler->manageRules($this->io, $this->shortUrl, $this->rules); + + self::assertEquals([$this->rules[1], $this->rules[0], $this->rules[2]], $result); + } + + #[Test] + public function warningIsPrintedWhenTryingToReArrangeRuleFromEmptyList(): void + { + $callIndex = 0; + $this->io->expects($this->exactly(2))->method('choice')->willReturnCallback( + function () use (&$callIndex): string { + $callIndex++; + $action = $callIndex === 1 ? RedirectRuleHandlerAction::RE_ARRANGE : RedirectRuleHandlerAction::DISCARD; + return $action->value; + }, + ); + $this->io->expects($this->once())->method('warning')->with('There are no rules to re-arrange'); + + $this->handler->manageRules($this->io, $this->shortUrl, []); + } +} diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 591fcc79..ed64a30e 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -31,7 +31,9 @@ return [ Options\TrackingOptions::class => [ValinorConfigFactory::class, 'config.tracking'], Options\QrCodeOptions::class => [ValinorConfigFactory::class, 'config.qr_codes'], Options\RabbitMqOptions::class => [ValinorConfigFactory::class, 'config.rabbitmq'], - Options\WebhookOptions::class => ConfigAbstractFactory::class, + + RedirectRule\ShortUrlRedirectRuleService::class => ConfigAbstractFactory::class, + RedirectRule\ShortUrlRedirectionResolver::class => ConfigAbstractFactory::class, ShortUrl\UrlShortener::class => ConfigAbstractFactory::class, ShortUrl\ShortUrlService::class => ConfigAbstractFactory::class, @@ -76,7 +78,6 @@ return [ Visit\Entity\Visit::class, ], - Util\UrlValidator::class => ConfigAbstractFactory::class, Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class, Util\RedirectResponseHelper::class => ConfigAbstractFactory::class, @@ -113,8 +114,6 @@ return [ Domain\DomainService::class, ], - Options\WebhookOptions::class => ['config.visits_webhooks'], - ShortUrl\UrlShortener::class => [ ShortUrl\Helper\ShortUrlTitleResolutionHelper::class, 'em', @@ -156,12 +155,14 @@ return [ ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Options\UrlShortenerOptions::class], Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'], - Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class], Util\DoctrineBatchHelper::class => ['em'], Util\RedirectResponseHelper::class => [Options\RedirectOptions::class], Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class, 'Logger_Shlink'], + RedirectRule\ShortUrlRedirectRuleService::class => ['em'], + RedirectRule\ShortUrlRedirectionResolver::class => [RedirectRule\ShortUrlRedirectRuleService::class], + Action\RedirectAction::class => [ ShortUrl\ShortUrlResolver::class, Visit\RequestTracker::class, @@ -183,8 +184,11 @@ return [ 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], + ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ['httpClient', Options\UrlShortenerOptions::class], + ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [ + Options\TrackingOptions::class, + RedirectRule\ShortUrlRedirectionResolver::class, + ], ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class], ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => [ ShortUrl\ShortUrlResolver::class, diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Domain.Entity.Domain.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Domain.Entity.Domain.php index 68427b42..ad77476a 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Domain.Entity.Domain.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Domain.Entity.Domain.php @@ -25,18 +25,21 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->unique() ->build(); - fieldWithUtf8Charset($builder->createField('baseUrlRedirect', Types::STRING), $emConfig) + fieldWithUtf8Charset($builder->createField('baseUrlRedirect', Types::TEXT), $emConfig) ->columnName('base_url_redirect') ->nullable() + ->length(2048) ->build(); - fieldWithUtf8Charset($builder->createField('regular404Redirect', Types::STRING), $emConfig) + fieldWithUtf8Charset($builder->createField('regular404Redirect', Types::TEXT), $emConfig) ->columnName('regular_not_found_redirect') ->nullable() + ->length(2048) ->build(); - fieldWithUtf8Charset($builder->createField('invalidShortUrlRedirect', Types::STRING), $emConfig) + fieldWithUtf8Charset($builder->createField('invalidShortUrlRedirect', Types::TEXT), $emConfig) ->columnName('invalid_short_url_redirect') ->nullable() + ->length(2048) ->build(); }; diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.RedirectCondition.php similarity index 54% rename from module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php rename to module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.RedirectCondition.php index 8de69c18..513089fa 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.RedirectCondition.php @@ -8,12 +8,12 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder; use Doctrine\ORM\Mapping\Builder\FieldBuilder; use Doctrine\ORM\Mapping\ClassMetadata; -use Shlinkio\Shlink\Core\Model\DeviceType; +use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; return static function (ClassMetadata $metadata, array $emConfig): void { $builder = new ClassMetadataBuilder($metadata); - $builder->setTable(determineTableName('device_long_urls', $emConfig)); + $builder->setTable(determineTableName('redirect_conditions', $emConfig)); $builder->createField('id', Types::BIGINT) ->columnName('id') @@ -23,19 +23,21 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->build(); (new FieldBuilder($builder, [ - 'fieldName' => 'deviceType', + 'fieldName' => 'type', 'type' => Types::STRING, - 'enumType' => DeviceType::class, - ]))->columnName('device_type') + 'enumType' => RedirectConditionType::class, + ]))->columnName('type') ->length(255) ->build(); - fieldWithUtf8Charset($builder->createField('longUrl', Types::STRING), $emConfig) - ->columnName('long_url') - ->length(2048) + fieldWithUtf8Charset($builder->createField('matchKey', Types::STRING), $emConfig) + ->columnName('match_key') + ->length(512) + ->nullable() ->build(); - $builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class) - ->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE') - ->build(); + fieldWithUtf8Charset($builder->createField('matchValue', Types::STRING), $emConfig) + ->columnName('match_value') + ->length(512) + ->build(); }; diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php new file mode 100644 index 00000000..eaedd590 --- /dev/null +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php @@ -0,0 +1,46 @@ +setTable(determineTableName('short_url_redirect_rules', $emConfig)); + + $builder->createField('id', Types::BIGINT) + ->columnName('id') + ->makePrimaryKey() + ->generatedValue('IDENTITY') + ->option('unsigned', true) + ->build(); + + $builder->createField('priority', Types::INTEGER) + ->columnName('priority') + ->build(); + + fieldWithUtf8Charset($builder->createField('longUrl', Types::TEXT), $emConfig) + ->columnName('long_url') + ->length(2048) + ->build(); + + $builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class) + ->addJoinColumn('short_url_id', 'id', nullable: false, onDelete: 'CASCADE') + ->build(); + + // We treat this ManyToMany relation as a unidirectional OneToMany, where conditions are persisted and deleted + // together with the rule + $builder->createManyToMany('conditions', RedirectRule\Entity\RedirectCondition::class) + ->setJoinTable(determineTableName('redirect_conditions_in_short_url_redirect_rules', $emConfig)) + ->addInverseJoinColumn('redirect_condition_id', 'id', onDelete: 'CASCADE') + ->addJoinColumn('short_url_redirect_rule_id', 'id', onDelete: 'CASCADE') + ->setOrderBy(['id' => 'ASC']) // Ensure a reliable order in the list of conditions + ->cascadePersist() // Create automatically with the rule + ->orphanRemoval() // Remove conditions when they are not linked to any rule + ->build(); +}; diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php index 746ac3fd..358ee6bd 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php @@ -23,7 +23,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->option('unsigned', true) ->build(); - fieldWithUtf8Charset($builder->createField('longUrl', Types::STRING), $emConfig) + fieldWithUtf8Charset($builder->createField('longUrl', Types::TEXT), $emConfig) ->columnName('original_url') // Rename to long_url some day? ¯\_(ツ)_/¯ ->length(2048) ->build(); @@ -67,27 +67,20 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->fetchExtraLazy() ->build(); - $builder->createOneToMany('deviceLongUrls', ShortUrl\Entity\DeviceLongUrl::class) - ->mappedBy('shortUrl') - ->cascadePersist() - ->orphanRemoval() - ->setIndexBy('deviceType') - ->build(); - $builder->createManyToMany('tags', Tag\Entity\Tag::class) ->setJoinTable(determineTableName('short_urls_in_tags', $emConfig)) - ->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE') - ->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE') + ->addInverseJoinColumn('tag_id', 'id', onDelete: 'CASCADE') + ->addJoinColumn('short_url_id', 'id', onDelete: 'CASCADE') ->setOrderBy(['name' => 'ASC']) ->build(); $builder->createManyToOne('domain', Domain\Entity\Domain::class) - ->addJoinColumn('domain_id', 'id', true, false, 'RESTRICT') + ->addJoinColumn('domain_id', 'id', onDelete: 'RESTRICT') ->cascadePersist() ->build(); $builder->createManyToOne('authorApiKey', ApiKey::class) - ->addJoinColumn('author_api_key_id', 'id', true, false, 'SET NULL') + ->addJoinColumn('author_api_key_id', 'id', onDelete: 'SET NULL') ->build(); $builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain'); diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php index 28adea80..7d402384 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php @@ -49,11 +49,11 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->build(); $builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class) - ->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE') + ->addJoinColumn('short_url_id', 'id', onDelete: 'CASCADE') ->build(); $builder->createManyToOne('visitLocation', Visit\Entity\VisitLocation::class) - ->addJoinColumn('visit_location_id', 'id', true, false, 'Set NULL') + ->addJoinColumn('visit_location_id', 'id', onDelete: 'Set NULL') ->cascadePersist() ->build(); diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index 1a81d8ed..8fc534d4 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -20,7 +20,6 @@ use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; -use function Shlinkio\Shlink\Config\runningInOpenswoole; use function Shlinkio\Shlink\Config\runningInRoadRunner; return (static function (): array { @@ -37,7 +36,6 @@ return (static function (): array { EventDispatcher\Mercure\NotifyVisitToMercure::class, EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class, EventDispatcher\RedisPubSub\NotifyVisitToRedis::class, - EventDispatcher\NotifyVisitToWebHooks::class, EventDispatcher\UpdateGeoLiteDb::class, ], EventDispatcher\Event\ShortUrlCreated::class => [ @@ -48,7 +46,7 @@ return (static function (): array { ]; // Send visits to matomo asynchronously if the runtime allows it - if (runningInRoadRunner() || runningInOpenswoole()) { + if (runningInRoadRunner()) { $asyncEvents[EventDispatcher\Event\VisitLocated::class][] = EventDispatcher\Matomo\SendVisitToMatomo::class; } else { $regularEvents[EventDispatcher\Event\VisitLocated::class] = [EventDispatcher\Matomo\SendVisitToMatomo::class]; @@ -66,7 +64,6 @@ return (static function (): array { EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, EventDispatcher\Matomo\SendVisitToMatomo::class => ConfigAbstractFactory::class, EventDispatcher\LocateUnlocatedVisits::class => ConfigAbstractFactory::class, - EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class, EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => ConfigAbstractFactory::class, EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class, @@ -104,9 +101,6 @@ return (static function (): array { EventDispatcher\LocateUnlocatedVisits::class => [ EventDispatcher\CloseDbConnectionEventListenerDelegator::class, ], - EventDispatcher\NotifyVisitToWebHooks::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, - ], ], ], @@ -119,14 +113,6 @@ return (static function (): array { EventDispatcherInterface::class, ], EventDispatcher\LocateUnlocatedVisits::class => [VisitLocator::class, VisitToLocationHelper::class], - EventDispatcher\NotifyVisitToWebHooks::class => [ - 'httpClient', - 'em', - 'Logger_Shlink', - Options\WebhookOptions::class, - ShortUrl\Transformer\ShortUrlDataTransformer::class, - Options\AppOptions::class, - ], EventDispatcher\Mercure\NotifyVisitToMercure::class => [ MercureHubPublishingHelper::class, EventDispatcher\PublishingUpdatesGenerator::class, @@ -144,7 +130,6 @@ return (static function (): array { EventDispatcher\PublishingUpdatesGenerator::class, 'em', 'Logger_Shlink', - Visit\Transformer\OrphanVisitDataTransformer::class, Options\RabbitMqOptions::class, ], EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [ @@ -187,7 +172,6 @@ return (static function (): array { Options\RabbitMqOptions::class, 'config.redis.pub_sub_enabled', MercureOptions::class, - Options\WebhookOptions::class, GeoLite2Options::class, MatomoOptions::class, ], diff --git a/module/Core/functions/array-utils.php b/module/Core/functions/array-utils.php index 5fb636e6..7b9ca7e5 100644 --- a/module/Core/functions/array-utils.php +++ b/module/Core/functions/array-utils.php @@ -72,3 +72,20 @@ function select_keys(array $array, array $keys): array ARRAY_FILTER_USE_KEY, ); } + +/** + * @template T + * @template R + * @param iterable $collection + * @param callable(T $value, string|number $key): R $callback + * @return R[] + */ +function map(iterable $collection, callable $callback): array +{ + $aggregation = []; + foreach ($collection as $key => $value) { + $aggregation[$key] = $callback($value, $key); + } + + return $aggregation; +} diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index d07bc9e2..e910833a 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -16,16 +16,22 @@ use PUGX\Shortid\Factory as ShortIdFactory; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; +use function array_filter; use function array_keys; use function array_map; +use function array_pad; use function array_reduce; use function date_default_timezone_get; +use function explode; +use function implode; use function is_array; use function print_r; use function Shlinkio\Shlink\Common\buildDateRange; use function sprintf; use function str_repeat; +use function str_replace; use function strtolower; +use function trim; use function ucfirst; function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string @@ -73,6 +79,38 @@ function normalizeDate(string|DateTimeInterface|Chronos $date): Chronos return normalizeOptionalDate($date); } +function normalizeLocale(string $locale): string +{ + return trim(strtolower(str_replace('_', '-', $locale))); +} + +/** + * @param non-empty-string $acceptLanguage + * @return string[]; + */ +function acceptLanguageToLocales(string $acceptLanguage): array +{ + $acceptLanguagesList = array_map(function (string $lang): string { + [$lang] = explode(';', $lang); // Discard everything after the semicolon (en-US;q=0.7) + return normalizeLocale($lang); + }, explode(',', $acceptLanguage)); + return array_filter($acceptLanguagesList, static fn (string $lang) => $lang !== '*'); +} + +/** + * Splits a locale into its corresponding language and country codes. + * The country code will be null if not present + * 'es-AR' -> ['es', 'AR'] + * 'fr-FR' -> ['fr', 'FR'] + * 'en' -> ['en', null] + * + * @return array{string, string|null} + */ +function splitLocale(string $locale): array +{ + return array_pad(explode('-', $locale), 2, null); +} + function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int { $value = $inputFilter->getValue($fieldName); @@ -182,3 +220,11 @@ function enumValues(string $enum): array $cache[$enum] = array_map(static fn (BackedEnum $type) => (string) $type->value, $enum::cases()) ); } + +/** + * @param class-string $enum + */ +function enumToString(string $enum): string +{ + return sprintf('["%s"]', implode('", "', enumValues($enum))); +} diff --git a/module/Core/migrations/Version20160819142757.php b/module/Core/migrations/Version20160819142757.php deleted file mode 100644 index aeb1eb16..00000000 --- a/module/Core/migrations/Version20160819142757.php +++ /dev/null @@ -1,49 +0,0 @@ -connection->getDatabasePlatform(); - $table = $schema->getTable('short_urls'); - $column = $table->getColumn('short_code'); - - match (true) { - is_subclass_of($platformClass, MySQLPlatform::class) => $column - ->setPlatformOption('charset', 'utf8mb4') - ->setPlatformOption('collation', 'utf8mb4_bin'), - is_subclass_of($platformClass, SqlitePlatform::class) => $column->setPlatformOption('collate', 'BINARY'), - default => null, - }; - } - - public function down(Schema $schema): void - { - // Nothing to roll back - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20160820191203.php b/module/Core/migrations/Version20160820191203.php deleted file mode 100644 index dea327b1..00000000 --- a/module/Core/migrations/Version20160820191203.php +++ /dev/null @@ -1,82 +0,0 @@ -getTables(); - foreach ($tables as $table) { - if ($table->getName() === 'tags') { - return; - } - } - - $this->createTagsTable($schema); - $this->createShortUrlsInTagsTable($schema); - } - - private function createTagsTable(Schema $schema): void - { - $table = $schema->createTable('tags'); - $table->addColumn('id', Types::BIGINT, [ - 'unsigned' => true, - 'autoincrement' => true, - 'notnull' => true, - ]); - $table->addColumn('name', Types::STRING, [ - 'length' => 255, - 'notnull' => true, - ]); - $table->addUniqueIndex(['name']); - - $table->setPrimaryKey(['id']); - } - - private function createShortUrlsInTagsTable(Schema $schema): void - { - $table = $schema->createTable('short_urls_in_tags'); - $table->addColumn('short_url_id', Types::BIGINT, [ - 'unsigned' => true, - 'notnull' => true, - ]); - $table->addColumn('tag_id', Types::BIGINT, [ - 'unsigned' => true, - 'notnull' => true, - ]); - - $table->addForeignKeyConstraint('tags', ['tag_id'], ['id'], [ - 'onDelete' => 'CASCADE', - 'onUpdate' => 'RESTRICT', - ]); - $table->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [ - 'onDelete' => 'CASCADE', - 'onUpdate' => 'RESTRICT', - ]); - - $table->setPrimaryKey(['short_url_id', 'tag_id']); - } - - public function down(Schema $schema): void - { - $schema->dropTable('short_urls_in_tags'); - $schema->dropTable('tags'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20171021093246.php b/module/Core/migrations/Version20171021093246.php deleted file mode 100644 index a810f49c..00000000 --- a/module/Core/migrations/Version20171021093246.php +++ /dev/null @@ -1,54 +0,0 @@ -getTable('short_urls'); - if ($shortUrls->hasColumn('valid_since')) { - return; - } - - $shortUrls->addColumn('valid_since', Types::DATETIME_MUTABLE, [ - 'notnull' => false, - ]); - $shortUrls->addColumn('valid_until', Types::DATETIME_MUTABLE, [ - 'notnull' => false, - ]); - } - - /** - * @throws SchemaException - */ - public function down(Schema $schema): void - { - $shortUrls = $schema->getTable('short_urls'); - if (! $shortUrls->hasColumn('valid_since')) { - return; - } - - $shortUrls->dropColumn('valid_since'); - $shortUrls->dropColumn('valid_until'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20171022064541.php b/module/Core/migrations/Version20171022064541.php deleted file mode 100644 index fb5f8d7a..00000000 --- a/module/Core/migrations/Version20171022064541.php +++ /dev/null @@ -1,51 +0,0 @@ -getTable('short_urls'); - if ($shortUrls->hasColumn('max_visits')) { - return; - } - - $shortUrls->addColumn('max_visits', Types::INTEGER, [ - 'unsigned' => true, - 'notnull' => false, - ]); - } - - /** - * @throws SchemaException - */ - public function down(Schema $schema): void - { - $shortUrls = $schema->getTable('short_urls'); - if (! $shortUrls->hasColumn('max_visits')) { - return; - } - - $shortUrls->dropColumn('max_visits'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20180801183328.php b/module/Core/migrations/Version20180801183328.php deleted file mode 100644 index 5fd40030..00000000 --- a/module/Core/migrations/Version20180801183328.php +++ /dev/null @@ -1,48 +0,0 @@ -setSize($schema, self::NEW_SIZE); - } - - /** - * @throws SchemaException - */ - public function down(Schema $schema): void - { - $this->setSize($schema, self::OLD_SIZE); - } - - /** - * @throws SchemaException - */ - private function setSize(Schema $schema, int $size): void - { - $schema->getTable('short_urls')->getColumn('short_code')->setLength($size); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20180913205455.php b/module/Core/migrations/Version20180913205455.php deleted file mode 100644 index fe04a395..00000000 --- a/module/Core/migrations/Version20180913205455.php +++ /dev/null @@ -1,75 +0,0 @@ -connection->createQueryBuilder(); - $qb->select('id', 'remote_addr') - ->from('visits'); - $st = $this->connection->executeQuery($qb->getSQL()); - - $qb = $this->connection->createQueryBuilder(); - $qb->update('visits', 'v') - ->set('v.remote_addr', ':obfuscatedAddr') - ->where('v.id=:id'); - - while ($row = $st->fetch(PDO::FETCH_ASSOC)) { - $addr = $row['remote_addr'] ?? null; - if ($addr === null) { - continue; - } - - $qb->setParameters([ - 'id' => $row['id'], - 'obfuscatedAddr' => $this->determineAddress((string) $addr), - ])->execute(); - } - } - - private function determineAddress(string $addr): ?string - { - if ($addr === IpAddress::LOCALHOST) { - return $addr; - } - - try { - return (string) IpAddress::fromString($addr)->getAnonymizedCopy(); - } catch (InvalidArgumentException) { - return null; - } - } - - public function down(Schema $schema): void - { - // Nothing to rollback - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20180915110857.php b/module/Core/migrations/Version20180915110857.php deleted file mode 100644 index b31ac105..00000000 --- a/module/Core/migrations/Version20180915110857.php +++ /dev/null @@ -1,56 +0,0 @@ - 'SET NULL', - 'short_urls' => 'CASCADE', - ]; - - /** - * @throws SchemaException - */ - public function up(Schema $schema): void - { - $visits = $schema->getTable('visits'); - $foreignKeys = $visits->getForeignKeys(); - - // Remove all existing foreign keys and add them again with CASCADE delete - foreach ($foreignKeys as $foreignKey) { - $visits->removeForeignKey($foreignKey->getName()); - $foreignTable = $foreignKey->getForeignTableName(); - - $visits->addForeignKeyConstraint( - $foreignTable, - $foreignKey->getLocalColumns(), - $foreignKey->getForeignColumns(), - [ - 'onDelete' => self::ON_DELETE_MAP[$foreignTable], - 'onUpdate' => 'RESTRICT', - ], - ); - } - } - - public function down(Schema $schema): void - { - // Nothing to run - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20181020060559.php b/module/Core/migrations/Version20181020060559.php deleted file mode 100644 index 908bf304..00000000 --- a/module/Core/migrations/Version20181020060559.php +++ /dev/null @@ -1,74 +0,0 @@ - 'country_code', - 'countryName' => 'country_name', - 'regionName' => 'region_name', - 'cityName' => 'city_name', - ]; - - /** - * @throws SchemaException - */ - public function up(Schema $schema): void - { - $this->createColumns($schema->getTable('visit_locations'), self::COLUMNS); - } - - private function createColumns(Table $visitLocations, array $columnNames): void - { - foreach ($columnNames as $name) { - if (! $visitLocations->hasColumn($name)) { - $visitLocations->addColumn($name, Types::STRING, ['notnull' => false]); - } - } - } - - /** - * @throws SchemaException - * @throws Exception - */ - public function postUp(Schema $schema): void - { - $visitLocations = $schema->getTable('visit_locations'); - - // If the camel case columns do not exist, do nothing - if (! $visitLocations->hasColumn('countryCode')) { - return; - } - - $qb = $this->connection->createQueryBuilder(); - $qb->update('visit_locations'); - foreach (self::COLUMNS as $camelCaseName => $snakeCaseName) { - $qb->set($snakeCaseName, $camelCaseName); - } - $qb->executeStatement(); - } - - public function down(Schema $schema): void - { - // No down - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20181020065148.php b/module/Core/migrations/Version20181020065148.php deleted file mode 100644 index 873e7f11..00000000 --- a/module/Core/migrations/Version20181020065148.php +++ /dev/null @@ -1,47 +0,0 @@ -getTable('visit_locations'); - - foreach (self::CAMEL_CASE_COLUMNS as $name) { - if ($visitLocations->hasColumn($name)) { - $visitLocations->dropColumn($name); - } - } - } - - public function down(Schema $schema): void - { - // No down - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20181110175521.php b/module/Core/migrations/Version20181110175521.php deleted file mode 100644 index 9fb989fa..00000000 --- a/module/Core/migrations/Version20181110175521.php +++ /dev/null @@ -1,43 +0,0 @@ -getUserAgentColumn($schema)->setLength(512); - } - - /** - * @throws SchemaException - */ - public function down(Schema $schema): void - { - $this->getUserAgentColumn($schema)->setLength(256); - } - - /** - * @throws SchemaException - */ - private function getUserAgentColumn(Schema $schema): Column - { - return $schema->getTable('visits')->getColumn('user_agent'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20190824075137.php b/module/Core/migrations/Version20190824075137.php deleted file mode 100644 index 663111ff..00000000 --- a/module/Core/migrations/Version20190824075137.php +++ /dev/null @@ -1,43 +0,0 @@ -getRefererColumn($schema)->setLength(1024); - } - - /** - * @throws SchemaException - */ - public function down(Schema $schema): void - { - $this->getRefererColumn($schema)->setLength(256); - } - - /** - * @throws SchemaException - */ - private function getRefererColumn(Schema $schema): Column - { - return $schema->getTable('visits')->getColumn('referer'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20190930165521.php b/module/Core/migrations/Version20190930165521.php deleted file mode 100644 index 97863843..00000000 --- a/module/Core/migrations/Version20190930165521.php +++ /dev/null @@ -1,61 +0,0 @@ -getTable('short_urls'); - if ($shortUrls->hasColumn('domain_id')) { - return; - } - - $domains = $schema->createTable('domains'); - $domains->addColumn('id', Types::BIGINT, [ - 'unsigned' => true, - 'autoincrement' => true, - 'notnull' => true, - ]); - $domains->addColumn('authority', Types::STRING, [ - 'length' => 512, - 'notnull' => true, - ]); - $domains->addUniqueIndex(['authority']); - $domains->setPrimaryKey(['id']); - - $shortUrls->addColumn('domain_id', Types::BIGINT, [ - 'unsigned' => true, - 'notnull' => false, - ]); - $shortUrls->addForeignKeyConstraint('domains', ['domain_id'], ['id'], [ - 'onDelete' => 'RESTRICT', - 'onUpdate' => 'RESTRICT', - ]); - } - - /** - * @throws SchemaException - */ - public function down(Schema $schema): void - { - $schema->getTable('short_urls')->dropColumn('domain_id'); - $schema->dropTable('domains'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20191001201532.php b/module/Core/migrations/Version20191001201532.php deleted file mode 100644 index fa13b85d..00000000 --- a/module/Core/migrations/Version20191001201532.php +++ /dev/null @@ -1,55 +0,0 @@ -getTable('short_urls'); - if ($shortUrls->hasIndex('unique_short_code_plus_domain')) { - return; - } - - /** @var Index|null $shortCodesIndex */ - $shortCodesIndex = array_reduce($shortUrls->getIndexes(), function (?Index $found, Index $current) { - [$column] = $current->getColumns(); - return $column === 'short_code' ? $current : $found; - }); - if ($shortCodesIndex === null) { - return; - } - - $shortUrls->dropIndex($shortCodesIndex->getName()); - $shortUrls->addUniqueIndex(['short_code', 'domain_id'], 'unique_short_code_plus_domain'); - } - - /** - * @throws SchemaException - */ - public function down(Schema $schema): void - { - $shortUrls = $schema->getTable('short_urls'); - - $shortUrls->dropIndex('unique_short_code_plus_domain'); - $shortUrls->addUniqueIndex(['short_code']); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20191020074522.php b/module/Core/migrations/Version20191020074522.php deleted file mode 100644 index c1b9aea9..00000000 --- a/module/Core/migrations/Version20191020074522.php +++ /dev/null @@ -1,43 +0,0 @@ -getOriginalUrlColumn($schema)->setLength(2048); - } - - /** - * @throws SchemaException - */ - public function down(Schema $schema): void - { - $this->getOriginalUrlColumn($schema)->setLength(1024); - } - - /** - * @throws SchemaException - */ - private function getOriginalUrlColumn(Schema $schema): Column - { - return $schema->getTable('short_urls')->getColumn('original_url'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20200105165647.php b/module/Core/migrations/Version20200105165647.php deleted file mode 100644 index 26f8cc0a..00000000 --- a/module/Core/migrations/Version20200105165647.php +++ /dev/null @@ -1,103 +0,0 @@ - 'latitude', 'lon' => 'longitude']; - - /** - * @throws Exception - */ - public function preUp(Schema $schema): void - { - $visitLocations = $schema->getTable('visit_locations'); - $this->skipIf(some( - self::COLUMNS, - fn (string $v, string|int $newColName) => $visitLocations->hasColumn((string) $newColName), - ), 'New columns already exist'); - - foreach (self::COLUMNS as $columnName) { - $qb = $this->connection->createQueryBuilder(); - $qb->update('visit_locations') - ->set($columnName, ':zeroValue') - ->where($qb->expr()->orX( - $qb->expr()->eq($columnName, ':emptyString'), - $qb->expr()->isNull($columnName), - )) - ->setParameters([ - 'zeroValue' => '0', - 'emptyString' => '', - ]) - ->executeStatement(); - } - } - - /** - * @throws Exception - */ - public function up(Schema $schema): void - { - $visitLocations = $schema->getTable('visit_locations'); - - foreach (self::COLUMNS as $newName => $oldName) { - $visitLocations->addColumn($newName, Types::FLOAT, [ - 'default' => '0.0', - ]); - } - } - - /** - * @throws Exception - */ - public function postUp(Schema $schema): void - { - $isPostgres = $this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform; - $castType = $isPostgres ? 'DOUBLE PRECISION' : 'DECIMAL(9,2)'; - - foreach (self::COLUMNS as $newName => $oldName) { - $qb = $this->connection->createQueryBuilder(); - $qb->update('visit_locations') - ->set($newName, 'CAST(' . $oldName . ' AS ' . $castType . ')') - ->executeStatement(); - } - } - - public function preDown(Schema $schema): void - { - foreach (self::COLUMNS as $newName => $oldName) { - $qb = $this->connection->createQueryBuilder(); - $qb->update('visit_locations') - ->set($oldName, $newName) - ->executeStatement(); - } - } - - /** - * @throws Exception - */ - public function down(Schema $schema): void - { - $visitLocations = $schema->getTable('visit_locations'); - - foreach (self::COLUMNS as $colName => $oldName) { - $visitLocations->dropColumn($colName); - } - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20200106215144.php b/module/Core/migrations/Version20200106215144.php deleted file mode 100644 index f5faba4e..00000000 --- a/module/Core/migrations/Version20200106215144.php +++ /dev/null @@ -1,60 +0,0 @@ -getTable('visit_locations'); - $this->skipIf($this->oldColumnsDoNotExist($visitLocations), 'Old columns do not exist'); - - foreach (self::COLUMNS as $colName) { - $visitLocations->dropColumn($colName); - } - } - - public function oldColumnsDoNotExist(Table $visitLocations): bool - { - foreach (self::COLUMNS as $oldColName) { - if ($visitLocations->hasColumn($oldColName)) { - return false; - } - } - - return true; - } - - /** - * @throws Exception - */ - public function down(Schema $schema): void - { - $visitLocations = $schema->getTable('visit_locations'); - - foreach (self::COLUMNS as $colName) { - $visitLocations->addColumn($colName, Types::STRING, [ - 'notnull' => false, - ]); - } - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20200110182849.php b/module/Core/migrations/Version20200110182849.php deleted file mode 100644 index 4b608bb2..00000000 --- a/module/Core/migrations/Version20200110182849.php +++ /dev/null @@ -1,60 +0,0 @@ - [ - 'referer', - 'user_agent', - ], - 'visit_locations' => [ - 'timezone', - 'country_code', - 'country_name', - 'region_name', - 'city_name', - ], - ]; - - public function up(Schema $schema): void - { - foreach (self::COLUMN_DEFAULTS_MAP as $tableName => $columns) { - foreach ($columns as $columnName) { - $this->setDefaultValueForColumnInTable($tableName, $columnName); - } - } - } - - /** - * @throws Exception - */ - public function setDefaultValueForColumnInTable(string $tableName, string $columnName): void - { - $qb = $this->connection->createQueryBuilder(); - $qb->update($tableName) - ->set($columnName, ':emptyValue') - ->setParameter('emptyValue', self::DEFAULT_EMPTY_VALUE) - ->where($qb->expr()->isNull($columnName)) - ->executeStatement(); - } - - public function down(Schema $schema): void - { - // No need (and no way) to undo this migration - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20200323190014.php b/module/Core/migrations/Version20200323190014.php deleted file mode 100644 index f76df5e7..00000000 --- a/module/Core/migrations/Version20200323190014.php +++ /dev/null @@ -1,51 +0,0 @@ -getTable('visit_locations'); - $this->skipIf($visitLocations->hasColumn('is_empty')); - - $visitLocations->addColumn('is_empty', Types::BOOLEAN, ['default' => false]); - } - - public function postUp(Schema $schema): void - { - $qb = $this->connection->createQueryBuilder(); - $qb->update('visit_locations') - ->set('is_empty', ':isEmpty') - ->where($qb->expr()->eq('country_code', ':emptyString')) - ->andWhere($qb->expr()->eq('country_name', ':emptyString')) - ->andWhere($qb->expr()->eq('region_name', ':emptyString')) - ->andWhere($qb->expr()->eq('city_name', ':emptyString')) - ->andWhere($qb->expr()->eq('timezone', ':emptyString')) - ->andWhere($qb->expr()->eq('lat', 0)) - ->andWhere($qb->expr()->eq('lon', 0)) - ->setParameter('isEmpty', true) - ->setParameter('emptyString', '') - ->executeStatement(); - } - - public function down(Schema $schema): void - { - $visitLocations = $schema->getTable('visit_locations'); - $this->skipIf(!$visitLocations->hasColumn('is_empty')); - - $visitLocations->dropColumn('is_empty'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20200503170404.php b/module/Core/migrations/Version20200503170404.php deleted file mode 100644 index ad2c63df..00000000 --- a/module/Core/migrations/Version20200503170404.php +++ /dev/null @@ -1,33 +0,0 @@ -getTable('visits'); - $this->skipIf($visits->hasIndex(self::INDEX_NAME)); - $visits->addIndex(['date'], self::INDEX_NAME); - } - - public function down(Schema $schema): void - { - $visits = $schema->getTable('visits'); - $this->skipIf(! $visits->hasIndex(self::INDEX_NAME)); - $visits->dropIndex(self::INDEX_NAME); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20201023090929.php b/module/Core/migrations/Version20201023090929.php deleted file mode 100644 index 4655cbd5..00000000 --- a/module/Core/migrations/Version20201023090929.php +++ /dev/null @@ -1,50 +0,0 @@ -getTable('short_urls'); - $this->skipIf($shortUrls->hasColumn(self::IMPORT_SOURCE_COLUMN)); - - $shortUrls->addColumn(self::IMPORT_SOURCE_COLUMN, Types::STRING, [ - 'length' => 255, - 'notnull' => false, - ]); - $shortUrls->addColumn('import_original_short_code', Types::STRING, [ - 'length' => 255, - 'notnull' => false, - ]); - - $shortUrls->addUniqueIndex( - [self::IMPORT_SOURCE_COLUMN, 'import_original_short_code', 'domain_id'], - 'unique_imports', - ); - } - - public function down(Schema $schema): void - { - $shortUrls = $schema->getTable('short_urls'); - $this->skipIf(! $shortUrls->hasColumn(self::IMPORT_SOURCE_COLUMN)); - - $shortUrls->dropColumn(self::IMPORT_SOURCE_COLUMN); - $shortUrls->dropColumn('import_original_short_code'); - $shortUrls->dropIndex('unique_imports'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20201102113208.php b/module/Core/migrations/Version20201102113208.php deleted file mode 100644 index 92647c7f..00000000 --- a/module/Core/migrations/Version20201102113208.php +++ /dev/null @@ -1,92 +0,0 @@ -getTable('short_urls'); - $this->skipIf($shortUrls->hasColumn(self::API_KEY_COLUMN)); - - $shortUrls->addColumn(self::API_KEY_COLUMN, Types::BIGINT, [ - 'unsigned' => true, - 'notnull' => false, - ]); - - $shortUrls->addForeignKeyConstraint('api_keys', [self::API_KEY_COLUMN], ['id'], [ - 'onDelete' => 'SET NULL', - 'onUpdate' => 'RESTRICT', - ], 'FK_' . self::API_KEY_COLUMN); - } - - public function postUp(Schema $schema): void - { - // If there's only one API key and it's active, link all existing URLs with it - $qb = $this->connection->createQueryBuilder(); - $qb->select('id') - ->from('api_keys') - ->where($qb->expr()->eq('enabled', ':enabled')) - ->andWhere($qb->expr()->or( - $qb->expr()->isNull('expiration_date'), - $qb->expr()->gt('expiration_date', ':expiration'), - )) - ->setParameters([ - 'enabled' => true, - 'expiration' => Chronos::now()->toDateTimeString(), - ]); - - /** @var Result $result */ - $result = $qb->execute(); - $id = $this->resolveOneApiKeyId($result); - if ($id === null) { - return; - } - - $qb = $this->connection->createQueryBuilder(); - $qb->update('short_urls') - ->set(self::API_KEY_COLUMN, ':apiKeyId') - ->setParameter('apiKeyId', $id) - ->execute(); - } - - private function resolveOneApiKeyId(Result $result): string|int|null - { - $results = []; - while ($row = $result->fetchAssociative()) { - // As soon as we have to iterate more than once, then we cannot resolve a single API key - if (! empty($results)) { - return null; - } - - $results[] = $row['id'] ?? null; - } - - return $results[0] ?? null; - } - - public function down(Schema $schema): void - { - $shortUrls = $schema->getTable('short_urls'); - $this->skipIf(! $shortUrls->hasColumn(self::API_KEY_COLUMN)); - - $shortUrls->removeForeignKey('FK_' . self::API_KEY_COLUMN); - $shortUrls->dropColumn(self::API_KEY_COLUMN); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20210102174433.php b/module/Core/migrations/Version20210102174433.php deleted file mode 100644 index 58ea36cd..00000000 --- a/module/Core/migrations/Version20210102174433.php +++ /dev/null @@ -1,58 +0,0 @@ -skipIf($schema->hasTable(self::TABLE_NAME)); - - $table = $schema->createTable(self::TABLE_NAME); - $table->addColumn('id', Types::BIGINT, [ - 'unsigned' => true, - 'autoincrement' => true, - 'notnull' => true, - ]); - $table->setPrimaryKey(['id']); - - $table->addColumn('role_name', Types::STRING, [ - 'length' => 255, - 'notnull' => true, - ]); - $table->addColumn('meta', Types::JSON, [ - 'notnull' => true, - ]); - - $table->addColumn('api_key_id', Types::BIGINT, [ - 'unsigned' => true, - 'notnull' => true, - ]); - $table->addForeignKeyConstraint('api_keys', ['api_key_id'], ['id'], [ - 'onDelete' => 'CASCADE', - 'onUpdate' => 'RESTRICT', - ]); - $table->addUniqueIndex(['role_name', 'api_key_id'], 'UQ_role_plus_api_key'); - } - - public function down(Schema $schema): void - { - $this->skipIf(! $schema->hasTable(self::TABLE_NAME)); - $schema->getTable(self::TABLE_NAME)->dropIndex('UQ_role_plus_api_key'); - $schema->dropTable(self::TABLE_NAME); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20210118153932.php b/module/Core/migrations/Version20210118153932.php deleted file mode 100644 index 476f8d84..00000000 --- a/module/Core/migrations/Version20210118153932.php +++ /dev/null @@ -1,32 +0,0 @@ -getTable('api_key_roles'); - $nameColumn = $rolesTable->getColumn('role_name'); - $nameColumn->setLength(255); - } - - public function down(Schema $schema): void - { - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20210202181026.php b/module/Core/migrations/Version20210202181026.php deleted file mode 100644 index 7a63b814..00000000 --- a/module/Core/migrations/Version20210202181026.php +++ /dev/null @@ -1,42 +0,0 @@ -getTable('short_urls'); - $this->skipIf($shortUrls->hasColumn(self::TITLE)); - - $shortUrls->addColumn(self::TITLE, Types::STRING, [ - 'notnull' => false, - 'length' => 512, - ]); - $shortUrls->addColumn('title_was_auto_resolved', Types::BOOLEAN, [ - 'default' => false, - ]); - } - - public function down(Schema $schema): void - { - $shortUrls = $schema->getTable('short_urls'); - $this->skipIf(! $shortUrls->hasColumn(self::TITLE)); - $shortUrls->dropColumn(self::TITLE); - $shortUrls->dropColumn('title_was_auto_resolved'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20210207100807.php b/module/Core/migrations/Version20210207100807.php deleted file mode 100644 index 4a77eba2..00000000 --- a/module/Core/migrations/Version20210207100807.php +++ /dev/null @@ -1,49 +0,0 @@ -getTable('visits'); - $this->skipIf($visits->hasColumn('visited_url')); - - $shortUrlId = $visits->getColumn('short_url_id'); - $shortUrlId->setNotnull(false); - - $visits->addColumn('visited_url', Types::STRING, [ - 'length' => Visitor::VISITED_URL_MAX_LENGTH, - 'notnull' => false, - ]); - $visits->addColumn('type', Types::STRING, [ - 'length' => 255, - 'default' => VisitType::VALID_SHORT_URL->value, - ]); - } - - public function down(Schema $schema): void - { - $visits = $schema->getTable('visits'); - $this->skipIf(! $visits->hasColumn('visited_url')); - - $shortUrlId = $visits->getColumn('short_url_id'); - $shortUrlId->setNotnull(true); - $visits->dropColumn('visited_url'); - $visits->dropColumn('type'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20210306165711.php b/module/Core/migrations/Version20210306165711.php deleted file mode 100644 index ba1a4476..00000000 --- a/module/Core/migrations/Version20210306165711.php +++ /dev/null @@ -1,43 +0,0 @@ -getTable(self::TABLE); - $this->skipIf($apiKeys->hasColumn(self::COLUMN)); - - $apiKeys->addColumn( - self::COLUMN, - Types::STRING, - [ - 'notnull' => false, - ], - ); - } - - public function down(Schema $schema): void - { - $apiKeys = $schema->getTable(self::TABLE); - $this->skipIf(! $apiKeys->hasColumn(self::COLUMN)); - - $apiKeys->dropColumn(self::COLUMN); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20210522051601.php b/module/Core/migrations/Version20210522051601.php deleted file mode 100644 index 279c7a7e..00000000 --- a/module/Core/migrations/Version20210522051601.php +++ /dev/null @@ -1,32 +0,0 @@ -getTable('short_urls'); - $this->skipIf($shortUrls->hasColumn('crawlable')); - $shortUrls->addColumn('crawlable', Types::BOOLEAN, ['default' => false]); - } - - public function down(Schema $schema): void - { - $shortUrls = $schema->getTable('short_urls'); - $this->skipIf(! $shortUrls->hasColumn('crawlable')); - $shortUrls->dropColumn('crawlable'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20210522124633.php b/module/Core/migrations/Version20210522124633.php deleted file mode 100644 index 921e0831..00000000 --- a/module/Core/migrations/Version20210522124633.php +++ /dev/null @@ -1,34 +0,0 @@ -getTable('visits'); - $this->skipIf($visits->hasColumn(self::POTENTIAL_BOT_COLUMN)); - $visits->addColumn(self::POTENTIAL_BOT_COLUMN, Types::BOOLEAN, ['default' => false]); - } - - public function down(Schema $schema): void - { - $visits = $schema->getTable('visits'); - $this->skipIf(! $visits->hasColumn(self::POTENTIAL_BOT_COLUMN)); - $visits->dropColumn(self::POTENTIAL_BOT_COLUMN); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20210720143824.php b/module/Core/migrations/Version20210720143824.php deleted file mode 100644 index 407c5c79..00000000 --- a/module/Core/migrations/Version20210720143824.php +++ /dev/null @@ -1,47 +0,0 @@ -getTable('domains'); - $this->skipIf($domainsTable->hasColumn('base_url_redirect')); - - $this->createRedirectColumn($domainsTable, 'base_url_redirect'); - $this->createRedirectColumn($domainsTable, 'regular_not_found_redirect'); - $this->createRedirectColumn($domainsTable, 'invalid_short_url_redirect'); - } - - private function createRedirectColumn(Table $table, string $columnName): void - { - $table->addColumn($columnName, Types::STRING, [ - 'notnull' => false, - 'default' => null, - ]); - } - - public function down(Schema $schema): void - { - $domainsTable = $schema->getTable('domains'); - $this->skipIf(! $domainsTable->hasColumn('base_url_redirect')); - - $domainsTable->dropColumn('base_url_redirect'); - $domainsTable->dropColumn('regular_not_found_redirect'); - $domainsTable->dropColumn('invalid_short_url_redirect'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20211002072605.php b/module/Core/migrations/Version20211002072605.php deleted file mode 100644 index 970d51d6..00000000 --- a/module/Core/migrations/Version20211002072605.php +++ /dev/null @@ -1,32 +0,0 @@ -getTable('short_urls'); - $this->skipIf($shortUrls->hasColumn('forward_query')); - $shortUrls->addColumn('forward_query', Types::BOOLEAN, ['default' => true]); - } - - public function down(Schema $schema): void - { - $shortUrls = $schema->getTable('short_urls'); - $this->skipIf(! $shortUrls->hasColumn('forward_query')); - $shortUrls->dropColumn('forward_query'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20240220214031.php b/module/Core/migrations/Version20240220214031.php new file mode 100644 index 00000000..adceb7f2 --- /dev/null +++ b/module/Core/migrations/Version20240220214031.php @@ -0,0 +1,59 @@ + self::DOMAINS_COLUMNS, + 'device_long_urls' => ['long_url'], + 'short_urls' => ['original_url'], + ]; + + public function up(Schema $schema): void + { + $textType = Type::getType(Types::TEXT); + + foreach (self::TEXT_COLUMNS as $table => $columns) { + $t = $schema->getTable($table); + + foreach ($columns as $column) { + $c = $t->getColumn($column); + + if ($c->getType() === $textType) { + continue; + } + + if (in_array($column, self::DOMAINS_COLUMNS, true)) { + // Domain columns had an incorrect length + $t->modifyColumn($column, ['length' => 2048]); + } + $c->setType($textType); + } + } + } + + public function down(Schema $schema): void + { + // Can't revert from TEXT to STRING, as it's bigger + } + + public function isTransactional(): bool + { + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} diff --git a/module/Core/migrations/Version20240224115725.php b/module/Core/migrations/Version20240224115725.php new file mode 100644 index 00000000..292e6dbb --- /dev/null +++ b/module/Core/migrations/Version20240224115725.php @@ -0,0 +1,95 @@ +skipIf($schema->hasTable('short_url_redirect_rules'), 'New columns already exist'); + + $redirectRules = $this->createTableWithId($schema, 'short_url_redirect_rules'); + $redirectRules->addColumn('priority', Types::INTEGER, ['unsigned' => true, 'default' => 1]); + // The length here is just so that Doctrine knows it should not use too small text types + $redirectRules->addColumn('long_url', Types::TEXT, ['length' => 2048]); + + $redirectRules->addColumn('short_url_id', Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + $redirectRules->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [ + 'onDelete' => 'CASCADE', + 'onUpdate' => 'RESTRICT', + ]); + + $redirectConditions = $this->createTableWithId($schema, 'redirect_conditions'); + + $redirectConditions->addColumn('type', Types::STRING, ['length' => 255]); + $redirectConditions->addColumn('match_key', Types::STRING, [ + 'length' => 512, + 'notnull' => false, + 'default' => null, + ]); + $redirectConditions->addColumn('match_value', Types::STRING, ['length' => 512]); + + $joinTable = $schema->createTable('redirect_conditions_in_short_url_redirect_rules'); + + $joinTable->addColumn('redirect_condition_id', Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + $joinTable->addForeignKeyConstraint('redirect_conditions', ['redirect_condition_id'], ['id'], [ + 'onDelete' => 'CASCADE', + 'onUpdate' => 'RESTRICT', + ]); + + $joinTable->addColumn('short_url_redirect_rule_id', Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + $joinTable->addForeignKeyConstraint('short_url_redirect_rules', ['short_url_redirect_rule_id'], ['id'], [ + 'onDelete' => 'CASCADE', + 'onUpdate' => 'RESTRICT', + ]); + + $joinTable->setPrimaryKey(['redirect_condition_id', 'short_url_redirect_rule_id']); + } + + private function createTableWithId(Schema $schema, string $tableName): Table + { + $table = $schema->createTable($tableName); + $table->addColumn('id', Types::BIGINT, [ + 'unsigned' => true, + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->setPrimaryKey(['id']); + + return $table; + } + + public function down(Schema $schema): void + { + $this->skipIf(! $schema->hasTable('short_url_redirect_rules'), 'Columns do not exist'); + + $schema->dropTable('redirect_conditions_in_short_url_redirect_rules'); + $schema->dropTable('short_url_redirect_rules'); + $schema->dropTable('redirect_conditions'); + } + + public function isTransactional(): bool + { + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} diff --git a/module/Core/migrations/Version20240226214216.php b/module/Core/migrations/Version20240226214216.php new file mode 100644 index 00000000..74237ca0 --- /dev/null +++ b/module/Core/migrations/Version20240226214216.php @@ -0,0 +1,76 @@ +skipIf(! $schema->hasTable('device_long_urls')); + + // Insert a rule per every device_long_url, and link it to the corresponding condition + $qb = $this->connection->createQueryBuilder(); + $rules = $qb->select('short_url_id', 'device_type', 'long_url') + ->from('device_long_urls') + ->executeQuery(); + + $priorities = []; + while ($ruleRow = $rules->fetchAssociative()) { + $shortUrlId = $ruleRow['short_url_id']; + $priority = $priorities[$shortUrlId] ?? 1; + + $ruleQb = $this->connection->createQueryBuilder(); + $ruleQb->insert('short_url_redirect_rules') + ->values([ + 'priority' => ':priority', + 'long_url' => ':long_url', + 'short_url_id' => ':short_url_id', + ]) + ->setParameters([ + 'priority' => $priority, + 'long_url' => $ruleRow['long_url'], + 'short_url_id' => $shortUrlId, + ]) + ->executeStatement(); + $ruleId = $this->connection->lastInsertId(); + + $deviceType = $ruleRow['device_type']; + $conditionQb = $this->connection->createQueryBuilder(); + $conditionQb->insert('redirect_conditions') + ->values([ + 'type' => ':type', + 'match_value' => ':match_value', + 'match_key' => ':match_key', + ]) + ->setParameters([ + 'type' => 'device', + 'match_value' => $deviceType, + 'match_key' => null, + ]) + ->executeStatement(); + $conditionId = $this->connection->lastInsertId(); + + $relationQb = $this->connection->createQueryBuilder(); + $relationQb->insert('redirect_conditions_in_short_url_redirect_rules') + ->values([ + 'redirect_condition_id' => ':redirect_condition_id', + 'short_url_redirect_rule_id' => ':short_url_redirect_rule_id', + ]) + ->setParameters([ + 'redirect_condition_id' => $conditionId, + 'short_url_redirect_rule_id' => $ruleId, + ]) + ->executeStatement(); + + $priorities[$shortUrlId] = $priority + 1; + } + } +} diff --git a/module/Core/migrations/Version20240227080629.php b/module/Core/migrations/Version20240227080629.php new file mode 100644 index 00000000..ad41dc54 --- /dev/null +++ b/module/Core/migrations/Version20240227080629.php @@ -0,0 +1,54 @@ +skipIf(! $schema->hasTable('device_long_urls')); + $schema->dropTable('device_long_urls'); + } + + public function down(Schema $schema): void + { + $this->skipIf($schema->hasTable('device_long_urls')); + + $table = $schema->createTable('device_long_urls'); + $table->addColumn('id', Types::BIGINT, [ + 'unsigned' => true, + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->setPrimaryKey(['id']); + + $table->addColumn('device_type', Types::STRING, ['length' => 255]); + $table->addColumn('long_url', Types::TEXT, ['length' => 2048]); + $table->addColumn('short_url_id', Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + + $table->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [ + 'onDelete' => 'CASCADE', + 'onUpdate' => 'RESTRICT', + ]); + + $table->addUniqueIndex(['device_type', 'short_url_id'], 'UQ_device_type_per_short_url'); + } + + public function isTransactional(): bool + { + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php index 05181f20..2a3907cc 100644 --- a/module/Core/src/Action/Model/QrCodeParams.php +++ b/module/Core/src/Action/Model/QrCodeParams.php @@ -4,24 +4,30 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Action\Model; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelInterface; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelLow; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelMedium; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelQuartile; -use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeInterface; -use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeMargin; -use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeNone; +use Endroid\QrCode\Color\Color; +use Endroid\QrCode\Color\ColorInterface; +use Endroid\QrCode\ErrorCorrectionLevel; +use Endroid\QrCode\RoundBlockSizeMode; use Endroid\QrCode\Writer\PngWriter; use Endroid\QrCode\Writer\SvgWriter; use Endroid\QrCode\Writer\WriterInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Options\QrCodeOptions; +use function ctype_xdigit; +use function hexdec; +use function ltrim; +use function max; +use function min; use function Shlinkio\Shlink\Core\ArrayUtils\contains; +use function strlen; use function strtolower; +use function substr; use function trim; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR; + final class QrCodeParams { private const MIN_SIZE = 50; @@ -32,8 +38,10 @@ final class QrCodeParams public readonly int $size, public readonly int $margin, public readonly WriterInterface $writer, - public readonly ErrorCorrectionLevelInterface $errorCorrectionLevel, - public readonly RoundBlockSizeModeInterface $roundBlockSizeMode, + public readonly ErrorCorrectionLevel $errorCorrectionLevel, + public readonly RoundBlockSizeMode $roundBlockSizeMode, + public readonly ColorInterface $color, + public readonly ColorInterface $bgColor, ) { } @@ -42,11 +50,13 @@ final class QrCodeParams $query = $request->getQueryParams(); return new self( - self::resolveSize($query, $defaults), - self::resolveMargin($query, $defaults), - self::resolveWriter($query, $defaults), - self::resolveErrorCorrection($query, $defaults), - self::resolveRoundBlockSize($query, $defaults), + size: self::resolveSize($query, $defaults), + margin: self::resolveMargin($query, $defaults), + writer: self::resolveWriter($query, $defaults), + errorCorrectionLevel: self::resolveErrorCorrection($query, $defaults), + roundBlockSizeMode: self::resolveRoundBlockSize($query, $defaults), + color: self::resolveColor($query, $defaults), + bgColor: self::resolveBackgroundColor($query, $defaults), ); } @@ -57,7 +67,7 @@ final class QrCodeParams return self::MIN_SIZE; } - return $size > self::MAX_SIZE ? self::MAX_SIZE : $size; + return min($size, self::MAX_SIZE); } private static function resolveMargin(array $query, QrCodeOptions $defaults): int @@ -68,7 +78,7 @@ final class QrCodeParams return 0; } - return $intMargin < 0 ? 0 : $intMargin; + return max($intMargin, 0); } private static function resolveWriter(array $query, QrCodeOptions $defaults): WriterInterface @@ -82,23 +92,57 @@ final class QrCodeParams }; } - private static function resolveErrorCorrection(array $query, QrCodeOptions $defaults): ErrorCorrectionLevelInterface + private static function resolveErrorCorrection(array $query, QrCodeOptions $defaults): ErrorCorrectionLevel { $errorCorrectionLevel = self::normalizeParam($query['errorCorrection'] ?? $defaults->errorCorrection); return match ($errorCorrectionLevel) { - 'h' => new ErrorCorrectionLevelHigh(), - 'q' => new ErrorCorrectionLevelQuartile(), - 'm' => new ErrorCorrectionLevelMedium(), - default => new ErrorCorrectionLevelLow(), // 'l' + 'h' => ErrorCorrectionLevel::High, + 'q' => ErrorCorrectionLevel::Quartile, + 'm' => ErrorCorrectionLevel::Medium, + default => ErrorCorrectionLevel::Low, // 'l' }; } - private static function resolveRoundBlockSize(array $query, QrCodeOptions $defaults): RoundBlockSizeModeInterface + private static function resolveRoundBlockSize(array $query, QrCodeOptions $defaults): RoundBlockSizeMode { $doNotRoundBlockSize = isset($query['roundBlockSize']) ? $query['roundBlockSize'] === 'false' : ! $defaults->roundBlockSize; - return $doNotRoundBlockSize ? new RoundBlockSizeModeNone() : new RoundBlockSizeModeMargin(); + return $doNotRoundBlockSize ? RoundBlockSizeMode::None : RoundBlockSizeMode::Margin; + } + + private static function resolveColor(array $query, QrCodeOptions $defaults): ColorInterface + { + $color = self::normalizeParam($query['color'] ?? $defaults->color); + return self::parseHexColor($color, DEFAULT_QR_CODE_COLOR); + } + + private static function resolveBackgroundColor(array $query, QrCodeOptions $defaults): ColorInterface + { + $bgColor = self::normalizeParam($query['bgColor'] ?? $defaults->bgColor); + return self::parseHexColor($bgColor, DEFAULT_QR_CODE_BG_COLOR); + } + + private static function parseHexColor(string $hexColor, ?string $fallback): Color + { + $hexColor = ltrim($hexColor, '#'); + if (! ctype_xdigit($hexColor) && $fallback !== null) { + return self::parseHexColor($fallback, null); + } + + if (strlen($hexColor) === 3) { + return new Color( + (int) hexdec(substr($hexColor, 0, 1) . substr($hexColor, 0, 1)), + (int) hexdec(substr($hexColor, 1, 1) . substr($hexColor, 1, 1)), + (int) hexdec(substr($hexColor, 2, 1) . substr($hexColor, 2, 1)), + ); + } + + return new Color( + (int) hexdec(substr($hexColor, 0, 2)), + (int) hexdec(substr($hexColor, 2, 2)), + (int) hexdec(substr($hexColor, 4, 2)), + ); } private static function normalizeParam(string $param): string diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index a952243a..53fb1251 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -48,7 +48,15 @@ readonly class QrCodeAction implements MiddlewareInterface ->margin($params->margin) ->writer($params->writer) ->errorCorrectionLevel($params->errorCorrectionLevel) - ->roundBlockSizeMode($params->roundBlockSizeMode); + ->roundBlockSizeMode($params->roundBlockSizeMode) + ->foregroundColor($params->color) + ->backgroundColor($params->bgColor); + + $logoUrl = $this->options->logoUrl; + if ($logoUrl !== null) { + $qrCodeBuilder->logoPath($logoUrl) + ->logoResizeToHeight((int) ($params->size / 4)); + } return new QrCodeResponse($qrCodeBuilder->build()); } diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index ff64838b..e5df9532 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -4,7 +4,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Config; +use function file_get_contents; +use function is_file; use function Shlinkio\Shlink\Config\env; +use function Shlinkio\Shlink\Config\parseEnvVar; +use function sprintf; enum EnvVars: string { @@ -20,7 +24,6 @@ enum EnvVars: string case CACHE_NAMESPACE = 'CACHE_NAMESPACE'; case REDIS_SERVERS = 'REDIS_SERVERS'; case REDIS_SENTINEL_SERVICE = 'REDIS_SENTINEL_SERVICE'; - case REDIS_DECODE_CREDENTIALS = 'REDIS_DECODE_CREDENTIALS'; case REDIS_PUB_SUB_ENABLED = 'REDIS_PUB_SUB_ENABLED'; case MERCURE_PUBLIC_HUB_URL = 'MERCURE_PUBLIC_HUB_URL'; case MERCURE_INTERNAL_HUB_URL = 'MERCURE_INTERNAL_HUB_URL'; @@ -32,8 +35,6 @@ enum EnvVars: string case RABBITMQ_PASSWORD = 'RABBITMQ_PASSWORD'; case RABBITMQ_VHOST = 'RABBITMQ_VHOST'; case RABBITMQ_USE_SSL = 'RABBITMQ_USE_SSL'; - /** @deprecated */ - case RABBITMQ_LEGACY_VISITS_PUBLISHING = 'RABBITMQ_LEGACY_VISITS_PUBLISHING'; case MATOMO_ENABLED = 'MATOMO_ENABLED'; case MATOMO_BASE_URL = 'MATOMO_BASE_URL'; case MATOMO_SITE_ID = 'MATOMO_SITE_ID'; @@ -44,6 +45,9 @@ enum EnvVars: string case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION'; case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE'; case QR_CODE_FOR_DISABLED_SHORT_URLS = 'QR_CODE_FOR_DISABLED_SHORT_URLS'; + case DEFAULT_QR_CODE_COLOR = 'DEFAULT_QR_CODE_COLOR'; + case DEFAULT_QR_CODE_BG_COLOR = 'DEFAULT_QR_CODE_BG_COLOR'; + case DEFAULT_QR_CODE_LOGO_URL = 'DEFAULT_QR_CODE_LOGO_URL'; case DEFAULT_INVALID_SHORT_URL_REDIRECT = 'DEFAULT_INVALID_SHORT_URL_REDIRECT'; case DEFAULT_REGULAR_404_REDIRECT = 'DEFAULT_REGULAR_404_REDIRECT'; case DEFAULT_BASE_URL_REDIRECT = 'DEFAULT_BASE_URL_REDIRECT'; @@ -52,9 +56,6 @@ enum EnvVars: string case BASE_PATH = 'BASE_PATH'; case SHORT_URL_TRAILING_SLASH = 'SHORT_URL_TRAILING_SLASH'; case SHORT_URL_MODE = 'SHORT_URL_MODE'; - case PORT = 'PORT'; - case TASK_WORKER_NUM = 'TASK_WORKER_NUM'; - case WEB_WORKER_NUM = 'WEB_WORKER_NUM'; case ANONYMIZE_REMOTE_ADDR = 'ANONYMIZE_REMOTE_ADDR'; case TRACK_ORPHAN_VISITS = 'TRACK_ORPHAN_VISITS'; case DISABLE_TRACK_PARAM = 'DISABLE_TRACK_PARAM'; @@ -70,14 +71,27 @@ enum EnvVars: string case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH'; case TIMEZONE = 'TIMEZONE'; case MULTI_SEGMENT_SLUGS_ENABLED = 'MULTI_SEGMENT_SLUGS_ENABLED'; - /** @deprecated */ - case VISITS_WEBHOOKS = 'VISITS_WEBHOOKS'; - /** @deprecated */ - case NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS = 'NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS'; public function loadFromEnv(mixed $default = null): mixed { - return env($this->value, $default); + return env($this->value) ?? $this->loadFromFileEnv() ?? $default; + } + + /** + * Checks if an equivalent environment variable exists with the `_FILE` suffix. If so, it loads its value as a file, + * reads it, and returns its contents. + * This is useful when loading Shlink with docker compose and using secrets. + * See https://docs.docker.com/compose/use-secrets/ + */ + private function loadFromFileEnv(): string|int|bool|null + { + $file = env(sprintf('%s_FILE', $this->value)); + if ($file === null || ! is_file($file)) { + return null; + } + + $content = file_get_contents($file); + return $content ? parseEnvVar($content) : null; } public function existsInEnv(): bool diff --git a/module/Core/src/Config/NotFoundRedirectResolver.php b/module/Core/src/Config/NotFoundRedirectResolver.php index ce5401d2..cfb09c8e 100644 --- a/module/Core/src/Config/NotFoundRedirectResolver.php +++ b/module/Core/src/Config/NotFoundRedirectResolver.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Config; -use League\Uri\Exceptions\SyntaxError; -use League\Uri\Uri; +use Laminas\Diactoros\Exception\InvalidArgumentException; +use Laminas\Diactoros\Uri; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; use Psr\Log\LoggerInterface; @@ -51,8 +51,8 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface private function resolvePlaceholders(UriInterface $currentUri, string $redirectUrl): string { try { - $redirectUri = Uri::createFromString($redirectUrl); - } catch (SyntaxError $e) { + $redirectUri = new Uri($redirectUrl); + } catch (InvalidArgumentException $e) { $this->logger->warning('It was not possible to parse "{url}" as a valid URL: {e}', [ 'e' => $e, 'url' => $redirectUrl, @@ -63,26 +63,22 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface $path = $currentUri->getPath(); $domain = $currentUri->getAuthority(); - $replacePlaceholderForPattern = static fn (string $pattern, string $replace, ?string $value): string|null => - $value === null ? null : str_replace($pattern, $replace, $value); - $replacePlaceholders = static function ( callable $modifier, - ?string $value, + string $value, ) use ( - $replacePlaceholderForPattern, $path, $domain, - ): string|null { - $value = $replacePlaceholderForPattern($modifier(self::DOMAIN_PLACEHOLDER), $modifier($domain), $value); - return $replacePlaceholderForPattern($modifier(self::ORIGINAL_PATH_PLACEHOLDER), $modifier($path), $value); + ): string { + $value = str_replace(urlencode(self::DOMAIN_PLACEHOLDER), $modifier($domain), $value); + return str_replace(urlencode(self::ORIGINAL_PATH_PLACEHOLDER), $modifier($path), $value); }; $replacePlaceholdersInPath = static function (string $path) use ($replacePlaceholders): string { $result = $replacePlaceholders(static fn (mixed $v) => $v, $path); - return str_replace('//', '/', $result ?? ''); + return str_replace('//', '/', $result); }; - $replacePlaceholdersInQuery = static fn (?string $query): string|null => $replacePlaceholders( + $replacePlaceholdersInQuery = static fn (string $query): string => $replacePlaceholders( urlencode(...), $query, ); diff --git a/module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php b/module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php index de627c1c..48035c6c 100644 --- a/module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php +++ b/module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php @@ -5,12 +5,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain\Validation; use Laminas\InputFilter\InputFilter; -use Shlinkio\Shlink\Common\Validation; +use Shlinkio\Shlink\Common\Validation\HostAndPortValidator; +use Shlinkio\Shlink\Common\Validation\InputFactory; class DomainRedirectsInputFilter extends InputFilter { - use Validation\InputFactoryTrait; - public const DOMAIN = 'domain'; public const BASE_URL_REDIRECT = 'baseUrlRedirect'; public const REGULAR_404_REDIRECT = 'regular404Redirect'; @@ -32,12 +31,12 @@ class DomainRedirectsInputFilter extends InputFilter private function initializeInputs(): void { - $domain = $this->createInput(self::DOMAIN); - $domain->getValidatorChain()->attach(new Validation\HostAndPortValidator()); + $domain = InputFactory::basic(self::DOMAIN, required: true); + $domain->getValidatorChain()->attach(new HostAndPortValidator()); $this->add($domain); - $this->add($this->createInput(self::BASE_URL_REDIRECT, false)); - $this->add($this->createInput(self::REGULAR_404_REDIRECT, false)); - $this->add($this->createInput(self::INVALID_SHORT_URL_REDIRECT, false)); + $this->add(InputFactory::basic(self::BASE_URL_REDIRECT)); + $this->add(InputFactory::basic(self::REGULAR_404_REDIRECT)); + $this->add(InputFactory::basic(self::INVALID_SHORT_URL_REDIRECT)); } } diff --git a/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php b/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php index 269aed76..ad4c8070 100644 --- a/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php +++ b/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php @@ -8,19 +8,17 @@ use Shlinkio\Shlink\Common\Mercure\MercureOptions; use Shlinkio\Shlink\Core\EventDispatcher; use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Options\RabbitMqOptions; -use Shlinkio\Shlink\Core\Options\WebhookOptions; use Shlinkio\Shlink\EventDispatcher\Listener\EnabledListenerCheckerInterface; use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options; -class EnabledListenerChecker implements EnabledListenerCheckerInterface +readonly class EnabledListenerChecker implements EnabledListenerCheckerInterface { public function __construct( - private readonly RabbitMqOptions $rabbitMqOptions, - private readonly bool $redisPubSubEnabled, - private readonly MercureOptions $mercureOptions, - private readonly WebhookOptions $webhookOptions, - private readonly GeoLite2Options $geoLiteOptions, - private readonly MatomoOptions $matomoOptions, + private RabbitMqOptions $rabbitMqOptions, + private bool $redisPubSubEnabled, + private MercureOptions $mercureOptions, + private GeoLite2Options $geoLiteOptions, + private MatomoOptions $matomoOptions, ) { } @@ -38,7 +36,6 @@ class EnabledListenerChecker implements EnabledListenerCheckerInterface EventDispatcher\Mercure\NotifyVisitToMercure::class, EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => $this->mercureOptions->isEnabled(), EventDispatcher\Matomo\SendVisitToMatomo::class => $this->matomoOptions->enabled, - EventDispatcher\NotifyVisitToWebHooks::class => $this->webhookOptions->hasWebhooks(), EventDispatcher\UpdateGeoLiteDb::class => $this->geoLiteOptions->hasLicenseKey(), default => false, // Any unknown async listener should not be enabled by default }; diff --git a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php deleted file mode 100644 index 028c3c13..00000000 --- a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php +++ /dev/null @@ -1,101 +0,0 @@ -webhookOptions->hasWebhooks()) { - return; - } - - $visitId = $shortUrlLocated->visitId; - - /** @var Visit|null $visit */ - $visit = $this->em->find(Visit::class, $visitId); - if ($visit === null) { - $this->logger->warning('Tried to notify webhooks for visit with id "{visitId}", but it does not exist.', [ - 'visitId' => $visitId, - ]); - return; - } - - if ($visit->isOrphan() && ! $this->webhookOptions->notifyOrphanVisits()) { - return; - } - - $requestOptions = $this->buildRequestOptions($visit); - $requestPromises = $this->performRequests($requestOptions, $visitId); - - // Wait for all the promises to finish, ignoring rejections, as in those cases we only want to log the error. - Utils::settle($requestPromises)->wait(); - } - - private function buildRequestOptions(Visit $visit): array - { - $payload = ['visit' => $visit->jsonSerialize()]; - $shortUrl = $visit->getShortUrl(); - if ($shortUrl !== null) { - $payload['shortUrl'] = $this->transformer->transform($shortUrl); - } - - return [ - RequestOptions::TIMEOUT => 10, - RequestOptions::JSON => $payload, - RequestOptions::HEADERS => ['User-Agent' => $this->appOptions->__toString()], - ]; - } - - /** - * @param Promise[] $requestOptions - */ - private function performRequests(array $requestOptions, string $visitId): array - { - return array_map( - fn (string $webhook): PromiseInterface => $this->httpClient - ->requestAsync(RequestMethodInterface::METHOD_POST, $webhook, $requestOptions) - ->otherwise(fn (Throwable $e) => $this->logWebhookFailure($webhook, $visitId, $e)), - $this->webhookOptions->webhooks(), - ); - } - - private function logWebhookFailure(string $webhook, string $visitId, Throwable $e): void - { - $this->logger->warning('Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}', [ - 'visitId' => $visitId, - 'webhook' => $webhook, - 'e' => $e, - ]); - } -} diff --git a/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php b/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php index ed5b08e0..ddc4221c 100644 --- a/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php +++ b/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php @@ -6,15 +6,11 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\RabbitMq; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface; -use Shlinkio\Shlink\Common\UpdatePublishing\Update; use Shlinkio\Shlink\Core\EventDispatcher\Async\AbstractNotifyVisitListener; use Shlinkio\Shlink\Core\EventDispatcher\Async\RemoteSystem; use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface; -use Shlinkio\Shlink\Core\EventDispatcher\Topic; use Shlinkio\Shlink\Core\Options\RabbitMqOptions; -use Shlinkio\Shlink\Core\Visit\Entity\Visit; class NotifyVisitToRabbitMq extends AbstractNotifyVisitListener { @@ -23,42 +19,11 @@ class NotifyVisitToRabbitMq extends AbstractNotifyVisitListener PublishingUpdatesGeneratorInterface $updatesGenerator, EntityManagerInterface $em, LoggerInterface $logger, - private readonly DataTransformerInterface $orphanVisitTransformer, private readonly RabbitMqOptions $options, ) { parent::__construct($rabbitMqHelper, $updatesGenerator, $em, $logger); } - /** - * @return Update[] - */ - protected function determineUpdatesForVisit(Visit $visit): array - { - // Once the two deprecated cases below have been removed, make parent method private - if (! $this->options->legacyVisitsPublishing) { - return parent::determineUpdatesForVisit($visit); - } - - // This was defined incorrectly. - // According to the spec, both the visit and the short URL it belongs to, should be published. - // The shape should be ['visit' => [...], 'shortUrl' => ?[...]] - // However, this would be a breaking change, so we need a flag that determines the shape of the payload. - return $visit->isOrphan() - ? [ - Update::forTopicAndPayload( - Topic::NEW_ORPHAN_VISIT->value, - $this->orphanVisitTransformer->transform($visit), - ), - ] - : [ - Update::forTopicAndPayload(Topic::NEW_VISIT->value, $visit->jsonSerialize()), - Update::forTopicAndPayload( - Topic::newShortUrlVisit($visit->getShortUrl()?->getShortCode()), - $visit->jsonSerialize(), - ), - ]; - } - protected function isEnabled(): bool { return $this->options->enabled; diff --git a/module/Core/src/Exception/InvalidUrlException.php b/module/Core/src/Exception/InvalidUrlException.php deleted file mode 100644 index 200914c2..00000000 --- a/module/Core/src/Exception/InvalidUrlException.php +++ /dev/null @@ -1,35 +0,0 @@ -detail = $e->getMessage(); - $e->title = self::TITLE; - $e->type = toProblemDetailsType(self::ERROR_CODE); - $e->status = $status; - $e->additional = ['url' => $url]; - - return $e; - } -} diff --git a/module/Core/src/Options/QrCodeOptions.php b/module/Core/src/Options/QrCodeOptions.php index fff27858..da130d17 100644 --- a/module/Core/src/Options/QrCodeOptions.php +++ b/module/Core/src/Options/QrCodeOptions.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION; use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT; @@ -20,6 +22,9 @@ readonly final class QrCodeOptions public string $errorCorrection = DEFAULT_QR_CODE_ERROR_CORRECTION, public bool $roundBlockSize = DEFAULT_QR_CODE_ROUND_BLOCK_SIZE, public bool $enabledForDisabledShortUrls = DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS, + public string $color = DEFAULT_QR_CODE_COLOR, + public string $bgColor = DEFAULT_QR_CODE_BG_COLOR, + public ?string $logoUrl = null, ) { } } diff --git a/module/Core/src/Options/RabbitMqOptions.php b/module/Core/src/Options/RabbitMqOptions.php index cc25f3bf..308dff2a 100644 --- a/module/Core/src/Options/RabbitMqOptions.php +++ b/module/Core/src/Options/RabbitMqOptions.php @@ -4,12 +4,10 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; -final class RabbitMqOptions +final readonly class RabbitMqOptions { public function __construct( - public readonly bool $enabled = false, - /** @deprecated */ - public readonly bool $legacyVisitsPublishing = false, + public bool $enabled = false, ) { } } diff --git a/module/Core/src/Options/WebhookOptions.php b/module/Core/src/Options/WebhookOptions.php deleted file mode 100644 index 7196fd0c..00000000 --- a/module/Core/src/Options/WebhookOptions.php +++ /dev/null @@ -1,41 +0,0 @@ -webhooks; - } - - public function hasWebhooks(): bool - { - return ! empty($this->webhooks); - } - - protected function setWebhooks(array $webhooks): void - { - $this->webhooks = $webhooks; - } - - public function notifyOrphanVisits(): bool - { - return $this->notifyOrphanVisitsToWebhooks; - } - - protected function setNotifyOrphanVisitsToWebhooks(bool $notifyOrphanVisitsToWebhooks): void - { - $this->notifyOrphanVisitsToWebhooks = $notifyOrphanVisitsToWebhooks; - } -} diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php new file mode 100644 index 00000000..29123733 --- /dev/null +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -0,0 +1,124 @@ +value); + } + + public static function fromRawData(array $rawData): self + { + $type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]); + $value = $rawData[RedirectRulesInputFilter::CONDITION_MATCH_VALUE]; + $key = $rawData[RedirectRulesInputFilter::CONDITION_MATCH_KEY] ?? null; + + return new self($type, $value, $key); + } + + /** + * Tells if this condition matches provided request + */ + public function matchesRequest(ServerRequestInterface $request): bool + { + return match ($this->type) { + RedirectConditionType::QUERY_PARAM => $this->matchesQueryParam($request), + RedirectConditionType::LANGUAGE => $this->matchesLanguage($request), + RedirectConditionType::DEVICE => $this->matchesDevice($request), + }; + } + + private function matchesQueryParam(ServerRequestInterface $request): bool + { + $query = $request->getQueryParams(); + $queryValue = $query[$this->matchKey] ?? null; + + return $queryValue === $this->matchValue; + } + + private function matchesLanguage(ServerRequestInterface $request): bool + { + $acceptLanguage = trim($request->getHeaderLine('Accept-Language')); + if ($acceptLanguage === '' || $acceptLanguage === '*') { + return false; + } + + $acceptedLanguages = acceptLanguageToLocales($acceptLanguage); + [$matchLanguage, $matchCountryCode] = splitLocale(normalizeLocale($this->matchValue)); + + return some( + $acceptedLanguages, + static function (string $lang) use ($matchLanguage, $matchCountryCode): bool { + [$language, $countryCode] = splitLocale($lang); + + if ($matchLanguage !== $language) { + return false; + } + + return $matchCountryCode === null || $matchCountryCode === $countryCode; + }, + ); + } + + private function matchesDevice(ServerRequestInterface $request): bool + { + $device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent')); + return $device !== null && $device->value === strtolower($this->matchValue); + } + + public function jsonSerialize(): array + { + return [ + 'type' => $this->type->value, + 'matchKey' => $this->matchKey, + 'matchValue' => $this->matchValue, + ]; + } + + public function toHumanFriendly(): string + { + return match ($this->type) { + RedirectConditionType::DEVICE => sprintf('device is %s', $this->matchValue), + RedirectConditionType::LANGUAGE => sprintf('%s language is accepted', $this->matchValue), + RedirectConditionType::QUERY_PARAM => sprintf( + 'query string contains %s=%s', + $this->matchKey, + $this->matchValue, + ), + }; + } +} diff --git a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php new file mode 100644 index 00000000..5f76d998 --- /dev/null +++ b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php @@ -0,0 +1,72 @@ + $conditions + */ + public function __construct( + private readonly ShortUrl $shortUrl, // No need to read this field. It's used by doctrine + private readonly int $priority, + public readonly string $longUrl, + private Collection $conditions = new ArrayCollection(), + ) { + } + + public function withPriority(int $newPriority): self + { + return new self( + $this->shortUrl, + $newPriority, + $this->longUrl, + $this->conditions, + ); + } + + /** + * Tells if this condition matches provided request + */ + public function matchesRequest(ServerRequestInterface $request): bool + { + return $this->conditions->count() > 0 && every( + $this->conditions, + static fn (RedirectCondition $condition) => $condition->matchesRequest($request), + ); + } + + public function clearConditions(): void + { + $this->conditions->clear(); + } + + /** + * @template R + * @param callable(RedirectCondition $condition): R $callback + * @return R[] + */ + public function mapConditions(callable $callback): array + { + return $this->conditions->map($callback(...))->toArray(); + } + + public function jsonSerialize(): array + { + return [ + 'longUrl' => $this->longUrl, + 'priority' => $this->priority, + 'conditions' => array_values($this->conditions->toArray()), + ]; + } +} diff --git a/module/Core/src/RedirectRule/Model/RedirectConditionType.php b/module/Core/src/RedirectRule/Model/RedirectConditionType.php new file mode 100644 index 00000000..c00cca7f --- /dev/null +++ b/module/Core/src/RedirectRule/Model/RedirectConditionType.php @@ -0,0 +1,10 @@ +isValid()) { + throw ValidationException::fromInputFilter($inputFilter); + } + + return new self(array_values($inputFilter->getValue(RedirectRulesInputFilter::REDIRECT_RULES))); + } catch (InvalidArgumentException) { + throw ValidationException::fromArray( + [RedirectRulesInputFilter::REDIRECT_RULES => RedirectRulesInputFilter::REDIRECT_RULES], + ); + } + } +} diff --git a/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php b/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php new file mode 100644 index 00000000..5decaf4c --- /dev/null +++ b/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php @@ -0,0 +1,89 @@ +setInputFilter(self::createRedirectRuleInputFilter()); + + $instance = new self(); + $instance->add($redirectRulesInputFilter, self::REDIRECT_RULES); + + $instance->setData($rawData); + return $instance; + } + + private static function createRedirectRuleInputFilter(): InputFilter + { + $redirectRuleInputFilter = new InputFilter(); + + $longUrl = InputFactory::basic(self::RULE_LONG_URL, required: true); + $longUrl->getValidatorChain()->merge(ShortUrlInputFilter::longUrlValidators()); + $redirectRuleInputFilter->add($longUrl); + + $conditionsInputFilter = new CollectionInputFilter(); + $conditionsInputFilter->setInputFilter(self::createRedirectConditionInputFilter()) + ->setIsRequired(true); + $redirectRuleInputFilter->add($conditionsInputFilter, self::RULE_CONDITIONS); + + return $redirectRuleInputFilter; + } + + private static function createRedirectConditionInputFilter(): InputFilter + { + $redirectConditionInputFilter = new InputFilter(); + + $type = InputFactory::basic(self::CONDITION_TYPE, required: true); + $type->getValidatorChain()->attach(new InArray([ + 'haystack' => enumValues(RedirectConditionType::class), + 'strict' => InArray::COMPARE_STRICT, + ])); + $redirectConditionInputFilter->add($type); + + $value = InputFactory::basic(self::CONDITION_MATCH_VALUE, required: true); + $value->getValidatorChain()->attach(new Callback(function (string $value, array $context) { + if ($context[self::CONDITION_TYPE] === RedirectConditionType::DEVICE->value) { + return contains($value, enumValues(DeviceType::class)); + } + + return true; + })); + $redirectConditionInputFilter->add($value); + + $redirectConditionInputFilter->add( + InputFactory::basic(self::CONDITION_MATCH_KEY, required: true)->setAllowEmpty(true), + ); + + return $redirectConditionInputFilter; + } +} diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php new file mode 100644 index 00000000..01ba0a8f --- /dev/null +++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php @@ -0,0 +1,94 @@ +em->getRepository(ShortUrlRedirectRule::class)->findBy( + criteria: ['shortUrl' => $shortUrl], + orderBy: ['priority' => 'ASC'], + ); + } + + /** + * @return ShortUrlRedirectRule[] + */ + public function setRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array + { + $rules = []; + foreach ($data->rules as $index => $rule) { + $rule = new ShortUrlRedirectRule( + shortUrl: $shortUrl, + priority: $index + 1, + longUrl: $rule[RedirectRulesInputFilter::RULE_LONG_URL], + conditions: new ArrayCollection(array_map( + RedirectCondition::fromRawData(...), + $rule[RedirectRulesInputFilter::RULE_CONDITIONS], + )), + ); + + $rules[] = $rule; + } + + $this->doSetRulesForShortUrl($shortUrl, $rules); + return $rules; + } + + /** + * @param ShortUrlRedirectRule[] $rules + */ + public function saveRulesForShortUrl(ShortUrl $shortUrl, array $rules): void + { + $normalizedAndDetachedRules = map($rules, function (ShortUrlRedirectRule $rule, int|string|float $priority) { + // Make sure all rules and conditions are detached so that the EM considers them new. + $rule->mapConditions(fn (RedirectCondition $cond) => $this->em->detach($cond)); + $this->em->detach($rule); + + // Normalize priorities so that they are sequential + return $rule->withPriority(((int) $priority) + 1); + }); + + $this->doSetRulesForShortUrl($shortUrl, $normalizedAndDetachedRules); + } + + /** + * @param ShortUrlRedirectRule[] $rules + */ + public function doSetRulesForShortUrl(ShortUrl $shortUrl, array $rules): void + { + $this->em->wrapInTransaction(function () use ($shortUrl, $rules): void { + // First, delete existing rules for the short URL + $oldRules = $this->rulesForShortUrl($shortUrl); + foreach ($oldRules as $oldRule) { + $oldRule->clearConditions(); // This will trigger the orphan removal of old conditions + $this->em->remove($oldRule); + } + $this->em->flush(); + + // Then insert new rules + foreach ($rules as $rule) { + $this->em->persist($rule); + } + }); + } +} diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php new file mode 100644 index 00000000..186be87e --- /dev/null +++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php @@ -0,0 +1,25 @@ +ruleService->rulesForShortUrl($shortUrl); + foreach ($rules as $rule) { + // Return the long URL for the first rule found that matches + if ($rule->matchesRequest($request)) { + return $rule->longUrl; + } + } + + return $shortUrl->getLongUrl(); + } +} diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectionResolverInterface.php b/module/Core/src/RedirectRule/ShortUrlRedirectionResolverInterface.php new file mode 100644 index 00000000..a1dd92a2 --- /dev/null +++ b/module/Core/src/RedirectRule/ShortUrlRedirectionResolverInterface.php @@ -0,0 +1,11 @@ +deviceType, $pair->longUrl); - } - - public function longUrl(): string - { - return $this->longUrl; - } - - public function updateLongUrl(string $longUrl): void - { - $this->longUrl = $longUrl; - } -} diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index e53e9afa..8a577205 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -12,8 +12,6 @@ use Doctrine\Common\Collections\Selectable; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; -use Shlinkio\Shlink\Core\Model\DeviceType; -use Shlinkio\Shlink\Core\ShortUrl\Model\DeviceLongUrlPair; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; @@ -26,13 +24,11 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitType; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use function array_fill_keys; -use function array_map; use function count; -use function Shlinkio\Shlink\Core\enumValues; use function Shlinkio\Shlink\Core\generateRandomShortCode; use function Shlinkio\Shlink\Core\normalizeDate; use function Shlinkio\Shlink\Core\normalizeOptionalDate; +use function sprintf; class ShortUrl extends AbstractEntity { @@ -41,8 +37,6 @@ class ShortUrl extends AbstractEntity private Chronos $dateCreated; /** @var Collection & Selectable */ private Collection & Selectable $visits; - /** @var Collection */ - private Collection $deviceLongUrls; /** @var Collection */ private Collection $tags; private ?Chronos $validSince = null; @@ -90,19 +84,16 @@ class ShortUrl extends AbstractEntity $instance->longUrl = $creation->getLongUrl(); $instance->dateCreated = Chronos::now(); $instance->visits = new ArrayCollection(); - $instance->deviceLongUrls = new ArrayCollection(array_map( - fn (DeviceLongUrlPair $pair) => DeviceLongUrl::fromShortUrlAndPair($instance, $pair), - $creation->deviceLongUrls, - )); $instance->tags = $relationResolver->resolveTags($creation->tags); $instance->validSince = $creation->validSince; $instance->validUntil = $creation->validUntil; $instance->maxVisits = $creation->maxVisits; $instance->customSlugWasProvided = $creation->hasCustomSlug(); $instance->shortCodeLength = $creation->shortCodeLength; - $instance->shortCode = $creation->customSlug ?? generateRandomShortCode( - $instance->shortCodeLength, - $creation->shortUrlMode, + $instance->shortCode = sprintf( + '%s%s', + $creation->pathPrefix ?? '', + $creation->customSlug ?? generateRandomShortCode($instance->shortCodeLength, $creation->shortUrlMode), ); $instance->domain = $relationResolver->resolveDomain($creation->domain); $instance->authorApiKey = $creation->apiKey; @@ -120,7 +111,6 @@ class ShortUrl extends AbstractEntity ?ShortUrlRelationResolverInterface $relationResolver = null, ): self { $meta = [ - ShortUrlInputFilter::VALIDATE_URL => false, ShortUrlInputFilter::LONG_URL => $url->longUrl, ShortUrlInputFilter::DOMAIN => $url->domain, ShortUrlInputFilter::TAGS => $url->tags, @@ -176,21 +166,6 @@ class ShortUrl extends AbstractEntity if ($shortUrlEdit->forwardQueryWasProvided()) { $this->forwardQuery = $shortUrlEdit->forwardQuery; } - - // Update device long URLs, removing, editing or creating where appropriate - foreach ($shortUrlEdit->devicesToRemove as $deviceType) { - $this->deviceLongUrls->remove($deviceType->value); - } - foreach ($shortUrlEdit->deviceLongUrls as $deviceLongUrlPair) { - $key = $deviceLongUrlPair->deviceType->value; - $deviceLongUrl = $this->deviceLongUrls->get($key); - - if ($deviceLongUrl !== null) { - $deviceLongUrl->updateLongUrl($deviceLongUrlPair->longUrl); - } else { - $this->deviceLongUrls->set($key, DeviceLongUrl::fromShortUrlAndPair($this, $deviceLongUrlPair)); - } - } } public function getLongUrl(): string @@ -198,12 +173,6 @@ class ShortUrl extends AbstractEntity return $this->longUrl; } - public function longUrlForDevice(?DeviceType $deviceType): string - { - $deviceLongUrl = $deviceType === null ? null : $this->deviceLongUrls->get($deviceType->value); - return $deviceLongUrl?->longUrl() ?? $this->longUrl; - } - public function getShortCode(): string { return $this->shortCode; @@ -331,14 +300,4 @@ class ShortUrl extends AbstractEntity return true; } - - public function deviceLongUrls(): array - { - $data = array_fill_keys(enumValues(DeviceType::class), null); - foreach ($this->deviceLongUrls as $deviceUrl) { - $data[$deviceUrl->deviceType->value] = $deviceUrl->longUrl(); - } - - return $data; - } } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php index c322f195..3a6a6d06 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php @@ -5,19 +5,21 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Helper; use GuzzleHttp\Psr7\Query; +use Laminas\Diactoros\Uri; use Laminas\Stdlib\ArrayUtils; -use League\Uri\Uri; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\Options\TrackingOptions; +use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectionResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use function sprintf; -class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface +readonly class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface { - public function __construct(private readonly TrackingOptions $trackingOptions) - { + public function __construct( + private TrackingOptions $trackingOptions, + private ShortUrlRedirectionResolverInterface $redirectionResolver, + ) { } public function buildShortUrlRedirect( @@ -25,9 +27,8 @@ class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface ServerRequestInterface $request, ?string $extraPath = null, ): string { + $uri = new Uri($this->redirectionResolver->resolveLongUrl($shortUrl, $request)); $currentQuery = $request->getQueryParams(); - $device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent')); - $uri = Uri::createFromString($shortUrl->longUrlForDevice($device)); $shouldForwardQuery = $shortUrl->forwardQuery(); return $uri @@ -36,9 +37,9 @@ class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface ->__toString(); } - private function resolveQuery(Uri $uri, array $currentQuery): ?string + private function resolveQuery(Uri $uri, array $currentQuery): string { - $hardcodedQuery = Query::parse($uri->getQuery() ?? ''); + $hardcodedQuery = Query::parse($uri->getQuery()); $disableTrackParam = $this->trackingOptions->disableTrackParam; if ($disableTrackParam !== null) { @@ -48,7 +49,7 @@ class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface // We want to merge preserving numeric keys, as some params might be numbers $mergedQuery = ArrayUtils::merge($hardcodedQuery, $currentQuery, true); - return empty($mergedQuery) ? null : Query::build($mergedQuery); + return Query::build($mergedQuery); } private function resolvePath(Uri $uri, ?string $extraPath): string diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php index 71963437..e91b1ff1 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php @@ -4,31 +4,92 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Helper; -use Shlinkio\Shlink\Core\Exception\InvalidUrlException; -use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; +use Fig\Http\Message\RequestMethodInterface; +use GuzzleHttp\ClientInterface; +use GuzzleHttp\RequestOptions; +use Psr\Http\Message\ResponseInterface; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; +use Throwable; -class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface +use function html_entity_decode; +use function preg_match; +use function str_contains; +use function str_starts_with; +use function strtolower; +use function trim; + +use const Shlinkio\Shlink\TITLE_TAG_VALUE; + +readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface { - public function __construct(private readonly UrlValidatorInterface $urlValidator) - { + public const MAX_REDIRECTS = 15; + public const CHROME_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' + . 'Chrome/121.0.0.0 Safari/537.36'; + + public function __construct( + private ClientInterface $httpClient, + private UrlShortenerOptions $options, + ) { } /** - * @deprecated TODO Rename to processTitle once URL validation is removed with Shlink 4.0.0 - * Move relevant logic from URL validator here. * @template T of TitleResolutionModelInterface * @param T $data * @return T - * @throws InvalidUrlException */ - public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface + public function processTitle(TitleResolutionModelInterface $data): TitleResolutionModelInterface { - if ($data->hasTitle()) { - $this->urlValidator->validateUrl($data->getLongUrl(), $data->doValidateUrl()); + if (! $this->options->autoResolveTitles || $data->hasTitle()) { return $data; } - $title = $this->urlValidator->validateUrlWithTitle($data->getLongUrl(), $data->doValidateUrl()); - return $title === null ? $data : $data->withResolvedTitle($title); + $response = $this->fetchUrl($data->getLongUrl()); + if ($response === null) { + return $data; + } + + $contentType = strtolower($response->getHeaderLine('Content-Type')); + if (! str_starts_with($contentType, 'text/html')) { + return $data; + } + + $title = $this->tryToResolveTitle($response); + return $title !== null ? $data->withResolvedTitle($title) : $data; + } + + private function fetchUrl(string $url): ?ResponseInterface + { + try { + return $this->httpClient->request(RequestMethodInterface::METHOD_GET, $url, [ + // Add a sensible 3-second timeout that prevents hanging here forever + RequestOptions::TIMEOUT => 3, + RequestOptions::CONNECT_TIMEOUT => 3, + // Prevent potential infinite redirection loops + RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS], + RequestOptions::IDN_CONVERSION => true, + // Making the request with a browser's user agent results in responses closer to a real user + RequestOptions::HEADERS => ['User-Agent' => self::CHROME_USER_AGENT], + RequestOptions::STREAM => true, // This ensures large files are not fully downloaded if not needed + ]); + } catch (Throwable) { + return null; + } + } + + private function tryToResolveTitle(ResponseInterface $response): ?string + { + $collectedBody = ''; + $body = $response->getBody(); + // With streaming enabled, we can walk the body until the tag is found, and then stop + while (! str_contains($collectedBody, '') && ! $body->eof()) { + $collectedBody .= $body->read(1024); + } + preg_match(TITLE_TAG_VALUE, $collectedBody, $matches); + return isset($matches[1]) ? $this->normalizeTitle($matches[1]) : null; + } + + private function normalizeTitle(string $title): string + { + return html_entity_decode(trim($title)); } } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php index 1861b451..6641460a 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php @@ -4,16 +4,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Helper; -use Shlinkio\Shlink\Core\Exception\InvalidUrlException; - interface ShortUrlTitleResolutionHelperInterface { /** - * @deprecated TODO Rename to processTitle once URL validation is removed with Shlink 4.0.0 * @template T of TitleResolutionModelInterface * @param T $data * @return T - * @throws InvalidUrlException */ - public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface; + public function processTitle(TitleResolutionModelInterface $data): TitleResolutionModelInterface; } diff --git a/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php b/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php index 4c56bfc1..cecd83e1 100644 --- a/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php +++ b/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php @@ -10,8 +10,5 @@ interface TitleResolutionModelInterface public function getLongUrl(): string; - /** @deprecated */ - public function doValidateUrl(): bool; - public function withResolvedTitle(string $title): static; } diff --git a/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php b/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php deleted file mode 100644 index a48c666b..00000000 --- a/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php +++ /dev/null @@ -1,45 +0,0 @@ - $map - * @return array{array, DeviceType[]} - */ - public static function fromMapToChangeSet(array $map): array - { - $pairsToKeep = []; - $deviceTypesToRemove = []; - - foreach ($map as $deviceType => $longUrl) { - if ($longUrl === null) { - $deviceTypesToRemove[] = DeviceType::from($deviceType); - } else { - $pairsToKeep[$deviceType] = self::fromRawTypeAndLongUrl($deviceType, $longUrl); - } - } - - return [$pairsToKeep, $deviceTypesToRemove]; - } -} diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index 43b39874..b0c87f99 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -18,56 +18,48 @@ use function Shlinkio\Shlink\Core\normalizeOptionalDate; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; -final class ShortUrlCreation implements TitleResolutionModelInterface +final readonly class ShortUrlCreation implements TitleResolutionModelInterface { /** * @param string[] $tags - * @param DeviceLongUrlPair[] $deviceLongUrls */ private function __construct( - public readonly string $longUrl, - public readonly ShortUrlMode $shortUrlMode, - public readonly array $deviceLongUrls = [], - public readonly ?Chronos $validSince = null, - public readonly ?Chronos $validUntil = null, - public readonly ?string $customSlug = null, - public readonly ?int $maxVisits = null, - public readonly bool $findIfExists = false, - public readonly ?string $domain = null, - public readonly int $shortCodeLength = 5, - /** @deprecated */ - public readonly bool $validateUrl = false, - public readonly ?ApiKey $apiKey = null, - public readonly array $tags = [], - public readonly ?string $title = null, - public readonly bool $titleWasAutoResolved = false, - public readonly bool $crawlable = false, - public readonly bool $forwardQuery = true, + public string $longUrl, + public ShortUrlMode $shortUrlMode, + public ?Chronos $validSince = null, + public ?Chronos $validUntil = null, + public ?string $customSlug = null, + public ?string $pathPrefix = null, + public ?int $maxVisits = null, + public bool $findIfExists = false, + public ?string $domain = null, + public int $shortCodeLength = 5, + public ?ApiKey $apiKey = null, + public array $tags = [], + public ?string $title = null, + public bool $titleWasAutoResolved = false, + public bool $crawlable = false, + public bool $forwardQuery = true, ) { } /** * @throws ValidationException */ - public static function fromRawData(array $data, ?UrlShortenerOptions $options = null): self + public static function fromRawData(array $data, UrlShortenerOptions $options = new UrlShortenerOptions()): self { - $options = $options ?? new UrlShortenerOptions(); - $inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data, $options); + $inputFilter = ShortUrlInputFilter::forCreation($data, $options); if (! $inputFilter->isValid()) { throw ValidationException::fromInputFilter($inputFilter); } - [$deviceLongUrls] = DeviceLongUrlPair::fromMapToChangeSet( - $inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [], - ); - return new self( longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL), shortUrlMode: $options->mode, - deviceLongUrls: $deviceLongUrls, validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)), validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)), customSlug: $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG), + pathPrefix: $inputFilter->getValue(ShortUrlInputFilter::PATH_PREFIX), maxVisits: getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS), findIfExists: $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS) ?? false, domain: getNonEmptyOptionalValueFromInputFilter($inputFilter, ShortUrlInputFilter::DOMAIN), @@ -75,7 +67,6 @@ final class ShortUrlCreation implements TitleResolutionModelInterface $inputFilter, ShortUrlInputFilter::SHORT_CODE_LENGTH, ) ?? DEFAULT_SHORT_CODES_LENGTH, - validateUrl: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false, apiKey: $inputFilter->getValue(ShortUrlInputFilter::API_KEY), tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS), title: $inputFilter->getValue(ShortUrlInputFilter::TITLE), @@ -89,15 +80,14 @@ final class ShortUrlCreation implements TitleResolutionModelInterface return new self( longUrl: $this->longUrl, shortUrlMode: $this->shortUrlMode, - deviceLongUrls: $this->deviceLongUrls, validSince: $this->validSince, validUntil: $this->validUntil, customSlug: $this->customSlug, + pathPrefix: $this->pathPrefix, maxVisits: $this->maxVisits, findIfExists: $this->findIfExists, domain: $this->domain, shortCodeLength: $this->shortCodeLength, - validateUrl: $this->validateUrl, apiKey: $this->apiKey, tags: $this->tags, title: $title, @@ -137,12 +127,6 @@ final class ShortUrlCreation implements TitleResolutionModelInterface return $this->domain !== null; } - /** @deprecated */ - public function doValidateUrl(): bool - { - return $this->validateUrl; - } - public function hasTitle(): bool { return $this->title !== null; diff --git a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php index fe92fae8..36a99f5f 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php @@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model; use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Exception\ValidationException; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; @@ -15,35 +14,29 @@ use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; use function Shlinkio\Shlink\Core\normalizeOptionalDate; -final class ShortUrlEdition implements TitleResolutionModelInterface +final readonly class ShortUrlEdition implements TitleResolutionModelInterface { /** * @param string[] $tags - * @param DeviceLongUrlPair[] $deviceLongUrls - * @param DeviceType[] $devicesToRemove */ private function __construct( - private readonly bool $longUrlPropWasProvided = false, - public readonly ?string $longUrl = null, - public readonly array $deviceLongUrls = [], - public readonly array $devicesToRemove = [], - private readonly bool $validSincePropWasProvided = false, - public readonly ?Chronos $validSince = null, - private readonly bool $validUntilPropWasProvided = false, - public readonly ?Chronos $validUntil = null, - private readonly bool $maxVisitsPropWasProvided = false, - public readonly ?int $maxVisits = null, - private readonly bool $tagsPropWasProvided = false, - public readonly array $tags = [], - private readonly bool $titlePropWasProvided = false, - public readonly ?string $title = null, - public readonly bool $titleWasAutoResolved = false, - /** @deprecated */ - public readonly bool $validateUrl = false, - private readonly bool $crawlablePropWasProvided = false, - public readonly bool $crawlable = false, - private readonly bool $forwardQueryPropWasProvided = false, - public readonly bool $forwardQuery = true, + private bool $longUrlPropWasProvided = false, + public ?string $longUrl = null, + private bool $validSincePropWasProvided = false, + public ?Chronos $validSince = null, + private bool $validUntilPropWasProvided = false, + public ?Chronos $validUntil = null, + private bool $maxVisitsPropWasProvided = false, + public ?int $maxVisits = null, + private bool $tagsPropWasProvided = false, + public array $tags = [], + private bool $titlePropWasProvided = false, + public ?string $title = null, + public bool $titleWasAutoResolved = false, + private bool $crawlablePropWasProvided = false, + public bool $crawlable = false, + private bool $forwardQueryPropWasProvided = false, + public bool $forwardQuery = true, ) { } @@ -52,20 +45,14 @@ final class ShortUrlEdition implements TitleResolutionModelInterface */ public static function fromRawData(array $data): self { - $inputFilter = ShortUrlInputFilter::withNonRequiredLongUrl($data); + $inputFilter = ShortUrlInputFilter::forEdition($data); if (! $inputFilter->isValid()) { throw ValidationException::fromInputFilter($inputFilter); } - [$deviceLongUrls, $devicesToRemove] = DeviceLongUrlPair::fromMapToChangeSet( - $inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [], - ); - return new self( longUrlPropWasProvided: array_key_exists(ShortUrlInputFilter::LONG_URL, $data), longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL), - deviceLongUrls: $deviceLongUrls, - devicesToRemove: $devicesToRemove, validSincePropWasProvided: array_key_exists(ShortUrlInputFilter::VALID_SINCE, $data), validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)), validUntilPropWasProvided: array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data), @@ -76,7 +63,6 @@ final class ShortUrlEdition implements TitleResolutionModelInterface tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS), titlePropWasProvided: array_key_exists(ShortUrlInputFilter::TITLE, $data), title: $inputFilter->getValue(ShortUrlInputFilter::TITLE), - validateUrl: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false, crawlablePropWasProvided: array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data), crawlable: $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE), forwardQueryPropWasProvided: array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data), @@ -89,8 +75,6 @@ final class ShortUrlEdition implements TitleResolutionModelInterface return new self( longUrlPropWasProvided: $this->longUrlPropWasProvided, longUrl: $this->longUrl, - deviceLongUrls: $this->deviceLongUrls, - devicesToRemove: $this->devicesToRemove, validSincePropWasProvided: $this->validSincePropWasProvided, validSince: $this->validSince, validUntilPropWasProvided: $this->validUntilPropWasProvided, @@ -102,7 +86,6 @@ final class ShortUrlEdition implements TitleResolutionModelInterface titlePropWasProvided: $this->titlePropWasProvided, title: $title, titleWasAutoResolved: true, - validateUrl: $this->validateUrl, crawlablePropWasProvided: $this->crawlablePropWasProvided, crawlable: $this->crawlable, forwardQueryPropWasProvided: $this->forwardQueryPropWasProvided, @@ -155,12 +138,6 @@ final class ShortUrlEdition implements TitleResolutionModelInterface return $this->titleWasAutoResolved; } - /** @deprecated */ - public function doValidateUrl(): bool - { - return $this->validateUrl; - } - public function crawlableWasProvided(): bool { return $this->crawlablePropWasProvided; diff --git a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php index 7ec19df6..a7c2e2ff 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php @@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; -use Symfony\Component\Console\Input\InputInterface; use function sprintf; @@ -32,18 +31,6 @@ final readonly class ShortUrlIdentifier return new self($shortCode, $domain); } - public static function fromCli(InputInterface $input): self - { - // Using getArguments and getOptions instead of getArgument(...) and getOption(...) because - // the later throw an exception if requested options are not defined - /** @var string $shortCode */ - $shortCode = $input->getArguments()['shortCode'] ?? ''; - /** @var string|null $domain */ - $domain = $input->getOptions()['domain'] ?? null; - - return new self($shortCode, $domain); - } - public static function fromShortUrl(ShortUrl $shortUrl): self { $domain = $shortUrl->getDomain(); diff --git a/module/Core/src/ShortUrl/Model/ShortUrlMode.php b/module/Core/src/ShortUrl/Model/ShortUrlMode.php index d359e8cc..19886657 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlMode.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlMode.php @@ -6,10 +6,4 @@ enum ShortUrlMode: string { case STRICT = 'strict'; case LOOSE = 'loose'; - - /** @deprecated */ - public static function tryDeprecated(string $mode): ?self - { - return $mode === 'loosely' ? self::LOOSE : self::tryFrom($mode); - } } diff --git a/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php b/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php index d7012bf1..2512fc44 100644 --- a/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php @@ -12,9 +12,9 @@ use function str_replace; use function strtolower; use function trim; -class CustomSlugFilter implements FilterInterface +readonly class CustomSlugFilter implements FilterInterface { - public function __construct(private readonly UrlShortenerOptions $options) + public function __construct(private UrlShortenerOptions $options) { } @@ -25,9 +25,8 @@ class CustomSlugFilter implements FilterInterface } $value = $this->options->isLooseMode() ? strtolower($value) : $value; - return (match ($this->options->multiSegmentSlugsEnabled) { - true => trim(str_replace(' ', '-', $value), '/'), - false => str_replace([' ', '/'], '-', $value), - }); + return $this->options->multiSegmentSlugsEnabled + ? trim(str_replace(' ', '-', $value), '/') + : str_replace([' ', '/'], '-', $value); } } diff --git a/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php b/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php deleted file mode 100644 index 82119e4e..00000000 --- a/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php +++ /dev/null @@ -1,57 +0,0 @@ - 'Provided value is not an array.', - self::INVALID_DEVICE => 'You have provided at least one invalid device identifier.', - self::INVALID_LONG_URL => 'At least one of the long URLs are invalid.', - ]; - - public function __construct(private readonly ValidatorInterface $longUrlValidators) - { - parent::__construct(); - } - - public function isValid(mixed $value): bool - { - if (! is_array($value)) { - $this->error(self::NOT_ARRAY); - return false; - } - - $validValues = enumValues(DeviceType::class); - $keys = array_keys($value); - if (! every($keys, static fn ($key) => contains($key, $validValues))) { - $this->error(self::INVALID_DEVICE); - return false; - } - - $longUrls = array_values($value); - $result = every($longUrls, $this->longUrlValidators->isValid(...)); - if (! $result) { - $this->error(self::INVALID_LONG_URL); - } - - return $result; - } -} diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index 23ac8a2f..e8d35284 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -8,7 +8,8 @@ use DateTimeInterface; use Laminas\Filter; use Laminas\InputFilter\InputFilter; use Laminas\Validator; -use Shlinkio\Shlink\Common\Validation; +use Shlinkio\Shlink\Common\Validation\HostAndPortValidator; +use Shlinkio\Shlink\Common\Validation\InputFactory; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -19,70 +20,49 @@ use function substr; use const Shlinkio\Shlink\LOOSE_URI_MATCHER; use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH; -/** - * @todo Pass forCreation/forEdition, instead of withRequiredLongUrl/withNonRequiredLongUrl. - * Make it also dynamically add the relevant fields - */ class ShortUrlInputFilter extends InputFilter { - use Validation\InputFactoryTrait; - - public const VALID_SINCE = 'validSince'; - public const VALID_UNTIL = 'validUntil'; + // Fields for creation only + public const SHORT_CODE_LENGTH = 'shortCodeLength'; public const CUSTOM_SLUG = 'customSlug'; - public const MAX_VISITS = 'maxVisits'; + public const PATH_PREFIX = 'pathPrefix'; public const FIND_IF_EXISTS = 'findIfExists'; public const DOMAIN = 'domain'; - public const SHORT_CODE_LENGTH = 'shortCodeLength'; + + // Fields for creation and edition public const LONG_URL = 'longUrl'; - public const DEVICE_LONG_URLS = 'deviceLongUrls'; - /** @deprecated */ - public const VALIDATE_URL = 'validateUrl'; - public const API_KEY = 'apiKey'; - public const TAGS = 'tags'; + public const VALID_SINCE = 'validSince'; + public const VALID_UNTIL = 'validUntil'; + public const MAX_VISITS = 'maxVisits'; public const TITLE = 'title'; + public const TAGS = 'tags'; public const CRAWLABLE = 'crawlable'; public const FORWARD_QUERY = 'forwardQuery'; + public const API_KEY = 'apiKey'; - private function __construct(array $data, bool $requireLongUrl, UrlShortenerOptions $options) + public static function forCreation(array $data, UrlShortenerOptions $options): self { - $this->initialize($requireLongUrl, $options); - $this->setData($data); + $instance = new self(); + $instance->initializeForCreation($options); + $instance->setData($data); + + return $instance; } - public static function withRequiredLongUrl(array $data, UrlShortenerOptions $options): self + public static function forEdition(array $data): self { - return new self($data, true, $options); + $instance = new self(); + $instance->initializeForEdition(); + $instance->setData($data); + + return $instance; } - public static function withNonRequiredLongUrl(array $data): self + private function initializeForCreation(UrlShortenerOptions $options): void { - return new self($data, false, new UrlShortenerOptions()); - } - - private function initialize(bool $requireLongUrl, UrlShortenerOptions $options): void - { - $longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl); - $longUrlInput->getValidatorChain()->merge($this->longUrlValidators()); - $this->add($longUrlInput); - - $deviceLongUrlsInput = $this->createInput(self::DEVICE_LONG_URLS, false); - $deviceLongUrlsInput->getValidatorChain()->attach( - new DeviceLongUrlsValidator($this->longUrlValidators(allowNull: ! $requireLongUrl)), - ); - $this->add($deviceLongUrlsInput); - - $validSince = $this->createInput(self::VALID_SINCE, false); - $validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM])); - $this->add($validSince); - - $validUntil = $this->createInput(self::VALID_UNTIL, false); - $validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM])); - $this->add($validUntil); - // The only way to enforce the NotEmpty validator to be evaluated when the key is present with an empty value - // is with setContinueIfEmpty - $customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true); + // is with setContinueIfEmpty(true) + $customSlug = InputFactory::basic(self::CUSTOM_SLUG)->setContinueIfEmpty(true); $customSlug->getFilterChain()->attach(new CustomSlugFilter($options)); $customSlug->getValidatorChain() ->attach(new Validator\NotEmpty([ @@ -92,36 +72,62 @@ class ShortUrlInputFilter extends InputFilter ->attach(CustomSlugValidator::forUrlShortenerOptions($options)); $this->add($customSlug); - $this->add($this->createNumericInput(self::MAX_VISITS, false)); - $this->add($this->createNumericInput(self::SHORT_CODE_LENGTH, false, MIN_SHORT_CODES_LENGTH)); + // The path prefix is subject to the same filtering and validation logic as the custom slug, which takes into + // consideration if multi-segment slugs are enabled or not. + // The only difference is that empty values are allowed here. + $pathPrefix = InputFactory::basic(self::PATH_PREFIX); + $pathPrefix->getFilterChain()->attach(new CustomSlugFilter($options)); + $pathPrefix->getValidatorChain()->attach(CustomSlugValidator::forUrlShortenerOptions($options)); + $this->add($pathPrefix); - $this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false)); + $this->add(InputFactory::numeric(self::SHORT_CODE_LENGTH, min: MIN_SHORT_CODES_LENGTH)); + $this->add(InputFactory::boolean(self::FIND_IF_EXISTS)); - // These cannot be defined as a boolean inputs, because they can actually have 3 values: true, false and null. - // Defining them as boolean will make null fall back to false, which is not the desired behavior. - $this->add($this->createInput(self::VALIDATE_URL, false)); - $this->add($this->createInput(self::FORWARD_QUERY, false)); - - $domain = $this->createInput(self::DOMAIN, false); - $domain->getValidatorChain()->attach(new Validation\HostAndPortValidator()); + $domain = InputFactory::basic(self::DOMAIN); + $domain->getValidatorChain()->attach(new HostAndPortValidator()); $this->add($domain); - $apiKeyInput = $this->createInput(self::API_KEY, false); - $apiKeyInput->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class])); - $this->add($apiKeyInput); + $this->initializeForEdition(requireLongUrl: true); + } - $this->add($this->createTagsInput(self::TAGS, false)); + private function initializeForEdition(bool $requireLongUrl = false): void + { + $longUrlInput = InputFactory::basic(self::LONG_URL, required: $requireLongUrl); + $longUrlInput->getValidatorChain()->merge(self::longUrlValidators(allowNull: ! $requireLongUrl)); + $this->add($longUrlInput); - $title = $this->createInput(self::TITLE, false); + $validSince = InputFactory::basic(self::VALID_SINCE); + $validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM])); + $this->add($validSince); + + $validUntil = InputFactory::basic(self::VALID_UNTIL); + $validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM])); + $this->add($validUntil); + + $this->add(InputFactory::numeric(self::MAX_VISITS)); + + $title = InputFactory::basic(self::TITLE); $title->getFilterChain()->attach(new Filter\Callback( static fn (?string $value) => $value === null ? $value : substr($value, 0, 512), )); $this->add($title); - $this->add($this->createBooleanInput(self::CRAWLABLE, false)); + $this->add(InputFactory::tags(self::TAGS)); + $this->add(InputFactory::boolean(self::CRAWLABLE)); + + // This cannot be defined as a boolean inputs, because it can actually have 3 values: true, false and null. + // Defining them as boolean will make null fall back to false, which is not the desired behavior. + $this->add(InputFactory::basic(self::FORWARD_QUERY)); + + $apiKeyInput = InputFactory::basic(self::API_KEY); + $apiKeyInput->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class])); + $this->add($apiKeyInput); } - private function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain + /** + * @todo Extract to its own validator class + */ + public static function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain { $emptyModifiers = [ Validator\NotEmpty::OBJECT, diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php index d7cda41e..f4f7c338 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation; use Laminas\InputFilter\InputFilter; use Laminas\Validator\InArray; use Shlinkio\Shlink\Common\Paginator\Paginator; -use Shlinkio\Shlink\Common\Validation; +use Shlinkio\Shlink\Common\Validation\InputFactory; use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; @@ -15,8 +15,6 @@ use function Shlinkio\Shlink\Core\enumValues; class ShortUrlsParamsInputFilter extends InputFilter { - use Validation\InputFactoryTrait; - public const PAGE = 'page'; public const SEARCH_TERM = 'searchTerm'; public const TAGS = 'tags'; @@ -36,26 +34,26 @@ class ShortUrlsParamsInputFilter extends InputFilter private function initialize(): void { - $this->add($this->createDateInput(self::START_DATE, false)); - $this->add($this->createDateInput(self::END_DATE, false)); + $this->add(InputFactory::date(self::START_DATE)); + $this->add(InputFactory::date(self::END_DATE)); - $this->add($this->createInput(self::SEARCH_TERM, false)); + $this->add(InputFactory::basic(self::SEARCH_TERM)); - $this->add($this->createNumericInput(self::PAGE, false)); - $this->add($this->createNumericInput(self::ITEMS_PER_PAGE, false, Paginator::ALL_ITEMS)); + $this->add(InputFactory::numeric(self::PAGE)); + $this->add(InputFactory::numeric(self::ITEMS_PER_PAGE, Paginator::ALL_ITEMS)); - $this->add($this->createTagsInput(self::TAGS, false)); + $this->add(InputFactory::tags(self::TAGS)); - $tagsMode = $this->createInput(self::TAGS_MODE, false); + $tagsMode = InputFactory::basic(self::TAGS_MODE); $tagsMode->getValidatorChain()->attach(new InArray([ 'haystack' => enumValues(TagsMode::class), 'strict' => InArray::COMPARE_STRICT, ])); $this->add($tagsMode); - $this->add($this->createOrderByInput(self::ORDER_BY, enumValues(OrderableField::class))); + $this->add(InputFactory::orderBy(self::ORDER_BY, enumValues(OrderableField::class))); - $this->add($this->createBooleanInput(self::EXCLUDE_MAX_VISITS_REACHED, false)); - $this->add($this->createBooleanInput(self::EXCLUDE_PAST_VALID_UNTIL, false)); + $this->add(InputFactory::boolean(self::EXCLUDE_MAX_VISITS_REACHED)); + $this->add(InputFactory::boolean(self::EXCLUDE_PAST_VALID_UNTIL)); } } diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index e014ac64..d6f7e421 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -55,15 +55,15 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh if (OrderableField::isBasicField($fieldName)) { $qb->orderBy('s.' . $fieldName, $order); } elseif (OrderableField::isVisitsField($fieldName)) { + $leftJoinConditions = [$qb->expr()->eq('v.shortUrl', 's')]; + if ($fieldName === OrderableField::NON_BOT_VISITS->value) { + $leftJoinConditions[] = $qb->expr()->eq('v.potentialBot', 'false'); + } + // FIXME This query is inefficient. // Diagnostic: It might need to use a sub-query, as done with the tags list query. $qb->addSelect('COUNT(DISTINCT v)') - ->leftJoin('s.visits', 'v', Join::WITH, $qb->expr()->andX( - $qb->expr()->eq('v.shortUrl', 's'), - $fieldName === OrderableField::NON_BOT_VISITS->value - ? $qb->expr()->eq('v.potentialBot', 'false') - : null, - )) + ->leftJoin('s.visits', 'v', Join::WITH, $qb->expr()->andX(...$leftJoinConditions)) ->groupBy('s') ->orderBy('COUNT(DISTINCT v)', $order); } diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php index 05800abd..e151a6c7 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php @@ -72,7 +72,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU /** * @param LockMode::PESSIMISTIC_WRITE|null $lockMode */ - private function doShortCodeIsInUse(ShortUrlIdentifier $identifier, ?Specification $spec, ?int $lockMode): bool + private function doShortCodeIsInUse(ShortUrlIdentifier $identifier, ?Specification $spec, ?LockMode $lockMode): bool { $qb = $this->createFindOneQueryBuilder($identifier, $spec)->select('s.id'); $query = $qb->getQuery(); diff --git a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php index 6c49ab5f..3aa6c887 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -79,6 +79,7 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt return new Collections\ArrayCollection(array_map(function (string $tagName) use ($repo): Tag { $this->lock($this->tagLocks, 'tag_' . $tagName); + /** @var Tag|null $existingTag */ $existingTag = $repo->findOneBy(['name' => $tagName]); if ($existingTag) { $this->releaseLock($this->tagLocks, 'tag_' . $tagName); diff --git a/module/Core/src/ShortUrl/ShortUrlResolver.php b/module/Core/src/ShortUrl/ShortUrlResolver.php index 4fd0d015..42d274c0 100644 --- a/module/Core/src/ShortUrl/ShortUrlResolver.php +++ b/module/Core/src/ShortUrl/ShortUrlResolver.php @@ -48,6 +48,9 @@ readonly class ShortUrlResolver implements ShortUrlResolverInterface return $shortUrl; } + /** + * @throws ShortUrlNotFoundException + */ public function resolvePublicShortUrl(ShortUrlIdentifier $identifier): ShortUrl { /** @var ShortUrlRepository $shortUrlRepo */ diff --git a/module/Core/src/ShortUrl/ShortUrlService.php b/module/Core/src/ShortUrl/ShortUrlService.php index 95561fc5..d75f847d 100644 --- a/module/Core/src/ShortUrl/ShortUrlService.php +++ b/module/Core/src/ShortUrl/ShortUrlService.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl; use Doctrine\ORM; -use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface; @@ -14,19 +13,18 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; -class ShortUrlService implements ShortUrlServiceInterface +readonly class ShortUrlService implements ShortUrlServiceInterface { public function __construct( - private readonly ORM\EntityManagerInterface $em, - private readonly ShortUrlResolverInterface $urlResolver, - private readonly ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, - private readonly ShortUrlRelationResolverInterface $relationResolver, + private ORM\EntityManagerInterface $em, + private ShortUrlResolverInterface $urlResolver, + private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, + private ShortUrlRelationResolverInterface $relationResolver, ) { } /** * @throws ShortUrlNotFoundException - * @throws InvalidUrlException */ public function updateShortUrl( ShortUrlIdentifier $identifier, @@ -34,7 +32,7 @@ class ShortUrlService implements ShortUrlServiceInterface ?ApiKey $apiKey = null, ): ShortUrl { if ($shortUrlEdit->longUrlWasProvided()) { - $shortUrlEdit = $this->titleResolutionHelper->processTitleAndValidateUrl($shortUrlEdit); + $shortUrlEdit = $this->titleResolutionHelper->processTitle($shortUrlEdit); } $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey); diff --git a/module/Core/src/ShortUrl/ShortUrlServiceInterface.php b/module/Core/src/ShortUrl/ShortUrlServiceInterface.php index 3365374e..c7892f55 100644 --- a/module/Core/src/ShortUrl/ShortUrlServiceInterface.php +++ b/module/Core/src/ShortUrl/ShortUrlServiceInterface.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl; -use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; @@ -15,7 +14,6 @@ interface ShortUrlServiceInterface { /** * @throws ShortUrlNotFoundException - * @throws InvalidUrlException */ public function updateShortUrl( ShortUrlIdentifier $identifier, diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php index a6641998..ea694c61 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -27,7 +27,6 @@ class ShortUrlDataTransformer implements DataTransformerInterface 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => $this->stringifier->stringify($shortUrl), 'longUrl' => $shortUrl->getLongUrl(), - 'deviceLongUrls' => $shortUrl->deviceLongUrls(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), 'tags' => array_map(static fn (Tag $tag) => $tag->__toString(), $shortUrl->getTags()->toArray()), 'meta' => $this->buildMeta($shortUrl), @@ -39,9 +38,6 @@ class ShortUrlDataTransformer implements DataTransformerInterface $shortUrl->getVisitsCount(), $shortUrl->nonBotVisitsCount(), ), - - // Deprecated - 'visitsCount' => $shortUrl->getVisitsCount(), ]; } diff --git a/module/Core/src/ShortUrl/UrlShortener.php b/module/Core/src/ShortUrl/UrlShortener.php index 7bb74ba6..4a908c78 100644 --- a/module/Core/src/ShortUrl/UrlShortener.php +++ b/module/Core/src/ShortUrl/UrlShortener.php @@ -8,7 +8,6 @@ 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; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelperInterface; @@ -31,7 +30,6 @@ class UrlShortener implements UrlShortenerInterface /** * @throws NonUniqueSlugException - * @throws InvalidUrlException */ public function shorten(ShortUrlCreation $creation): UrlShorteningResult { @@ -41,7 +39,7 @@ class UrlShortener implements UrlShortenerInterface return UrlShorteningResult::withoutErrorOnEventDispatching($existingShortUrl); } - $creation = $this->titleResolutionHelper->processTitleAndValidateUrl($creation); + $creation = $this->titleResolutionHelper->processTitle($creation); /** @var ShortUrl $newShortUrl */ $newShortUrl = $this->em->wrapInTransaction(function () use ($creation): ShortUrl { @@ -57,7 +55,7 @@ class UrlShortener implements UrlShortenerInterface $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 + // When using RoadRunner, 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); diff --git a/module/Core/src/ShortUrl/UrlShortenerInterface.php b/module/Core/src/ShortUrl/UrlShortenerInterface.php index 70896ec1..7da0aaef 100644 --- a/module/Core/src/ShortUrl/UrlShortenerInterface.php +++ b/module/Core/src/ShortUrl/UrlShortenerInterface.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl; -use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\UrlShorteningResult; @@ -13,7 +12,6 @@ interface UrlShortenerInterface { /** * @throws NonUniqueSlugException - * @throws InvalidUrlException */ public function shorten(ShortUrlCreation $creation): UrlShorteningResult; } diff --git a/module/Core/src/Tag/Model/OrderableField.php b/module/Core/src/Tag/Model/OrderableField.php index b7a9509f..39092e4d 100644 --- a/module/Core/src/Tag/Model/OrderableField.php +++ b/module/Core/src/Tag/Model/OrderableField.php @@ -10,15 +10,13 @@ enum OrderableField: string case SHORT_URLS_COUNT = 'shortUrlsCount'; case VISITS = 'visits'; case NON_BOT_VISITS = 'nonBotVisits'; - /** @deprecated Use VISITS instead */ - case VISITS_COUNT = 'visitsCount'; - public static function toSnakeCaseValidField(?string $field): self + public static function toValidField(?string $field): self { - $parsed = $field !== null ? self::tryFrom($field) : self::TAG; - return match ($parsed) { - self::VISITS_COUNT, null => self::VISITS, - default => $parsed, - }; + if ($field === null) { + return self::TAG; + } + + return self::tryFrom($field) ?? self::TAG; } } diff --git a/module/Core/src/Tag/Model/TagInfo.php b/module/Core/src/Tag/Model/TagInfo.php index 4c0018b2..504181ec 100644 --- a/module/Core/src/Tag/Model/TagInfo.php +++ b/module/Core/src/Tag/Model/TagInfo.php @@ -7,13 +7,13 @@ namespace Shlinkio\Shlink\Core\Tag\Model; use JsonSerializable; use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary; -final class TagInfo implements JsonSerializable +final readonly class TagInfo implements JsonSerializable { - public readonly VisitsSummary $visitsSummary; + public VisitsSummary $visitsSummary; public function __construct( - public readonly string $tag, - public readonly int $shortUrlsCount, + public string $tag, + public int $shortUrlsCount, int $visitsCount, ?int $nonBotVisitsCount = null, ) { @@ -36,9 +36,6 @@ final class TagInfo implements JsonSerializable 'tag' => $this->tag, 'shortUrlsCount' => $this->shortUrlsCount, 'visitsSummary' => $this->visitsSummary, - - // Deprecated - 'visitsCount' => $this->visitsSummary->total, ]; } } diff --git a/module/Core/src/Tag/Model/TagsParams.php b/module/Core/src/Tag/Model/TagsParams.php index 3b1d84b2..422f9da1 100644 --- a/module/Core/src/Tag/Model/TagsParams.php +++ b/module/Core/src/Tag/Model/TagsParams.php @@ -14,8 +14,6 @@ final class TagsParams extends AbstractInfinitePaginableListParams private function __construct( public readonly ?string $searchTerm, public readonly Ordering $orderBy, - /** @deprecated */ - public readonly bool $withStats, ?int $page, ?int $itemsPerPage, ) { @@ -27,7 +25,6 @@ final class TagsParams extends AbstractInfinitePaginableListParams return new self( $query['searchTerm'] ?? null, Ordering::fromTuple(isset($query['orderBy']) ? parseOrderBy($query['orderBy']) : [null, null]), - ($query['withStats'] ?? null) === 'true', isset($query['page']) ? (int) $query['page'] : null, isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null, ); diff --git a/module/Core/src/Tag/Repository/TagRepository.php b/module/Core/src/Tag/Repository/TagRepository.php index ce8b1f76..0f113776 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -43,7 +43,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito */ public function findTagsWithInfo(?TagsListFiltering $filtering = null): array { - $orderField = OrderableField::toSnakeCaseValidField($filtering?->orderBy?->field); + $orderField = OrderableField::toValidField($filtering?->orderBy?->field); $orderDir = $filtering?->orderBy?->direction ?? 'ASC'; $apiKey = $filtering?->apiKey; $conn = $this->getEntityManager()->getConnection(); @@ -82,12 +82,12 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito : $visitsSubQb->expr()->and( $commonJoinCondition, $visitsSubQb->expr()->eq('v.potential_bot', $conn->quote('0')), - ); + )->__toString(); return $visitsSubQb ->select('st.tag_id AS tag_id', 'COUNT(DISTINCT v.id) AS ' . $aggregateAlias) ->from('visits', 'v') - ->join('v', 'short_urls', 's', $visitsJoin) // @phpstan-ignore-line + ->join('v', 'short_urls', 's', $visitsJoin) ->join('s', 'short_urls_in_tags', 'st', $visitsSubQb->expr()->eq('st.short_url_id', 's.id')) ->groupBy('st.tag_id'); }; diff --git a/module/Core/src/Util/UrlValidator.php b/module/Core/src/Util/UrlValidator.php deleted file mode 100644 index 1ab3e8f8..00000000 --- a/module/Core/src/Util/UrlValidator.php +++ /dev/null @@ -1,116 +0,0 @@ -validateUrlAndGetResponse($url); - } - - /** - * @deprecated - * @throws InvalidUrlException - */ - public function validateUrlWithTitle(string $url, bool $doValidate): ?string - { - if (! $doValidate && ! $this->options->autoResolveTitles) { - return null; - } - - if (! $this->options->autoResolveTitles) { - $this->validateUrlAndGetResponse($url, self::METHOD_HEAD); - return null; - } - - $response = $doValidate ? $this->validateUrlAndGetResponse($url) : $this->getResponse($url); - if ($response === null) { - return null; - } - - $contentType = strtolower($response->getHeaderLine('Content-Type')); - if (! str_starts_with($contentType, 'text/html')) { - return null; - } - - $collectedBody = ''; - $body = $response->getBody(); - // With streaming enabled, we can walk the body until the tag is found, and then stop - while (! str_contains($collectedBody, '') && ! $body->eof()) { - $collectedBody .= $body->read(1024); - } - preg_match(TITLE_TAG_VALUE, $collectedBody, $matches); - return isset($matches[1]) ? $this->normalizeTitle($matches[1]) : null; - } - - /** - * @param self::METHOD_GET|self::METHOD_HEAD $method - * @throws InvalidUrlException - */ - private function validateUrlAndGetResponse(string $url, string $method = self::METHOD_GET): ResponseInterface - { - try { - return $this->httpClient->request($method, $url, [ - RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS], - RequestOptions::IDN_CONVERSION => true, - // Making the request with a browser's user agent makes the validation closer to a real user - RequestOptions::HEADERS => ['User-Agent' => self::CHROME_USER_AGENT], - RequestOptions::STREAM => true, // This ensures large files are not fully downloaded if not needed - ]); - } catch (GuzzleException $e) { - throw InvalidUrlException::fromUrl($url, $e); - } - } - - private function getResponse(string $url): ?ResponseInterface - { - try { - return $this->validateUrlAndGetResponse($url); - } catch (Throwable) { - return null; - } - } - - private function normalizeTitle(string $title): string - { - return html_entity_decode(trim($title)); - } -} diff --git a/module/Core/src/Util/UrlValidatorInterface.php b/module/Core/src/Util/UrlValidatorInterface.php deleted file mode 100644 index cb38dc42..00000000 --- a/module/Core/src/Util/UrlValidatorInterface.php +++ /dev/null @@ -1,23 +0,0 @@ -dateRange, + page: $visitsParams->page, + itemsPerPage: $visitsParams->itemsPerPage, + excludeBots: $visitsParams->excludeBots, + type: $type !== null ? self::parseType($type) : null, + ); + } + + private static function parseType(string $type): OrphanVisitType + { + try { + return OrphanVisitType::from($type); + } catch (ValueError) { + throw ValidationException::fromArray([ + 'type' => sprintf( + '%s is not a valid orphan visit type. Expected one of %s', + $type, + enumToString(OrphanVisitType::class), + ), + ]); + } + } +} diff --git a/module/Core/src/Visit/Model/VisitType.php b/module/Core/src/Visit/Model/VisitType.php index 5352c2f1..bac726c3 100644 --- a/module/Core/src/Visit/Model/VisitType.php +++ b/module/Core/src/Visit/Model/VisitType.php @@ -8,7 +8,7 @@ enum VisitType: string { case VALID_SHORT_URL = 'valid_short_url'; case IMPORTED = 'imported'; - case INVALID_SHORT_URL = 'invalid_short_url'; - case BASE_URL = 'base_url'; - case REGULAR_404 = 'regular_404'; + case INVALID_SHORT_URL = OrphanVisitType::INVALID_SHORT_URL->value; + case BASE_URL = OrphanVisitType::BASE_URL->value; + case REGULAR_404 = OrphanVisitType::REGULAR_404->value; } diff --git a/module/Core/src/Visit/Model/VisitsParams.php b/module/Core/src/Visit/Model/VisitsParams.php index 90ca4770..10713131 100644 --- a/module/Core/src/Visit/Model/VisitsParams.php +++ b/module/Core/src/Visit/Model/VisitsParams.php @@ -9,7 +9,7 @@ use Shlinkio\Shlink\Core\Model\AbstractInfinitePaginableListParams; use function Shlinkio\Shlink\Core\parseDateRangeFromQuery; -final class VisitsParams extends AbstractInfinitePaginableListParams +class VisitsParams extends AbstractInfinitePaginableListParams { public readonly DateRange $dateRange; diff --git a/module/Core/src/Visit/Model/VisitsStats.php b/module/Core/src/Visit/Model/VisitsStats.php index adac34eb..22f05bd4 100644 --- a/module/Core/src/Visit/Model/VisitsStats.php +++ b/module/Core/src/Visit/Model/VisitsStats.php @@ -6,10 +6,10 @@ namespace Shlinkio\Shlink\Core\Visit\Model; use JsonSerializable; -final class VisitsStats implements JsonSerializable +final readonly class VisitsStats implements JsonSerializable { - private readonly VisitsSummary $nonOrphanVisitsSummary; - private readonly VisitsSummary $orphanVisitsSummary; + private VisitsSummary $nonOrphanVisitsSummary; + private VisitsSummary $orphanVisitsSummary; public function __construct( int $nonOrphanVisitsTotal, @@ -32,10 +32,6 @@ final class VisitsStats implements JsonSerializable return [ 'nonOrphanVisits' => $this->nonOrphanVisitsSummary, 'orphanVisits' => $this->orphanVisitsSummary, - - // Deprecated - 'visitsCount' => $this->nonOrphanVisitsSummary->total, - 'orphanVisitsCount' => $this->orphanVisitsSummary->total, ]; } } diff --git a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php index e871d125..863460a1 100644 --- a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php @@ -5,9 +5,9 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter; use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter; -use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; -use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; -use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -15,26 +15,28 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte { public function __construct( private readonly VisitRepositoryInterface $repo, - private readonly VisitsParams $params, + private readonly OrphanVisitsParams $params, private readonly ?ApiKey $apiKey, ) { } protected function doCount(): int { - return $this->repo->countOrphanVisits(new VisitsCountFiltering( + return $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering( dateRange: $this->params->dateRange, excludeBots: $this->params->excludeBots, apiKey: $this->apiKey, + type: $this->params->type, )); } public function getSlice(int $offset, int $length): iterable { - return $this->repo->findOrphanVisits(new VisitsListFiltering( + return $this->repo->findOrphanVisits(new OrphanVisitsListFiltering( dateRange: $this->params->dateRange, excludeBots: $this->params->excludeBots, apiKey: $this->apiKey, + type: $this->params->type, limit: $length, offset: $offset, )); diff --git a/module/Core/src/Visit/Persistence/OrphanVisitsCountFiltering.php b/module/Core/src/Visit/Persistence/OrphanVisitsCountFiltering.php new file mode 100644 index 00000000..88676df8 --- /dev/null +++ b/module/Core/src/Visit/Persistence/OrphanVisitsCountFiltering.php @@ -0,0 +1,21 @@ +apiKey?->hasRole(Role::NO_ORPHAN_VISITS)) { return []; @@ -146,10 +148,17 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $qb = $this->createAllVisitsQueryBuilder($filtering); $qb->andWhere($qb->expr()->isNull('v.shortUrl')); + + // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later + if ($filtering->type) { + $conn = $this->getEntityManager()->getConnection(); + $qb->andWhere($qb->expr()->eq('v.type', $conn->quote($filtering->type->value))); + } + return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset); } - public function countOrphanVisits(VisitsCountFiltering $filtering): int + public function countOrphanVisits(OrphanVisitsCountFiltering $filtering): int { if ($filtering->apiKey?->hasRole(Role::NO_ORPHAN_VISITS)) { return 0; @@ -176,7 +185,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo return (int) $this->matchSingleScalarResult(new CountOfNonOrphanVisits($filtering)); } - private function createAllVisitsQueryBuilder(VisitsListFiltering $filtering): QueryBuilder + private function createAllVisitsQueryBuilder(VisitsListFiltering|OrphanVisitsListFiltering $filtering): QueryBuilder { // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later // Since they are not provided by the caller, it's reasonably safe diff --git a/module/Core/src/Visit/Repository/VisitRepositoryInterface.php b/module/Core/src/Visit/Repository/VisitRepositoryInterface.php index 4e53db2b..9904181b 100644 --- a/module/Core/src/Visit/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Visit/Repository/VisitRepositoryInterface.php @@ -8,6 +8,8 @@ use Doctrine\Persistence\ObjectRepository; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; @@ -37,9 +39,9 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification /** * @return Visit[] */ - public function findOrphanVisits(VisitsListFiltering $filtering): array; + public function findOrphanVisits(OrphanVisitsListFiltering $filtering): array; - public function countOrphanVisits(VisitsCountFiltering $filtering): int; + public function countOrphanVisits(OrphanVisitsCountFiltering $filtering): int; /** * @return Visit[] diff --git a/module/Core/src/Visit/Spec/CountOfOrphanVisits.php b/module/Core/src/Visit/Spec/CountOfOrphanVisits.php index 106350c6..9d9cab56 100644 --- a/module/Core/src/Visit/Spec/CountOfOrphanVisits.php +++ b/module/Core/src/Visit/Spec/CountOfOrphanVisits.php @@ -8,11 +8,11 @@ use Happyr\DoctrineSpecification\Spec; use Happyr\DoctrineSpecification\Specification\BaseSpecification; use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\Spec\InDateRange; -use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; class CountOfOrphanVisits extends BaseSpecification { - public function __construct(private VisitsCountFiltering $filtering) + public function __construct(private readonly OrphanVisitsCountFiltering $filtering) { parent::__construct(); } @@ -28,6 +28,10 @@ class CountOfOrphanVisits extends BaseSpecification $conditions[] = Spec::eq('potentialBot', false); } + if ($this->filtering->type) { + $conditions[] = Spec::eq('type', $this->filtering->type->value); + } + return Spec::countOf(Spec::andX(...$conditions)); } } diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index bdd2fd3b..480435c1 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -18,6 +18,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\DomainVisitsPaginatorAdapter; @@ -25,6 +26,7 @@ use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\NonOrphanVisitsPaginatorAdapter use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\ShortUrlVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\TagVisitsPaginatorAdapter; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; @@ -42,13 +44,13 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface $visitsRepo = $this->em->getRepository(Visit::class); return new VisitsStats( - nonOrphanVisitsTotal: $visitsRepo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey)), - orphanVisitsTotal: $visitsRepo->countOrphanVisits(VisitsCountFiltering::withApiKey($apiKey)), + nonOrphanVisitsTotal: $visitsRepo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey)), + orphanVisitsTotal: $visitsRepo->countOrphanVisits(new OrphanVisitsCountFiltering(apiKey: $apiKey)), nonOrphanVisitsNonBots: $visitsRepo->countNonOrphanVisits( new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey), ), orphanVisitsNonBots: $visitsRepo->countOrphanVisits( - new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey), + new OrphanVisitsCountFiltering(excludeBots: true, apiKey: $apiKey), ), ); } @@ -116,7 +118,7 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface /** * @return Visit[]|Paginator */ - public function orphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator + public function orphanVisits(OrphanVisitsParams $params, ?ApiKey $apiKey = null): Paginator { /** @var VisitRepositoryInterface $repo */ $repo = $this->em->getRepository(Visit::class); diff --git a/module/Core/src/Visit/VisitsStatsHelperInterface.php b/module/Core/src/Visit/VisitsStatsHelperInterface.php index 71173553..265174ed 100644 --- a/module/Core/src/Visit/VisitsStatsHelperInterface.php +++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php @@ -10,6 +10,7 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -43,7 +44,7 @@ interface VisitsStatsHelperInterface /** * @return Visit[]|Paginator */ - public function orphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator; + public function orphanVisits(OrphanVisitsParams $params, ?ApiKey $apiKey = null): Paginator; /** * @return Visit[]|Paginator diff --git a/module/Core/test-api/Action/QrCodeTest.php b/module/Core/test-api/Action/QrCodeTest.php index 955e6c7e..21fd5147 100644 --- a/module/Core/test-api/Action/QrCodeTest.php +++ b/module/Core/test-api/Action/QrCodeTest.php @@ -10,7 +10,7 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class QrCodeTest extends ApiTestCase { #[Test] - public function returnsNotFoundWhenShortUrlIsNotEnabled(): void + public function returnsQrCodeEvenIfShortUrlIsNotEnabled(): void { // The QR code successfully resolves at first $response = $this->callShortUrl('custom/qr-code'); @@ -20,8 +20,8 @@ class QrCodeTest extends ApiTestCase $this->callShortUrl('custom'); $this->callShortUrl('custom'); - // After 2 visits, the QR code should return a 404 - $response = $this->callShortUrl('custom/qr-code'); - self::assertEquals(404, $response->getStatusCode()); + // After 2 visits, the short URL returns a 404, but the QR code should still work + self::assertEquals(404, $this->callShortUrl('custom')->getStatusCode()); + self::assertEquals(200, $this->callShortUrl('custom/qr-code')->getStatusCode()); } } diff --git a/module/Core/test-api/Action/RedirectTest.php b/module/Core/test-api/Action/RedirectTest.php index f3edcbe4..cb623edc 100644 --- a/module/Core/test-api/Action/RedirectTest.php +++ b/module/Core/test-api/Action/RedirectTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Core\Action; +use GuzzleHttp\RequestOptions; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; @@ -15,23 +16,68 @@ use const ShlinkioTest\Shlink\IOS_USER_AGENT; class RedirectTest extends ApiTestCase { #[Test, DataProvider('provideUserAgents')] - public function properRedirectHappensBasedOnUserAgent(?string $userAgent, string $expectedRedirect): void + public function properRedirectHappensBasedOnUserAgent(array $options, string $expectedRedirect): void { - $response = $this->callShortUrl('def456', $userAgent); + $response = $this->callShortUrl('def456', $options); + + self::assertEquals(302, $response->getStatusCode()); self::assertEquals($expectedRedirect, $response->getHeaderLine('Location')); } public static function provideUserAgents(): iterable { - yield 'android' => [ANDROID_USER_AGENT, 'https://blog.alejandrocelaya.com/android']; - yield 'ios' => [IOS_USER_AGENT, 'https://blog.alejandrocelaya.com/ios']; + yield 'android' => [ + [ + RequestOptions::HEADERS => ['User-Agent' => ANDROID_USER_AGENT], + ], + 'https://blog.alejandrocelaya.com/android', + ]; + yield 'ios' => [ + [ + RequestOptions::HEADERS => ['User-Agent' => IOS_USER_AGENT], + ], + 'https://blog.alejandrocelaya.com/ios', + ]; yield 'desktop' => [ - DESKTOP_USER_AGENT, + [ + RequestOptions::HEADERS => ['User-Agent' => DESKTOP_USER_AGENT], + ], 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', ]; yield 'unknown' => [ - null, + [], 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', ]; + yield 'rule: english and foo' => [ + [ + RequestOptions::HEADERS => ['Accept-Language' => 'en-UK'], + RequestOptions::QUERY => ['foo' => 'bar'], + ], + 'https://example.com/english-and-foo-query?foo=bar', + ]; + yield 'rule: multiple query params' => [ + [ + RequestOptions::QUERY => ['foo' => 'bar', 'hello' => 'world'], + ], + 'https://example.com/multiple-query-params?foo=bar&hello=world', + ]; + yield 'rule: british english' => [ + [ + RequestOptions::HEADERS => ['Accept-Language' => 'en-UK'], + ], + 'https://example.com/only-english', + ]; + yield 'rule: english' => [ + [ + RequestOptions::HEADERS => ['Accept-Language' => 'en'], + ], + 'https://example.com/only-english', + ]; + yield 'rule: complex matching accept language' => [ + [ + RequestOptions::HEADERS => ['Accept-Language' => 'fr-FR, es;q=08, en;q=0.5, *;q=0.2'], + ], + 'https://example.com/only-english', + ]; } } diff --git a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php index 6cccf199..77077142 100644 --- a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php @@ -197,13 +197,6 @@ class TagRepositoryTest extends DatabaseTestCase ['another', 0, 0, 0], ], ]; - yield 'visits count DESC ordering and limit' => [ - new TagsListFiltering(2, null, null, Ordering::fromTuple([OrderableField::VISITS_COUNT->value, 'DESC'])), - [ - ['foo', 2, 4, 3], - ['bar', 3, 3, 2], - ], - ]; yield 'api key' => [new TagsListFiltering(null, null, null, null, ApiKey::fromMeta( ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()), )), [ diff --git a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php index cca71a14..90496e1e 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -15,7 +15,10 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; 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\OrphanVisitType; use Shlinkio\Shlink\Core\Visit\Model\Visitor; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository; @@ -305,10 +308,12 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); self::assertEquals(4 + 5 + 7, $this->repo->countNonOrphanVisits(new VisitsCountFiltering())); - self::assertEquals(4, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey1))); - self::assertEquals(5 + 7, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey2))); - self::assertEquals(4 + 7, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($domainApiKey))); - self::assertEquals(0, $this->repo->countOrphanVisits(VisitsCountFiltering::withApiKey($noOrphanVisitsApiKey))); + self::assertEquals(4, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey1))); + self::assertEquals(5 + 7, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey2))); + self::assertEquals(4 + 7, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $domainApiKey))); + self::assertEquals(0, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering( + apiKey: $noOrphanVisitsApiKey, + ))); self::assertEquals(4, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::since( Chronos::parse('2016-01-05')->startOfDay(), )))); @@ -319,8 +324,8 @@ class VisitRepositoryTest extends DatabaseTestCase Chronos::parse('2016-01-07')->startOfDay(), ), false, $apiKey2))); self::assertEquals(3 + 5, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(null, true, $apiKey2))); - self::assertEquals(4, $this->repo->countOrphanVisits(new VisitsCountFiltering())); - self::assertEquals(3, $this->repo->countOrphanVisits(new VisitsCountFiltering(null, true))); + self::assertEquals(4, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering())); + self::assertEquals(3, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering(excludeBots: true))); } #[Test] @@ -353,27 +358,36 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - self::assertCount(0, $this->repo->findOrphanVisits(new VisitsListFiltering(apiKey: $noOrphanVisitsApiKey))); - self::assertCount(18, $this->repo->findOrphanVisits(new VisitsListFiltering())); - self::assertCount(15, $this->repo->findOrphanVisits(new VisitsListFiltering(null, true))); - self::assertCount(5, $this->repo->findOrphanVisits(new VisitsListFiltering(null, false, null, 5))); - self::assertCount(10, $this->repo->findOrphanVisits(new VisitsListFiltering(null, false, null, 15, 8))); - self::assertCount(9, $this->repo->findOrphanVisits(new VisitsListFiltering( - DateRange::since(Chronos::parse('2020-01-04')), - false, - null, - 15, + self::assertCount(0, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering( + apiKey: $noOrphanVisitsApiKey, ))); - self::assertCount(2, $this->repo->findOrphanVisits(new VisitsListFiltering( - DateRange::between(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), - false, - null, - 6, - 4, + self::assertCount(18, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering())); + self::assertCount(15, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering(excludeBots: true))); + self::assertCount(5, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering(limit: 5))); + self::assertCount(10, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering(limit: 15, offset: 8))); + self::assertCount(9, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering( + dateRange: DateRange::since(Chronos::parse('2020-01-04')), + limit: 15, ))); - self::assertCount(3, $this->repo->findOrphanVisits(new VisitsListFiltering( + self::assertCount(2, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering( + dateRange: DateRange::between(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), + limit: 6, + offset: 4, + ))); + self::assertCount(2, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering( + dateRange: DateRange::between(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), + type: OrphanVisitType::INVALID_SHORT_URL, + ))); + self::assertCount(3, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering( DateRange::until(Chronos::parse('2020-01-01')), ))); + self::assertCount(6, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering( + type: OrphanVisitType::REGULAR_404, + ))); + self::assertCount(4, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering( + type: OrphanVisitType::BASE_URL, + limit: 4, + ))); } #[Test] @@ -400,17 +414,27 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - self::assertEquals(18, $this->repo->countOrphanVisits(new VisitsCountFiltering())); - self::assertEquals(18, $this->repo->countOrphanVisits(new VisitsCountFiltering(DateRange::allTime()))); + self::assertEquals(18, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering())); + self::assertEquals(18, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering(DateRange::allTime()))); self::assertEquals(9, $this->repo->countOrphanVisits( - new VisitsCountFiltering(DateRange::since(Chronos::parse('2020-01-04'))), + new OrphanVisitsCountFiltering(DateRange::since(Chronos::parse('2020-01-04'))), )); - self::assertEquals(6, $this->repo->countOrphanVisits(new VisitsCountFiltering( + self::assertEquals(6, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering( DateRange::between(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), ))); self::assertEquals(3, $this->repo->countOrphanVisits( - new VisitsCountFiltering(DateRange::until(Chronos::parse('2020-01-01'))), + new OrphanVisitsCountFiltering(DateRange::until(Chronos::parse('2020-01-01'))), )); + self::assertEquals(2, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering( + dateRange: DateRange::between(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), + type: OrphanVisitType::BASE_URL, + ))); + self::assertEquals(6, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering( + type: OrphanVisitType::INVALID_SHORT_URL, + ))); + self::assertEquals(6, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering( + type: OrphanVisitType::REGULAR_404, + ))); } #[Test] diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 9a89ff47..08564bf9 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -24,9 +24,12 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use function getimagesizefromstring; +use function hexdec; use function imagecolorat; use function imagecreatefromstring; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR; + class QrCodeActionTest extends TestCase { private const WHITE = 0xFFFFFF; @@ -46,10 +49,10 @@ class QrCodeActionTest extends TestCase $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), )->willThrowException(ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain(''))); - $delegate = $this->createMock(RequestHandlerInterface::class); - $delegate->expects($this->once())->method('handle')->withAnyParameters()->willReturn(new Response()); + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->once())->method('handle')->withAnyParameters()->willReturn(new Response()); - $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate); + $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $handler); } #[Test] @@ -59,10 +62,10 @@ class QrCodeActionTest extends TestCase $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), )->willReturn(ShortUrl::createFake()); - $delegate = $this->createMock(RequestHandlerInterface::class); - $delegate->expects($this->never())->method('handle'); + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->never())->method('handle'); - $resp = $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate); + $resp = $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $handler); self::assertInstanceOf(QrCodeResponse::class, $resp); self::assertEquals(200, $resp->getStatusCode()); @@ -78,10 +81,10 @@ class QrCodeActionTest extends TestCase $this->urlResolver->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($code, ''), )->willReturn(ShortUrl::createFake()); - $delegate = $this->createMock(RequestHandlerInterface::class); + $handler = $this->createMock(RequestHandlerInterface::class); $req = (new ServerRequest())->withAttribute('shortCode', $code)->withQueryParams($query); - $resp = $this->action(new QrCodeOptions(format: $defaultFormat))->process($req, $delegate); + $resp = $this->action(new QrCodeOptions(format: $defaultFormat))->process($req, $handler); self::assertEquals($expectedContentType, $resp->getHeaderLine('Content-Type')); } @@ -108,9 +111,9 @@ class QrCodeActionTest extends TestCase $this->urlResolver->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($code, ''), )->willReturn(ShortUrl::createFake()); - $delegate = $this->createMock(RequestHandlerInterface::class); + $handler = $this->createMock(RequestHandlerInterface::class); - $resp = $this->action($defaultOptions)->process($req->withAttribute('shortCode', $code), $delegate); + $resp = $this->action($defaultOptions)->process($req->withAttribute('shortCode', $code), $handler); $result = getimagesizefromstring($resp->getBody()->__toString()); self::assertNotFalse($result); @@ -198,14 +201,14 @@ class QrCodeActionTest extends TestCase $this->urlResolver->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($code, ''), )->willReturn(ShortUrl::withLongUrl('https://shlink.io')); - $delegate = $this->createMock(RequestHandlerInterface::class); + $handler = $this->createMock(RequestHandlerInterface::class); - $resp = $this->action($defaultOptions)->process($req, $delegate); + $resp = $this->action($defaultOptions)->process($req, $handler); $image = imagecreatefromstring($resp->getBody()->__toString()); self::assertNotFalse($image); $color = imagecolorat($image, 1, 1); - self::assertEquals($color, $expectedColor); + self::assertEquals($expectedColor, $color); } public static function provideRoundBlockSize(): iterable @@ -230,10 +233,47 @@ class QrCodeActionTest extends TestCase ]; } + #[Test, DataProvider('provideColors')] + public function properColorsAreUsed(?string $queryColor, ?string $optionsColor, int $expectedColor): void + { + $code = 'abc123'; + $req = ServerRequestFactory::fromGlobals() + ->withQueryParams(['color' => $queryColor]) + ->withAttribute('shortCode', $code); + + $this->urlResolver->method('resolveEnabledShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain($code), + )->willReturn(ShortUrl::withLongUrl('https://shlink.io')); + $handler = $this->createMock(RequestHandlerInterface::class); + + $resp = $this->action( + new QrCodeOptions(size: 250, roundBlockSize: false, color: $optionsColor ?? DEFAULT_QR_CODE_COLOR), + )->process($req, $handler); + $image = imagecreatefromstring($resp->getBody()->__toString()); + self::assertNotFalse($image); + + $resultingColor = imagecolorat($image, 1, 1); + self::assertEquals($expectedColor, $resultingColor); + } + + public static function provideColors(): iterable + { + yield 'no query, no default' => [null, null, self::BLACK]; + yield '6-char-query black' => ['000000', null, self::BLACK]; + yield '6-char-query white' => ['ffffff', null, self::WHITE]; + yield '6-char-query red' => ['ff0000', null, (int) hexdec('ff0000')]; + yield '3-char-query black' => ['000', null, self::BLACK]; + yield '3-char-query white' => ['fff', null, self::WHITE]; + yield '3-char-query red' => ['f00', null, (int) hexdec('ff0000')]; + yield '3-char-default red' => [null, 'f00', (int) hexdec('ff0000')]; + yield 'invalid color in query' => ['zzzzzzzz', null, self::BLACK]; + yield 'invalid color in query with default' => ['zzzzzzzz', 'aa88cc', self::BLACK]; + yield 'invalid color in default' => [null, 'zzzzzzzz', self::BLACK]; + } + #[Test, DataProvider('provideEnabled')] public function qrCodeIsResolvedBasedOnOptions(bool $enabledForDisabledShortUrls): void { - if ($enabledForDisabledShortUrls) { $this->urlResolver->expects($this->once())->method('resolvePublicShortUrl')->willThrowException( ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain('')), @@ -253,6 +293,27 @@ class QrCodeActionTest extends TestCase ); } + #[Test] + public function logoIsAddedToQrCodeIfOptionIsDefined(): void + { + $logoUrl = 'https://avatars.githubusercontent.com/u/20341790?v=4'; // Shlink logo + $code = 'abc123'; + $req = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $code); + + $this->urlResolver->method('resolveEnabledShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain($code), + )->willReturn(ShortUrl::withLongUrl('https://shlink.io')); + $handler = $this->createMock(RequestHandlerInterface::class); + + $resp = $this->action(new QrCodeOptions(size: 250, logoUrl: $logoUrl))->process($req, $handler); + $image = imagecreatefromstring($resp->getBody()->__toString()); + self::assertNotFalse($image); + + // At around 100x100 px we can already find the logo, which has Shlink's brand color + $resultingColor = imagecolorat($image, 100, 100); + self::assertEquals(hexdec('4696E5'), $resultingColor); + } + public static function provideEnabled(): iterable { yield 'always enabled' => [true]; @@ -265,7 +326,7 @@ class QrCodeActionTest extends TestCase $this->urlResolver, new ShortUrlStringifier(['domain' => 's.test']), new NullLogger(), - $options ?? new QrCodeOptions(), + $options ?? new QrCodeOptions(enabledForDisabledShortUrls: false), ); } } diff --git a/module/Core/test/Config/EnvVarsTest.php b/module/Core/test/Config/EnvVarsTest.php index 0b012051..dd83393b 100644 --- a/module/Core/test/Config/EnvVarsTest.php +++ b/module/Core/test/Config/EnvVarsTest.php @@ -17,12 +17,16 @@ class EnvVarsTest extends TestCase { putenv(EnvVars::BASE_PATH->value . '=the_base_path'); putenv(EnvVars::DB_NAME->value . '=shlink'); + + $envFilePath = __DIR__ . '/../DB_PASSWORD.env'; + putenv(EnvVars::DB_PASSWORD->value . '_FILE=' . $envFilePath); } protected function tearDown(): void { putenv(EnvVars::BASE_PATH->value . '='); putenv(EnvVars::DB_NAME->value . '='); + putenv(EnvVars::DB_PASSWORD->value . '_FILE='); } #[Test, DataProvider('provideExistingEnvVars')] @@ -54,4 +58,10 @@ class EnvVarsTest extends TestCase yield 'DB_DRIVER without default' => [EnvVars::DB_DRIVER, null, null]; yield 'DB_DRIVER with default' => [EnvVars::DB_DRIVER, 'foobar', 'foobar']; } + + #[Test] + public function fallsBackToReadEnvVarsFromFile(): void + { + self::assertEquals('this_is_the_password', EnvVars::DB_PASSWORD->loadFromEnv()); + } } diff --git a/module/Core/test/Config/NotFoundRedirectResolverTest.php b/module/Core/test/Config/NotFoundRedirectResolverTest.php index 0b943099..5ef5db2b 100644 --- a/module/Core/test/Config/NotFoundRedirectResolverTest.php +++ b/module/Core/test/Config/NotFoundRedirectResolverTest.php @@ -57,8 +57,14 @@ class NotFoundRedirectResolverTest extends TestCase yield 'base URL with trailing slash' => [ $uri = new Uri('/'), self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions(baseUrl: 'baseUrl'), - 'baseUrl', + new NotFoundRedirectOptions(baseUrl: 'https://example.com/baseUrl'), + 'https://example.com/baseUrl', + ]; + yield 'base URL without trailing slash' => [ + $uri = new Uri(''), + self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), + new NotFoundRedirectOptions(baseUrl: 'https://example.com/baseUrl'), + 'https://example.com/baseUrl', ]; yield 'base URL with domain placeholder' => [ $uri = new Uri('https://s.test'), @@ -72,17 +78,11 @@ class NotFoundRedirectResolverTest extends TestCase new NotFoundRedirectOptions(baseUrl: 'https://redirect-here.com/?domain={DOMAIN}'), 'https://redirect-here.com/?domain=s.test', ]; - yield 'base URL without trailing slash' => [ - $uri = new Uri(''), - self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions(baseUrl: 'baseUrl'), - 'baseUrl', - ]; yield 'regular 404' => [ $uri = new Uri('/foo/bar'), self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions(regular404: 'regular404'), - 'regular404', + new NotFoundRedirectOptions(regular404: 'https://example.com/regular404'), + 'https://example.com/regular404', ]; yield 'regular 404 with path placeholder in query' => [ $uri = new Uri('/foo/bar'), @@ -101,8 +101,8 @@ class NotFoundRedirectResolverTest extends TestCase yield 'invalid short URL' => [ new Uri('/foo'), self::notFoundType(self::requestForRoute(RedirectAction::class)), - new NotFoundRedirectOptions(invalidShortUrl: 'invalidShortUrl'), - 'invalidShortUrl', + new NotFoundRedirectOptions(invalidShortUrl: 'https://example.com/invalidShortUrl'), + 'https://example.com/invalidShortUrl', ]; yield 'invalid short URL with path placeholder' => [ new Uri('/foo'), diff --git a/module/Core/test/DB_PASSWORD.env b/module/Core/test/DB_PASSWORD.env new file mode 100644 index 00000000..d5b7bed8 --- /dev/null +++ b/module/Core/test/DB_PASSWORD.env @@ -0,0 +1 @@ +this_is_the_password diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index eb14c982..c67597ec 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -14,7 +14,7 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; -use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; +use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository; use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; @@ -34,7 +34,7 @@ class DomainServiceTest extends TestCase #[Test, DataProvider('provideExcludedDomains')] public function listDomainsDelegatesIntoRepository(array $domains, array $expectedResult, ?ApiKey $apiKey): void { - $repo = $this->createMock(DomainRepositoryInterface::class); + $repo = $this->createMock(DomainRepository::class); $repo->expects($this->once())->method('findDomains')->with($apiKey)->willReturn($domains); $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); @@ -126,7 +126,7 @@ class DomainServiceTest extends TestCase public function getOrCreateAlwaysPersistsDomain(?Domain $foundDomain, ?ApiKey $apiKey): void { $authority = 'example.com'; - $repo = $this->createMock(DomainRepositoryInterface::class); + $repo = $this->createMock(DomainRepository::class); $repo->method('findOneByAuthority')->with($authority, $apiKey)->willReturn( $foundDomain, ); @@ -148,7 +148,7 @@ class DomainServiceTest extends TestCase $domain = Domain::withAuthority($authority); $domain->setId('1'); $apiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($domain))); - $repo = $this->createMock(DomainRepositoryInterface::class); + $repo = $this->createMock(DomainRepository::class); $repo->method('findOneByAuthority')->with($authority, $apiKey)->willReturn(null); $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); $this->em->expects($this->never())->method('persist'); @@ -163,7 +163,7 @@ class DomainServiceTest extends TestCase public function configureNotFoundRedirectsConfiguresFetchedDomain(?Domain $foundDomain, ?ApiKey $apiKey): void { $authority = 'example.com'; - $repo = $this->createMock(DomainRepositoryInterface::class); + $repo = $this->createMock(DomainRepository::class); $repo->method('findOneByAuthority')->with($authority, $apiKey)->willReturn($foundDomain); $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); $this->em->expects($this->once())->method('persist')->with($foundDomain ?? $this->isInstanceOf(Domain::class)); diff --git a/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php index 00f78fe4..cebde437 100644 --- a/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php +++ b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php @@ -12,7 +12,6 @@ use Shlinkio\Shlink\Core\EventDispatcher\Helper\EnabledListenerChecker; use Shlinkio\Shlink\Core\EventDispatcher\Matomo\SendVisitToMatomo; use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyNewShortUrlToMercure; use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyVisitToMercure; -use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToWebHooks; use Shlinkio\Shlink\Core\EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq; use Shlinkio\Shlink\Core\EventDispatcher\RabbitMq\NotifyVisitToRabbitMq; use Shlinkio\Shlink\Core\EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis; @@ -20,7 +19,6 @@ use Shlinkio\Shlink\Core\EventDispatcher\RedisPubSub\NotifyVisitToRedis; use Shlinkio\Shlink\Core\EventDispatcher\UpdateGeoLiteDb; use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Options\RabbitMqOptions; -use Shlinkio\Shlink\Core\Options\WebhookOptions; use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options; class EnabledListenerCheckerTest extends TestCase @@ -41,7 +39,6 @@ class EnabledListenerCheckerTest extends TestCase [NotifyVisitToMercure::class], [NotifyNewShortUrlToMercure::class], [SendVisitToMatomo::class], - [NotifyVisitToWebHooks::class], [UpdateGeoLiteDb::class], ]; } @@ -68,7 +65,6 @@ class EnabledListenerCheckerTest extends TestCase NotifyNewShortUrlToRedis::class => false, NotifyVisitToMercure::class => false, NotifyNewShortUrlToMercure::class => false, - NotifyVisitToWebHooks::class => false, UpdateGeoLiteDb::class => false, 'unknown' => false, ]]; @@ -79,7 +75,6 @@ class EnabledListenerCheckerTest extends TestCase NotifyNewShortUrlToRedis::class => true, NotifyVisitToMercure::class => false, NotifyNewShortUrlToMercure::class => false, - NotifyVisitToWebHooks::class => false, UpdateGeoLiteDb::class => false, 'unknown' => false, ]]; @@ -90,18 +85,6 @@ class EnabledListenerCheckerTest extends TestCase NotifyNewShortUrlToRedis::class => false, NotifyVisitToMercure::class => true, NotifyNewShortUrlToMercure::class => true, - NotifyVisitToWebHooks::class => false, - UpdateGeoLiteDb::class => false, - 'unknown' => false, - ]]; - yield 'Webhooks' => [self::checker(webhooksEnabled: true), [ - NotifyVisitToRabbitMq::class => false, - NotifyNewShortUrlToRabbitMq::class => false, - NotifyVisitToRedis::class => false, - NotifyNewShortUrlToRedis::class => false, - NotifyVisitToMercure::class => false, - NotifyNewShortUrlToMercure::class => false, - NotifyVisitToWebHooks::class => true, UpdateGeoLiteDb::class => false, 'unknown' => false, ]]; @@ -112,7 +95,6 @@ class EnabledListenerCheckerTest extends TestCase NotifyNewShortUrlToRedis::class => false, NotifyVisitToMercure::class => false, NotifyNewShortUrlToMercure::class => false, - NotifyVisitToWebHooks::class => false, UpdateGeoLiteDb::class => true, 'unknown' => false, ]]; @@ -124,7 +106,6 @@ class EnabledListenerCheckerTest extends TestCase NotifyVisitToMercure::class => false, NotifyNewShortUrlToMercure::class => false, SendVisitToMatomo::class => true, - NotifyVisitToWebHooks::class => false, UpdateGeoLiteDb::class => false, 'unknown' => false, ]]; @@ -135,7 +116,6 @@ class EnabledListenerCheckerTest extends TestCase NotifyNewShortUrlToRedis::class => false, NotifyVisitToMercure::class => false, NotifyNewShortUrlToMercure::class => false, - NotifyVisitToWebHooks::class => false, UpdateGeoLiteDb::class => false, 'unknown' => false, ]]; @@ -143,7 +123,6 @@ class EnabledListenerCheckerTest extends TestCase rabbitMqEnabled: true, redisPubSubEnabled: true, mercureEnabled: true, - webhooksEnabled: true, geoLiteEnabled: true, matomoEnabled: true, ), [ @@ -154,7 +133,6 @@ class EnabledListenerCheckerTest extends TestCase NotifyVisitToMercure::class => true, NotifyNewShortUrlToMercure::class => true, SendVisitToMatomo::class => true, - NotifyVisitToWebHooks::class => true, UpdateGeoLiteDb::class => true, 'unknown' => false, ]]; @@ -164,7 +142,6 @@ class EnabledListenerCheckerTest extends TestCase bool $rabbitMqEnabled = false, bool $redisPubSubEnabled = false, bool $mercureEnabled = false, - bool $webhooksEnabled = false, bool $geoLiteEnabled = false, bool $matomoEnabled = false, ): EnabledListenerChecker { @@ -172,7 +149,6 @@ class EnabledListenerCheckerTest extends TestCase new RabbitMqOptions(enabled: $rabbitMqEnabled), $redisPubSubEnabled, new MercureOptions(publicHubUrl: $mercureEnabled ? 'the-url' : null), - new WebhookOptions(['webhooks' => $webhooksEnabled ? ['foo', 'bar'] : []]), new GeoLite2Options(licenseKey: $geoLiteEnabled ? 'the-key' : null), new MatomoOptions(enabled: $matomoEnabled), ); diff --git a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php deleted file mode 100644 index 8b9c10ac..00000000 --- a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php +++ /dev/null @@ -1,144 +0,0 @@ -httpClient = $this->createMock(ClientInterface::class); - $this->em = $this->createMock(EntityManagerInterface::class); - $this->logger = $this->createMock(LoggerInterface::class); - } - - #[Test] - public function emptyWebhooksMakeNoFurtherActions(): void - { - $this->em->expects($this->never())->method('find'); - - $this->createListener([])(new VisitLocated('1')); - } - - #[Test] - public function invalidVisitDoesNotPerformAnyRequest(): void - { - $this->em->expects($this->once())->method('find')->with(Visit::class, '1')->willReturn(null); - $this->httpClient->expects($this->never())->method('requestAsync'); - $this->logger->expects($this->once())->method('warning')->with( - 'Tried to notify webhooks for visit with id "{visitId}", but it does not exist.', - ['visitId' => '1'], - ); - - $this->createListener(['foo', 'bar'])(new VisitLocated('1')); - } - - #[Test] - public function orphanVisitDoesNotPerformAnyRequestWhenDisabled(): void - { - $this->em->expects($this->once())->method('find')->with(Visit::class, '1')->willReturn( - Visit::forBasePath(Visitor::emptyInstance()), - ); - $this->httpClient->expects($this->never())->method('requestAsync'); - $this->logger->expects($this->never())->method('warning'); - - $this->createListener(['foo', 'bar'], false)(new VisitLocated('1')); - } - - #[Test, DataProvider('provideVisits')] - public function expectedRequestsArePerformedToWebhooks(Visit $visit, array $expectedResponseKeys): void - { - $webhooks = ['foo', 'invalid', 'bar', 'baz']; - $invalidWebhooks = ['invalid', 'baz']; - - $this->em->expects($this->once())->method('find')->with(Visit::class, '1')->willReturn($visit); - $this->httpClient->expects($this->exactly(count($webhooks)))->method('requestAsync')->with( - RequestMethodInterface::METHOD_POST, - $this->istype('string'), - $this->callback(function (array $requestOptions) use ($expectedResponseKeys) { - Assert::assertArrayHasKey(RequestOptions::HEADERS, $requestOptions); - Assert::assertArrayHasKey(RequestOptions::JSON, $requestOptions); - Assert::assertArrayHasKey(RequestOptions::TIMEOUT, $requestOptions); - Assert::assertEquals(10, $requestOptions[RequestOptions::TIMEOUT]); - Assert::assertEquals(['User-Agent' => 'Shlink:v1.2.3'], $requestOptions[RequestOptions::HEADERS]); - - $json = $requestOptions[RequestOptions::JSON]; - Assert::assertCount(count($expectedResponseKeys), $json); - foreach ($expectedResponseKeys as $key) { - Assert::assertArrayHasKey($key, $json); - } - - return true; - }), - )->willReturnCallback(function ($_, $webhook) use ($invalidWebhooks) { - $shouldReject = contains($webhook, $invalidWebhooks); - return $shouldReject ? new RejectedPromise(new Exception('')) : new FulfilledPromise(''); - }); - $this->logger->expects($this->exactly(count($invalidWebhooks)))->method('warning')->with( - 'Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}', - $this->callback(function (array $extra): bool { - Assert::assertArrayHasKey('webhook', $extra); - Assert::assertArrayHasKey('visitId', $extra); - Assert::assertArrayHasKey('e', $extra); - - return true; - }), - ); - - $this->createListener($webhooks)(new VisitLocated('1')); - } - - public static function provideVisits(): iterable - { - yield 'regular visit' => [ - Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), - ['shortUrl', 'visit'], - ]; - yield 'orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), ['visit']]; - } - - private function createListener(array $webhooks, bool $notifyOrphanVisits = true): NotifyVisitToWebHooks - { - return new NotifyVisitToWebHooks( - $this->httpClient, - $this->em, - $this->logger, - new WebhookOptions( - ['webhooks' => $webhooks, 'notify_orphan_visits_to_webhooks' => $notifyOrphanVisits], - ), - new ShortUrlDataTransformer(new ShortUrlStringifier([])), - new AppOptions('Shlink', '1.2.3'), - ); - } -} diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index 9d28f2cd..545c5b47 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -51,9 +51,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => 'http:/' . $shortUrl->getShortCode(), 'longUrl' => 'https://longUrl', - 'deviceLongUrls' => $shortUrl->deviceLongUrls(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), - 'visitsCount' => 0, 'tags' => [], 'meta' => [ 'validSince' => null, @@ -126,9 +124,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => 'http:/' . $shortUrl->getShortCode(), 'longUrl' => 'https://longUrl', - 'deviceLongUrls' => $shortUrl->deviceLongUrls(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), - 'visitsCount' => 0, 'tags' => [], 'meta' => [ 'validSince' => null, diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php index e722bf25..7386169f 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -7,7 +7,6 @@ namespace ShlinkioTest\Shlink\Core\EventDispatcher\RabbitMq; use Doctrine\ORM\EntityManagerInterface; use DomainException; use Exception; -use PHPUnit\Framework\Assert; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; @@ -24,7 +23,6 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; -use Shlinkio\Shlink\Core\Visit\Transformer\OrphanVisitDataTransformer; use Throwable; use function array_walk; @@ -132,9 +130,8 @@ class NotifyVisitToRabbitMqTest extends TestCase yield [new DomainException('DomainException Error')]; } - #[Test, DataProvider('provideLegacyPayloads')] + #[Test, DataProvider('providePayloads')] public function expectedPayloadIsPublishedDependingOnConfig( - bool $legacy, Visit $visit, callable $setup, callable $expect, @@ -144,44 +141,12 @@ class NotifyVisitToRabbitMqTest extends TestCase $setup($this->updatesGenerator); $expect($this->helper, $this->updatesGenerator); - ($this->listener(new RabbitMqOptions(true, $legacy)))(new VisitLocated($visitId)); + ($this->listener())(new VisitLocated($visitId)); } - public static function provideLegacyPayloads(): iterable + public static function providePayloads(): iterable { - yield 'legacy non-orphan visit' => [ - true, - $visit = Visit::forValidShortUrl(ShortUrl::withLongUrl('https://longUrl'), Visitor::emptyInstance()), - static fn () => null, - function (MockObject & PublishingHelperInterface $helper) use ($visit): void { - $helper->method('publishUpdate')->with(self::callback(function (Update $update) use ($visit): bool { - $payload = $update->payload; - Assert::assertEquals($payload, $visit->jsonSerialize()); - Assert::assertArrayNotHasKey('visitedUrl', $payload); - Assert::assertArrayNotHasKey('type', $payload); - Assert::assertArrayNotHasKey('visit', $payload); - Assert::assertArrayNotHasKey('shortUrl', $payload); - - return true; - })); - }, - ]; - yield 'legacy orphan visit' => [ - true, - Visit::forBasePath(Visitor::emptyInstance()), - static fn () => null, - function (MockObject & PublishingHelperInterface $helper): void { - $helper->method('publishUpdate')->with(self::callback(function (Update $update): bool { - $payload = $update->payload; - Assert::assertArrayHasKey('visitedUrl', $payload); - Assert::assertArrayHasKey('type', $payload); - - return true; - })); - }, - ]; - yield 'non-legacy non-orphan visit' => [ - false, + yield 'non-orphan visit' => [ Visit::forValidShortUrl(ShortUrl::withLongUrl('https://longUrl'), Visitor::emptyInstance()), function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator): void { $update = Update::forTopicAndPayload('', []); @@ -195,8 +160,7 @@ class NotifyVisitToRabbitMqTest extends TestCase $helper->expects(self::exactly(2))->method('publishUpdate')->with(self::isInstanceOf(Update::class)); }, ]; - yield 'non-legacy orphan visit' => [ - false, + yield 'orphan visit' => [ Visit::forBasePath(Visitor::emptyInstance()), function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator): void { $update = Update::forTopicAndPayload('', []); @@ -217,8 +181,7 @@ class NotifyVisitToRabbitMqTest extends TestCase $this->updatesGenerator, $this->em, $this->logger, - new OrphanVisitDataTransformer(), - $options ?? new RabbitMqOptions(enabled: true, legacyVisitsPublishing: false), + $options ?? new RabbitMqOptions(enabled: true), ); } } diff --git a/module/Core/test/Exception/InvalidUrlExceptionTest.php b/module/Core/test/Exception/InvalidUrlExceptionTest.php deleted file mode 100644 index 5e31d27a..00000000 --- a/module/Core/test/Exception/InvalidUrlExceptionTest.php +++ /dev/null @@ -1,41 +0,0 @@ -getMessage()); - self::assertEquals($expectedMessage, $e->getDetail()); - self::assertEquals('Invalid URL', $e->getTitle()); - self::assertEquals('https://shlink.io/api/error/invalid-url', $e->getType()); - self::assertEquals(['url' => $url], $e->getAdditionalData()); - self::assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode()); - self::assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getStatus()); - self::assertEquals($prev, $e->getPrevious()); - } - - public static function providePrevious(): iterable - { - yield 'null previous' => [null]; - yield 'instance previous' => [new Exception('Previous error', 10)]; - } -} diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php index e267744d..7c8a17d1 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -16,12 +16,12 @@ use RuntimeException; use Shlinkio\Shlink\Core\Importer\ImportedLinksProcessor; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelperInterface; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; -use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository; use Shlinkio\Shlink\Importer\Model\ImportedShlinkOrphanVisit; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit; @@ -42,13 +42,13 @@ class ImportedLinksProcessorTest extends TestCase private ImportedLinksProcessor $processor; private MockObject & EntityManagerInterface $em; private MockObject & ShortCodeUniquenessHelperInterface $shortCodeHelper; - private MockObject & ShortUrlRepositoryInterface $repo; + private MockObject & ShortUrlRepository $repo; private MockObject & StyleInterface $io; protected function setUp(): void { $this->em = $this->createMock(EntityManagerInterface::class); - $this->repo = $this->createMock(ShortUrlRepositoryInterface::class); + $this->repo = $this->createMock(ShortUrlRepository::class); $this->shortCodeHelper = $this->createMock(ShortCodeUniquenessHelperInterface::class); $batchHelper = $this->createMock(DoctrineBatchHelperInterface::class); @@ -281,7 +281,7 @@ class ImportedLinksProcessorTest extends TestCase sprintf('Imported %s orphan visits.', $expectedImportedVisits), ); - $visitRepo = $this->createMock(VisitRepositoryInterface::class); + $visitRepo = $this->createMock(VisitRepository::class); $visitRepo->expects($importOrphanVisits ? $this->once() : $this->never())->method( 'findMostRecentOrphanVisit', )->willReturn($lastOrphanVisit); diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php new file mode 100644 index 00000000..eaea4c25 --- /dev/null +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -0,0 +1,73 @@ +withQueryParams(['foo' => 'bar']); + $result = RedirectCondition::forQueryParam($param, $value)->matchesRequest($request); + + self::assertEquals($expectedResult, $result); + } + + #[Test] + #[TestWith([null, '', false])] // no accept language + #[TestWith(['', '', false])] // empty accept language + #[TestWith(['*', '', false])] // wildcard accept language + #[TestWith(['en', 'en', true])] // single language match + #[TestWith(['es, en,fr', 'en', true])] // multiple languages match + #[TestWith(['es, en-US,fr', 'EN', true])] // multiple locales match + #[TestWith(['es_ES', 'es-ES', true])] // single locale match + #[TestWith(['en-UK', 'en-uk', true])] // different casing match + #[TestWith(['en-UK', 'en', true])] // only lang + #[TestWith(['es-AR', 'en', false])] // different only lang + #[TestWith(['fr', 'fr-FR', false])] // less restrictive matching locale + public function matchesLanguage(?string $acceptLanguage, string $value, bool $expected): void + { + $request = ServerRequestFactory::fromGlobals(); + if ($acceptLanguage !== null) { + $request = $request->withHeader('Accept-Language', $acceptLanguage); + } + + $result = RedirectCondition::forLanguage($value)->matchesRequest($request); + + self::assertEquals($expected, $result); + } + + #[Test] + #[TestWith([null, DeviceType::ANDROID, false])] + #[TestWith(['unknown', DeviceType::ANDROID, false])] + #[TestWith([ANDROID_USER_AGENT, DeviceType::ANDROID, true])] + #[TestWith([DESKTOP_USER_AGENT, DeviceType::DESKTOP, true])] + #[TestWith([IOS_USER_AGENT, DeviceType::IOS, true])] + #[TestWith([IOS_USER_AGENT, DeviceType::ANDROID, false])] + #[TestWith([DESKTOP_USER_AGENT, DeviceType::IOS, false])] + public function matchesDevice(?string $userAgent, DeviceType $value, bool $expected): void + { + $request = ServerRequestFactory::fromGlobals(); + if ($userAgent !== null) { + $request = $request->withHeader('User-Agent', $userAgent); + } + + $result = RedirectCondition::forDevice($value)->matchesRequest($request); + + self::assertEquals($expected, $result); + } +} diff --git a/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php b/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php new file mode 100644 index 00000000..d61bc6fa --- /dev/null +++ b/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php @@ -0,0 +1,96 @@ +withHeader('Accept-Language', 'en-UK') + ->withQueryParams(['foo' => 'bar']); + + $result = $this->createRule(new ArrayCollection($conditions))->matchesRequest($request); + + self::assertEquals($expectedResult, $result); + } + + public static function provideConditions(): iterable + { + yield 'no conditions' => [[], false]; + yield 'not all conditions match' => [ + [RedirectCondition::forLanguage('en-UK'), RedirectCondition::forQueryParam('foo', 'foo')], + false, + ]; + yield 'all conditions match' => [ + [RedirectCondition::forLanguage('en-UK'), RedirectCondition::forQueryParam('foo', 'bar')], + true, + ]; + } + + #[Test] + public function conditionsCanBeCleared(): void + { + $conditions = new ArrayCollection( + [RedirectCondition::forLanguage('en-UK'), RedirectCondition::forQueryParam('foo', 'bar')], + ); + $rule = $this->createRule($conditions); + + self::assertNotEmpty($conditions); + $rule->clearConditions(); + self::assertEmpty($conditions); + } + + #[Test, DataProvider('provideConditionMappingCallbacks')] + public function conditionsCanBeMapped(callable $callback, array $expectedResult): void + { + $conditions = new ArrayCollection( + [RedirectCondition::forLanguage('en-UK'), RedirectCondition::forQueryParam('foo', 'bar')], + ); + $rule = $this->createRule($conditions); + + $result = $rule->mapConditions($callback); + + self::assertEquals($expectedResult, $result); + } + + public static function provideConditionMappingCallbacks(): iterable + { + yield 'json-serialized conditions' => [fn (RedirectCondition $cond) => $cond->jsonSerialize(), [ + [ + 'type' => RedirectConditionType::LANGUAGE->value, + 'matchKey' => null, + 'matchValue' => 'en-UK', + ], + [ + 'type' => RedirectConditionType::QUERY_PARAM->value, + 'matchKey' => 'foo', + 'matchValue' => 'bar', + ], + ]]; + yield 'human-friendly conditions' => [fn (RedirectCondition $cond) => $cond->toHumanFriendly(), [ + 'en-UK language is accepted', + 'query string contains foo=bar', + ]]; + } + + /** + * @param ArrayCollection $conditions + */ + private function createRule(ArrayCollection $conditions): ShortUrlRedirectRule + { + $shortUrl = ShortUrl::withLongUrl('https://s.test'); + return new ShortUrlRedirectRule($shortUrl, 1, '', $conditions); + } +} diff --git a/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php b/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php new file mode 100644 index 00000000..f0ded32b --- /dev/null +++ b/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php @@ -0,0 +1,59 @@ + ['foo']]])] + #[TestWith([['redirectRules' => [ + [ + 'longUrl' => 34, + ], + ]]])] + #[TestWith([['redirectRules' => [ + [ + 'longUrl' => 'https://example.com', + 'conditions' => [ + [ + 'type' => 'invalid', + ], + ], + ], + ]]])] + #[TestWith([['redirectRules' => [ + [ + 'longUrl' => 'https://example.com', + 'conditions' => [ + [ + 'type' => 'device', + 'matchValue' => 'invalid-device', + 'matchKey' => null, + ], + ], + ], + ]]])] + #[TestWith([['redirectRules' => [ + [ + 'longUrl' => 'https://example.com', + 'conditions' => [ + [ + 'type' => 'language', + ], + ], + ], + ]]])] + public function throwsWhenProvidedDataIsInvalid(array $invalidData): void + { + $this->expectException(ValidationException::class); + RedirectRulesData::fromRawData($invalidData); + } +} diff --git a/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php b/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php new file mode 100644 index 00000000..103c6fd0 --- /dev/null +++ b/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php @@ -0,0 +1,171 @@ +em = $this->createMock(EntityManagerInterface::class); + $this->ruleService = new ShortUrlRedirectRuleService($this->em); + } + + #[Test] + public function rulesForShortUrlDelegatesToRepository(): void + { + $shortUrl = ShortUrl::withLongUrl('https://shlink.io'); + $rules = [ + new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com/from-rule', new ArrayCollection([ + RedirectCondition::forLanguage('en-US'), + ])), + new ShortUrlRedirectRule($shortUrl, 2, 'https://example.com/from-rule-2', new ArrayCollection([ + RedirectCondition::forQueryParam('foo', 'bar'), + RedirectCondition::forDevice(DeviceType::ANDROID), + ])), + ]; + + $repo = $this->createMock(EntityRepository::class); + $repo->expects($this->once())->method('findBy')->with( + ['shortUrl' => $shortUrl], + ['priority' => 'ASC'], + )->willReturn($rules); + $this->em->expects($this->once())->method('getRepository')->with(ShortUrlRedirectRule::class)->willReturn( + $repo, + ); + + $result = $this->ruleService->rulesForShortUrl($shortUrl); + + self::assertSame($rules, $result); + } + + #[Test] + public function setRulesForShortUrlParsesProvidedData(): void + { + $shortUrl = ShortUrl::withLongUrl('https://example.com'); + $data = RedirectRulesData::fromRawData([ + RedirectRulesInputFilter::REDIRECT_RULES => [ + [ + RedirectRulesInputFilter::RULE_LONG_URL => 'https://example.com/first', + RedirectRulesInputFilter::RULE_CONDITIONS => [ + [ + RedirectRulesInputFilter::CONDITION_TYPE => RedirectConditionType::DEVICE->value, + RedirectRulesInputFilter::CONDITION_MATCH_KEY => null, + RedirectRulesInputFilter::CONDITION_MATCH_VALUE => DeviceType::ANDROID->value, + ], + [ + RedirectRulesInputFilter::CONDITION_TYPE => RedirectConditionType::QUERY_PARAM->value, + RedirectRulesInputFilter::CONDITION_MATCH_KEY => 'foo', + RedirectRulesInputFilter::CONDITION_MATCH_VALUE => 'bar', + ], + ], + ], + [ + RedirectRulesInputFilter::RULE_LONG_URL => 'https://example.com/second', + RedirectRulesInputFilter::RULE_CONDITIONS => [ + [ + RedirectRulesInputFilter::CONDITION_TYPE => RedirectConditionType::DEVICE->value, + RedirectRulesInputFilter::CONDITION_MATCH_KEY => null, + RedirectRulesInputFilter::CONDITION_MATCH_VALUE => DeviceType::IOS->value, + ], + ], + ], + ], + ]); + + $this->em->expects($this->once())->method('wrapInTransaction')->willReturnCallback( + fn (callable $callback) => $callback(), + ); + $this->em->expects($this->exactly(2))->method('persist'); + $this->em->expects($this->never())->method('remove'); + + $result = $this->ruleService->setRulesForShortUrl($shortUrl, $data); + + self::assertCount(2, $result); + self::assertInstanceOf(ShortUrlRedirectRule::class, $result[0]); + self::assertInstanceOf(ShortUrlRedirectRule::class, $result[1]); + } + + #[Test] + public function setRulesForShortUrlRemovesOldRules(): void + { + $shortUrl = ShortUrl::withLongUrl('https://example.com'); + $data = RedirectRulesData::fromRawData([ + RedirectRulesInputFilter::REDIRECT_RULES => [], + ]); + + $repo = $this->createMock(EntityRepository::class); + $repo->expects($this->once())->method('findBy')->with( + ['shortUrl' => $shortUrl], + ['priority' => 'ASC'], + )->willReturn([ + new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com'), + new ShortUrlRedirectRule($shortUrl, 2, 'https://example.com'), + ]); + $this->em->expects($this->once())->method('getRepository')->with(ShortUrlRedirectRule::class)->willReturn( + $repo, + ); + $this->em->expects($this->once())->method('wrapInTransaction')->willReturnCallback( + fn (callable $callback) => $callback(), + ); + $this->em->expects($this->never())->method('persist'); + $this->em->expects($this->exactly(2))->method('remove'); + + $result = $this->ruleService->setRulesForShortUrl($shortUrl, $data); + + self::assertCount(0, $result); + } + + #[Test] + public function saveRulesForShortUrlDetachesAllEntitiesAndArrangesPriorities(): void + { + $shortUrl = ShortUrl::withLongUrl('https://example.com'); + $rules = [ + new ShortUrlRedirectRule($shortUrl, 8, 'https://example.com', new ArrayCollection([ + RedirectCondition::forLanguage('es-ES'), + RedirectCondition::forDevice(DeviceType::ANDROID), + ])), + new ShortUrlRedirectRule($shortUrl, 3, 'https://example.com', new ArrayCollection([ + RedirectCondition::forQueryParam('foo', 'bar'), + RedirectCondition::forQueryParam('bar', 'foo'), + ])), + new ShortUrlRedirectRule($shortUrl, 15, 'https://example.com', new ArrayCollection([ + RedirectCondition::forDevice(DeviceType::IOS), + ])), + ]; + + // Detach will be called 8 times: 3 rules + 5 conditions + $this->em->expects($this->exactly(8))->method('detach'); + $this->em->expects($this->once())->method('wrapInTransaction')->willReturnCallback( + fn (callable $callback) => $callback(), + ); + + // Persist will be called for each of the three rules. Their priorities should be consecutive starting at 1 + $cont = 0; + $this->em->expects($this->exactly(3))->method('persist')->with($this->callback( + function (ShortUrlRedirectRule $rule) use (&$cont): bool { + $cont++; + return $rule->jsonSerialize()['priority'] === $cont; + }, + )); + + $this->ruleService->saveRulesForShortUrl($shortUrl, $rules); + } +} diff --git a/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php new file mode 100644 index 00000000..5bf435b2 --- /dev/null +++ b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php @@ -0,0 +1,92 @@ +ruleService = $this->createMock(ShortUrlRedirectRuleServiceInterface::class); + $this->resolver = new ShortUrlRedirectionResolver($this->ruleService); + } + + #[Test, DataProvider('provideData')] + public function resolveLongUrlReturnsExpectedValue( + ServerRequestInterface $request, + ?RedirectCondition $condition, + string $expectedUrl, + ): void { + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'https://example.com/foo/bar', + ])); + + $this->ruleService->expects($this->once())->method('rulesForShortUrl')->with($shortUrl)->willReturn( + $condition !== null ? [ + new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com/from-rule', new ArrayCollection([ + $condition, + ])), + ] : [], + ); + + $result = $this->resolver->resolveLongUrl($shortUrl, $request); + + self::assertEquals($expectedUrl, $result); + } + + public static function provideData(): iterable + { + $request = static fn (string $userAgent = '') => ServerRequestFactory::fromGlobals()->withHeader( + 'User-Agent', + $userAgent, + ); + + yield 'unknown user agent' => [ + $request('Unknown'), // This user agent won't match any device + RedirectCondition::forLanguage('es-ES'), // This condition won't match + 'https://example.com/foo/bar', + ]; + yield 'desktop user agent' => [$request(DESKTOP_USER_AGENT), null, 'https://example.com/foo/bar']; + yield 'matching android device' => [ + $request(ANDROID_USER_AGENT), + RedirectCondition::forDevice(DeviceType::ANDROID), + 'https://example.com/from-rule', + ]; + yield 'matching ios device' => [ + $request(IOS_USER_AGENT), + RedirectCondition::forDevice(DeviceType::IOS), + 'https://example.com/from-rule', + ]; + yield 'matching language' => [ + $request()->withHeader('Accept-Language', 'es-ES'), + RedirectCondition::forLanguage('es-ES'), + 'https://example.com/from-rule', + ]; + yield 'matching query params' => [ + $request()->withQueryParams(['foo' => 'bar']), + RedirectCondition::forQueryParam('foo', 'bar'), + 'https://example.com/from-rule', + ]; + } +} diff --git a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php index 0a898399..be5b4101 100644 --- a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php +++ b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php @@ -7,13 +7,12 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Entity; use Cake\Chronos\Chronos; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; @@ -92,45 +91,24 @@ class ShortUrlTest extends TestCase } #[Test] - public function deviceLongUrlsAreUpdated(): void - { - $shortUrl = ShortUrl::withLongUrl('https://foo'); - - $shortUrl->update(ShortUrlEdition::fromRawData([ - ShortUrlInputFilter::DEVICE_LONG_URLS => [ - DeviceType::ANDROID->value => 'https://android', - DeviceType::IOS->value => 'https://ios', - ], + #[TestWith([null, '', 5])] + #[TestWith(['foo bar/', 'foo-bar-', 13])] + public function shortCodesHaveExpectedPrefix( + ?string $pathPrefix, + string $expectedPrefix, + int $expectedShortCodeLength, + ): void { + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'https://longUrl', + ShortUrlInputFilter::SHORT_CODE_LENGTH => 5, + ShortUrlInputFilter::PATH_PREFIX => $pathPrefix, ])); - self::assertEquals([ - DeviceType::ANDROID->value => 'https://android', - DeviceType::IOS->value => 'https://ios', - DeviceType::DESKTOP->value => null, - ], $shortUrl->deviceLongUrls()); + $shortCode = $shortUrl->getShortCode(); - $shortUrl->update(ShortUrlEdition::fromRawData([ - ShortUrlInputFilter::DEVICE_LONG_URLS => [ - DeviceType::ANDROID->value => null, - DeviceType::DESKTOP->value => 'https://desktop', - ], - ])); - self::assertEquals([ - DeviceType::ANDROID->value => null, - DeviceType::IOS->value => 'https://ios', - DeviceType::DESKTOP->value => 'https://desktop', - ], $shortUrl->deviceLongUrls()); - - $shortUrl->update(ShortUrlEdition::fromRawData([ - ShortUrlInputFilter::DEVICE_LONG_URLS => [ - DeviceType::ANDROID->value => null, - DeviceType::IOS->value => null, - ], - ])); - self::assertEquals([ - DeviceType::ANDROID->value => null, - DeviceType::IOS->value => null, - DeviceType::DESKTOP->value => 'https://desktop', - ], $shortUrl->deviceLongUrls()); + if (strlen($expectedPrefix) > 0) { + self::assertStringStartsWith($expectedPrefix, $shortCode); + } + self::assertEquals($expectedShortCodeLength, strlen($shortCode)); } #[Test] diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php index cf88db35..53a86322 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php @@ -7,26 +7,26 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Helper; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\Options\TrackingOptions; +use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectionResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilder; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; -use const ShlinkioTest\Shlink\ANDROID_USER_AGENT; -use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT; -use const ShlinkioTest\Shlink\IOS_USER_AGENT; - class ShortUrlRedirectionBuilderTest extends TestCase { private ShortUrlRedirectionBuilder $redirectionBuilder; + private ShortUrlRedirectionResolverInterface & MockObject $redirectionResolver; protected function setUp(): void { $trackingOptions = new TrackingOptions(disableTrackParam: 'foobar'); - $this->redirectionBuilder = new ShortUrlRedirectionBuilder($trackingOptions); + $this->redirectionResolver = $this->createMock(ShortUrlRedirectionResolverInterface::class); + + $this->redirectionBuilder = new ShortUrlRedirectionBuilder($trackingOptions, $this->redirectionResolver); } #[Test, DataProvider('provideData')] @@ -39,11 +39,12 @@ class ShortUrlRedirectionBuilderTest extends TestCase $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'longUrl' => 'https://domain.com/foo/bar?some=thing', 'forwardQuery' => $forwardQuery, - 'deviceLongUrls' => [ - DeviceType::ANDROID->value => 'https://domain.com/android', - DeviceType::IOS->value => 'https://domain.com/ios', - ], ])); + $this->redirectionResolver->expects($this->once())->method('resolveLongUrl')->with( + $shortUrl, + $request, + )->willReturn($shortUrl->getLongUrl()); + $result = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request, $extraPath); self::assertEquals($expectedUrl, $result); @@ -72,7 +73,7 @@ class ShortUrlRedirectionBuilderTest extends TestCase ]; yield [ 'https://domain.com/foo/bar?some=overwritten', - $request(['foobar' => 'notrack', 'some' => 'overwritten'])->withHeader('User-Agent', 'Unknown'), + $request(['foobar' => 'notrack', 'some' => 'overwritten']), null, true, ]; @@ -91,7 +92,7 @@ class ShortUrlRedirectionBuilderTest extends TestCase yield ['https://domain.com/foo/bar/something/else-baz?some=thing', $request(), '/something/else-baz', true]; yield [ 'https://domain.com/foo/bar/something/else-baz?some=thing&hello=world', - $request(['hello' => 'world'])->withHeader('User-Agent', DESKTOP_USER_AGENT), + $request(['hello' => 'world']), '/something/else-baz', true, ]; @@ -107,17 +108,5 @@ class ShortUrlRedirectionBuilderTest extends TestCase '/something/else-baz', false, ]; - yield [ - 'https://domain.com/android/something', - $request(['foo' => 'bar'])->withHeader('User-Agent', ANDROID_USER_AGENT), - '/something', - false, - ]; - yield [ - 'https://domain.com/ios?foo=bar', - $request(['foo' => 'bar'])->withHeader('User-Agent', IOS_USER_AGENT), - null, - null, - ]; } } diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php index ae89fa6f..06b47f8c 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php @@ -4,46 +4,144 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ShortUrl\Helper; -use PHPUnit\Framework\Attributes\DataProvider; +use Exception; +use Fig\Http\Message\RequestMethodInterface; +use GuzzleHttp\ClientInterface; +use GuzzleHttp\RequestOptions; +use Laminas\Diactoros\Response; +use Laminas\Diactoros\Response\JsonResponse; +use Laminas\Diactoros\Stream; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\MockObject\Builder\InvocationMocker; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelper; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; -use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; class ShortUrlTitleResolutionHelperTest extends TestCase { - private ShortUrlTitleResolutionHelper $helper; - private MockObject & UrlValidatorInterface $urlValidator; + private const LONG_URL = 'http://foobar.com/12345/hello?foo=bar'; + + private MockObject & ClientInterface $httpClient; protected function setUp(): void { - $this->urlValidator = $this->createMock(UrlValidatorInterface::class); - $this->helper = new ShortUrlTitleResolutionHelper($this->urlValidator); + $this->httpClient = $this->createMock(ClientInterface::class); } - #[Test, DataProvider('provideTitles')] - public function urlIsProperlyShortened(?string $title, int $validateWithTitleCallsNum, int $validateCallsNum): void + #[Test] + public function dataIsReturnedAsIsWhenResolvingTitlesIsDisabled(): void { - $longUrl = 'http://foobar.com/12345/hello?foo=bar'; - $this->urlValidator->expects($this->exactly($validateWithTitleCallsNum))->method('validateUrlWithTitle')->with( - $longUrl, - $this->isFalse(), - ); - $this->urlValidator->expects($this->exactly($validateCallsNum))->method('validateUrl')->with( - $longUrl, - $this->isFalse(), - ); + $data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]); + $this->httpClient->expects($this->never())->method('request'); - $this->helper->processTitleAndValidateUrl( - ShortUrlCreation::fromRawData(['longUrl' => $longUrl, 'title' => $title]), + $result = $this->helper()->processTitle($data); + + self::assertSame($data, $result); + } + + #[Test] + public function dataIsReturnedAsIsWhenItAlreadyHasTitle(): void + { + $data = ShortUrlCreation::fromRawData([ + 'longUrl' => self::LONG_URL, + 'title' => 'foo', + ]); + $this->httpClient->expects($this->never())->method('request'); + + $result = $this->helper(autoResolveTitles: true)->processTitle($data); + + self::assertSame($data, $result); + } + + #[Test] + public function dataIsReturnedAsIsWhenFetchingFails(): void + { + $data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]); + $this->expectRequestToBeCalled()->willThrowException(new Exception('Error')); + + $result = $this->helper(autoResolveTitles: true)->processTitle($data); + + self::assertSame($data, $result); + } + + #[Test] + public function dataIsReturnedAsIsWhenResponseIsNotHtml(): void + { + $data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]); + $this->expectRequestToBeCalled()->willReturn(new JsonResponse(['foo' => 'bar'])); + + $result = $this->helper(autoResolveTitles: true)->processTitle($data); + + self::assertSame($data, $result); + } + + #[Test] + public function dataIsReturnedAsIsWhenTitleCannotBeResolvedFromResponse(): void + { + $data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]); + $this->expectRequestToBeCalled()->willReturn($this->respWithoutTitle()); + + $result = $this->helper(autoResolveTitles: true)->processTitle($data); + + self::assertSame($data, $result); + } + + #[Test] + public function titleIsUpdatedWhenItCanBeResolvedFromResponse(): void + { + $data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]); + $this->expectRequestToBeCalled()->willReturn($this->respWithTitle()); + + $result = $this->helper(autoResolveTitles: true)->processTitle($data); + + self::assertNotSame($data, $result); + self::assertEquals('Resolved "title"', $result->title); + } + + private function expectRequestToBeCalled(): InvocationMocker + { + return $this->httpClient->expects($this->once())->method('request')->with( + RequestMethodInterface::METHOD_GET, + self::LONG_URL, + [ + RequestOptions::TIMEOUT => 3, + RequestOptions::CONNECT_TIMEOUT => 3, + RequestOptions::ALLOW_REDIRECTS => ['max' => ShortUrlTitleResolutionHelper::MAX_REDIRECTS], + RequestOptions::IDN_CONVERSION => true, + RequestOptions::HEADERS => ['User-Agent' => ShortUrlTitleResolutionHelper::CHROME_USER_AGENT], + RequestOptions::STREAM => true, + ], ); } - public static function provideTitles(): iterable + private function respWithoutTitle(): Response { - yield 'no title' => [null, 1, 0]; - yield 'title' => ['link title', 0, 1]; + $body = $this->createStreamWithContent('No title'); + return new Response($body, 200, ['Content-Type' => 'text/html']); + } + + private function respWithTitle(): Response + { + $body = $this->createStreamWithContent(' Resolved "title" '); + return new Response($body, 200, ['Content-Type' => 'TEXT/html; charset=utf-8']); + } + + private function createStreamWithContent(string $content): Stream + { + $body = new Stream('php://temp', 'wr'); + $body->write($content); + $body->rewind(); + + return $body; + } + + private function helper(bool $autoResolveTitles = false): ShortUrlTitleResolutionHelper + { + return new ShortUrlTitleResolutionHelper( + $this->httpClient, + new UrlShortenerOptions(autoResolveTitles: $autoResolveTitles), + ); } } diff --git a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php index 47d4648c..b84b5b27 100644 --- a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php +++ b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php @@ -9,7 +9,6 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\ValidationException; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; @@ -79,43 +78,6 @@ class ShortUrlCreationTest extends TestCase yield [[ ShortUrlInputFilter::LONG_URL => 'missing_schema', ]]; - yield [[ - ShortUrlInputFilter::LONG_URL => 'https://foo', - ShortUrlInputFilter::DEVICE_LONG_URLS => [ - 'invalid' => 'https://shlink.io', - ], - ]]; - yield [[ - ShortUrlInputFilter::LONG_URL => 'https://foo', - ShortUrlInputFilter::DEVICE_LONG_URLS => [ - DeviceType::DESKTOP->value => '', - ], - ]]; - yield [[ - ShortUrlInputFilter::LONG_URL => 'https://foo', - ShortUrlInputFilter::DEVICE_LONG_URLS => [ - DeviceType::DESKTOP->value => null, - ], - ]]; - yield [[ - ShortUrlInputFilter::LONG_URL => 'https://foo', - ShortUrlInputFilter::DEVICE_LONG_URLS => [ - DeviceType::IOS->value => ' ', - ], - ]]; - yield [[ - ShortUrlInputFilter::LONG_URL => 'https://foo', - ShortUrlInputFilter::DEVICE_LONG_URLS => [ - DeviceType::ANDROID->value => 'missing_schema', - ], - ]]; - yield [[ - ShortUrlInputFilter::LONG_URL => 'https://foo', - ShortUrlInputFilter::DEVICE_LONG_URLS => [ - DeviceType::IOS->value => 'https://bar', - DeviceType::ANDROID->value => [], - ], - ]]; } #[Test, DataProvider('provideCustomSlugs')] diff --git a/module/Core/test/ShortUrl/Model/ShortUrlEditionTest.php b/module/Core/test/ShortUrl/Model/ShortUrlEditionTest.php deleted file mode 100644 index 5d77d806..00000000 --- a/module/Core/test/ShortUrl/Model/ShortUrlEditionTest.php +++ /dev/null @@ -1,59 +0,0 @@ - $deviceLongUrls]); - - self::assertEquals($expectedDeviceLongUrls, $edition->deviceLongUrls); - self::assertEquals($expectedDevicesToRemove, $edition->devicesToRemove); - } - - public static function provideDeviceLongUrls(): iterable - { - yield 'null' => [null, [], []]; - yield 'empty' => [[], [], []]; - yield 'only new urls' => [[ - DeviceType::DESKTOP->value => 'https://foo', - DeviceType::IOS->value => 'https://bar', - ], [ - DeviceType::DESKTOP->value => DeviceLongUrlPair::fromRawTypeAndLongUrl( - DeviceType::DESKTOP->value, - 'https://foo', - ), - DeviceType::IOS->value => DeviceLongUrlPair::fromRawTypeAndLongUrl(DeviceType::IOS->value, 'https://bar'), - ], []]; - yield 'only urls to remove' => [[ - DeviceType::ANDROID->value => null, - DeviceType::IOS->value => null, - ], [], [DeviceType::ANDROID, DeviceType::IOS]]; - yield 'both' => [[ - DeviceType::DESKTOP->value => 'https://bar', - DeviceType::IOS->value => 'https://foo', - DeviceType::ANDROID->value => null, - ], [ - DeviceType::DESKTOP->value => DeviceLongUrlPair::fromRawTypeAndLongUrl( - DeviceType::DESKTOP->value, - 'https://bar', - ), - DeviceType::IOS->value => DeviceLongUrlPair::fromRawTypeAndLongUrl(DeviceType::IOS->value, 'https://foo'), - ], [DeviceType::ANDROID]]; - } -} diff --git a/module/Core/test/ShortUrl/Model/ShortUrlModeTest.php b/module/Core/test/ShortUrl/Model/ShortUrlModeTest.php deleted file mode 100644 index f2ca7cce..00000000 --- a/module/Core/test/ShortUrl/Model/ShortUrlModeTest.php +++ /dev/null @@ -1,28 +0,0 @@ - ['invalid', null]; - yield 'foo' => ['foo', null]; - yield 'loose' => ['loose', ShortUrlMode::LOOSE]; - yield 'loosely' => ['loosely', ShortUrlMode::LOOSE]; - yield 'strict' => ['strict', ShortUrlMode::STRICT]; - } -} diff --git a/module/Core/test/ShortUrl/Model/Validation/DeviceLongUrlsValidatorTest.php b/module/Core/test/ShortUrl/Model/Validation/DeviceLongUrlsValidatorTest.php deleted file mode 100644 index 860e2a39..00000000 --- a/module/Core/test/ShortUrl/Model/Validation/DeviceLongUrlsValidatorTest.php +++ /dev/null @@ -1,70 +0,0 @@ -validator = new DeviceLongUrlsValidator(new NotEmpty()); - } - - #[Test, DataProvider('provideNonArrayValues')] - public function nonArrayValuesAreNotValid(mixed $invalidValue): void - { - self::assertFalse($this->validator->isValid($invalidValue)); - self::assertEquals(['NOT_ARRAY' => 'Provided value is not an array.'], $this->validator->getMessages()); - } - - public static function provideNonArrayValues(): iterable - { - yield 'int' => [0]; - yield 'float' => [100.45]; - yield 'string' => ['foo']; - yield 'boolean' => [true]; - yield 'object' => [new stdClass()]; - yield 'null' => [null]; - } - - #[Test] - public function unrecognizedKeysAreNotValid(): void - { - self::assertFalse($this->validator->isValid(['foo' => 'bar'])); - self::assertEquals( - ['INVALID_DEVICE' => 'You have provided at least one invalid device identifier.'], - $this->validator->getMessages(), - ); - } - - #[Test] - public function everyUrlMustMatchLongUrlValidator(): void - { - self::assertFalse($this->validator->isValid([DeviceType::ANDROID->value => ''])); - self::assertEquals( - ['INVALID_LONG_URL' => 'At least one of the long URLs are invalid.'], - $this->validator->getMessages(), - ); - } - - #[Test] - public function validValuesResultInValidResult(): void - { - self::assertTrue($this->validator->isValid([ - DeviceType::ANDROID->value => 'foo', - DeviceType::IOS->value => 'bar', - DeviceType::DESKTOP->value => 'baz', - ])); - } -} diff --git a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php index d7af118d..43860909 100644 --- a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -11,11 +11,11 @@ use PHPUnit\Framework\Attributes\Test; 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\Domain\Repository\DomainRepository; 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; +use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; use function count; @@ -50,7 +50,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase #[Test, DataProvider('provideFoundDomains')] public function findsOrCreatesDomainWhenValueIsProvided(?Domain $foundDomain, string $authority): void { - $repo = $this->createMock(DomainRepositoryInterface::class); + $repo = $this->createMock(DomainRepository::class); $repo->expects($this->once())->method('findOneBy')->with(['authority' => $authority])->willReturn($foundDomain); $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); @@ -78,7 +78,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase // One of the tags will already exist. The rest will be new $expectedPersistedTags = $expectedLookedOutTags - 1; - $tagRepo = $this->createMock(TagRepositoryInterface::class); + $tagRepo = $this->createMock(TagRepository::class); $tagRepo->expects($this->exactly($expectedLookedOutTags))->method('findOneBy')->with( $this->isType('array'), )->willReturnCallback(function (array $criteria): ?Tag { @@ -116,7 +116,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase #[Test] public function newDomainsAreMemoizedUntilStateIsCleared(): void { - $repo = $this->createMock(DomainRepositoryInterface::class); + $repo = $this->createMock(DomainRepository::class); $repo->expects($this->exactly(3))->method('findOneBy')->with($this->isType('array'))->willReturn(null); $this->em->method('getRepository')->with(Domain::class)->willReturn($repo); @@ -135,7 +135,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase #[Test] public function newTagsAreMemoizedUntilStateIsCleared(): void { - $tagRepo = $this->createMock(TagRepositoryInterface::class); + $tagRepo = $this->createMock(TagRepository::class); $tagRepo->expects($this->exactly(6))->method('findOneBy')->with($this->isType('array'))->willReturn(null); $this->em->method('getRepository')->with(Tag::class)->willReturn($tagRepo); diff --git a/module/Core/test/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/ShortUrl/ShortUrlResolverTest.php index 729302c9..1b3aa564 100644 --- a/module/Core/test/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/ShortUrl/ShortUrlResolverTest.php @@ -18,7 +18,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolver; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; @@ -32,12 +32,12 @@ class ShortUrlResolverTest extends TestCase { private ShortUrlResolver $urlResolver; private MockObject & EntityManagerInterface $em; - private MockObject & ShortUrlRepositoryInterface $repo; + private MockObject & ShortUrlRepository $repo; protected function setUp(): void { $this->em = $this->createMock(EntityManagerInterface::class); - $this->repo = $this->createMock(ShortUrlRepositoryInterface::class); + $this->repo = $this->createMock(ShortUrlRepository::class); $this->urlResolver = new ShortUrlResolver($this->em, new UrlShortenerOptions()); } diff --git a/module/Core/test/ShortUrl/ShortUrlServiceTest.php b/module/Core/test/ShortUrl/ShortUrlServiceTest.php index 67b10720..ae73ba33 100644 --- a/module/Core/test/ShortUrl/ShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlServiceTest.php @@ -12,7 +12,6 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Rule\InvocationOrder; use PHPUnit\Framework\MockObject\Rule\InvokedCount; use PHPUnit\Framework\TestCase; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; @@ -22,9 +21,6 @@ use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlService; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use function array_fill_keys; -use function Shlinkio\Shlink\Core\enumValues; - class ShortUrlServiceTest extends TestCase { private ShortUrlService $service; @@ -63,7 +59,7 @@ class ShortUrlServiceTest extends TestCase )->willReturn($shortUrl); $this->titleResolutionHelper->expects($expectedValidateCalls) - ->method('processTitleAndValidateUrl') + ->method('processTitle') ->with($shortUrlEdit) ->willReturn($shortUrlEdit); @@ -73,21 +69,11 @@ class ShortUrlServiceTest extends TestCase $apiKey, ); - $resolveDeviceLongUrls = function () use ($shortUrlEdit): array { - $result = array_fill_keys(enumValues(DeviceType::class), null); - foreach ($shortUrlEdit->deviceLongUrls ?? [] as $longUrl) { - $result[$longUrl->deviceType->value] = $longUrl->longUrl; - } - - return $result; - }; - self::assertSame($shortUrl, $result); self::assertEquals($shortUrlEdit->validSince, $shortUrl->getValidSince()); self::assertEquals($shortUrlEdit->validUntil, $shortUrl->getValidUntil()); self::assertEquals($shortUrlEdit->maxVisits, $shortUrl->getMaxVisits()); self::assertEquals($shortUrlEdit->longUrl ?? $originalLongUrl, $shortUrl->getLongUrl()); - self::assertEquals($resolveDeviceLongUrls(), $shortUrl->deviceLongUrls()); } public static function provideShortUrlEdits(): iterable @@ -102,15 +88,5 @@ class ShortUrlServiceTest extends TestCase 'maxVisits' => 10, 'longUrl' => 'https://modifiedLongUrl', ]), ApiKey::create()]; - yield 'long URL with validation' => [new InvokedCount(1), ShortUrlEdition::fromRawData([ - 'longUrl' => 'https://modifiedLongUrl', - 'validateUrl' => true, - ]), null]; - yield 'device redirects' => [new InvokedCount(0), ShortUrlEdition::fromRawData([ - 'deviceLongUrls' => [ - DeviceType::IOS->value => 'https://iosLongUrl', - DeviceType::ANDROID->value => 'https://androidLongUrl', - ], - ]), null]; } } diff --git a/module/Core/test/ShortUrl/UrlShortenerTest.php b/module/Core/test/ShortUrl/UrlShortenerTest.php index a442abb3..b332afd2 100644 --- a/module/Core/test/ShortUrl/UrlShortenerTest.php +++ b/module/Core/test/ShortUrl/UrlShortenerTest.php @@ -57,7 +57,7 @@ class UrlShortenerTest extends TestCase { $longUrl = 'http://foobar.com/12345/hello?foo=bar'; $meta = ShortUrlCreation::fromRawData(['longUrl' => $longUrl]); - $this->titleResolutionHelper->expects($this->once())->method('processTitleAndValidateUrl')->with( + $this->titleResolutionHelper->expects($this->once())->method('processTitle')->with( $meta, )->willReturnArgument(0); $this->shortCodeHelper->method('ensureShortCodeUniqueness')->willReturn(true); @@ -90,7 +90,7 @@ class UrlShortenerTest extends TestCase ); $this->shortCodeHelper->expects($this->once())->method('ensureShortCodeUniqueness')->willReturn(false); - $this->titleResolutionHelper->expects($this->once())->method('processTitleAndValidateUrl')->with( + $this->titleResolutionHelper->expects($this->once())->method('processTitle')->with( $meta, )->willReturnArgument(0); @@ -105,7 +105,7 @@ class UrlShortenerTest extends TestCase $repo = $this->createMock(ShortUrlRepository::class); $repo->expects($this->once())->method('findOneMatching')->willReturn($expected); $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($repo); - $this->titleResolutionHelper->expects($this->never())->method('processTitleAndValidateUrl'); + $this->titleResolutionHelper->expects($this->never())->method('processTitle'); $this->shortCodeHelper->method('ensureShortCodeUniqueness')->willReturn(true); $result = $this->urlShortener->shorten($meta); diff --git a/module/Core/test/Util/UrlValidatorTest.php b/module/Core/test/Util/UrlValidatorTest.php deleted file mode 100644 index 233d69bd..00000000 --- a/module/Core/test/Util/UrlValidatorTest.php +++ /dev/null @@ -1,176 +0,0 @@ -httpClient = $this->createMock(ClientInterface::class); - } - - #[Test] - public function exceptionIsThrownWhenUrlIsInvalid(): void - { - $this->httpClient->expects($this->once())->method('request')->willThrowException($this->clientException()); - $this->expectException(InvalidUrlException::class); - - $this->urlValidator()->validateUrl('http://foobar.com/12345/hello?foo=bar', true); - } - - #[Test] - public function expectedUrlIsCalledWhenTryingToVerify(): void - { - $expectedUrl = 'http://foobar.com'; - - $this->httpClient->expects($this->once())->method('request')->with( - RequestMethodInterface::METHOD_GET, - $expectedUrl, - $this->callback(function (array $options) { - Assert::assertArrayHasKey(RequestOptions::ALLOW_REDIRECTS, $options); - Assert::assertEquals(['max' => 15], $options[RequestOptions::ALLOW_REDIRECTS]); - Assert::assertArrayHasKey(RequestOptions::IDN_CONVERSION, $options); - Assert::assertTrue($options[RequestOptions::IDN_CONVERSION]); - Assert::assertArrayHasKey(RequestOptions::HEADERS, $options); - Assert::assertArrayHasKey('User-Agent', $options[RequestOptions::HEADERS]); - - return true; - }), - )->willReturn(new Response()); - - $this->urlValidator()->validateUrl($expectedUrl, true); - } - - #[Test] - public function noCheckIsPerformedWhenUrlValidationIsDisabled(): void - { - $this->httpClient->expects($this->never())->method('request'); - $this->urlValidator()->validateUrl('', false); - } - - #[Test] - public function validateUrlWithTitleReturnsNullWhenRequestFailsAndValidationIsDisabled(): void - { - $this->httpClient->expects($this->once())->method('request')->willThrowException($this->clientException()); - - $result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', false); - - self::assertNull($result); - } - - #[Test] - public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsDisabled(): void - { - $this->httpClient->expects($this->never())->method('request'); - - $result = $this->urlValidator()->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', false); - - self::assertNull($result); - } - - #[Test] - public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsDisabledAndValidationIsEnabled(): void - { - $this->httpClient->expects($this->once())->method('request')->with( - RequestMethodInterface::METHOD_HEAD, - $this->anything(), - $this->anything(), - )->willReturn($this->respWithTitle()); - - $result = $this->urlValidator()->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); - - self::assertNull($result); - } - - #[Test] - public function validateUrlWithTitleResolvesTitleWhenAutoResolutionIsEnabled(): void - { - $this->httpClient->expects($this->once())->method('request')->with( - RequestMethodInterface::METHOD_GET, - $this->anything(), - $this->anything(), - )->willReturn($this->respWithTitle()); - - $result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); - - self::assertEquals('Resolved "title"', $result); - } - - #[Test] - public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsEnabledAndReturnedContentTypeIsInvalid(): void - { - $this->httpClient->expects($this->once())->method('request')->with( - RequestMethodInterface::METHOD_GET, - $this->anything(), - $this->anything(), - )->willReturn(new Response('php://memory', 200, ['Content-Type' => 'application/octet-stream'])); - - $result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); - - self::assertNull($result); - } - - #[Test] - public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsEnabledAndBodyDoesNotContainTitle(): void - { - $this->httpClient->expects($this->once())->method('request')->with( - RequestMethodInterface::METHOD_GET, - $this->anything(), - $this->anything(), - )->willReturn( - new Response($this->createStreamWithContent('No title'), 200, ['Content-Type' => 'text/html']), - ); - - $result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); - - self::assertNull($result); - } - - private function respWithTitle(): Response - { - $body = $this->createStreamWithContent(' Resolved "title" '); - return new Response($body, 200, ['Content-Type' => 'TEXT/html; charset=utf-8']); - } - - private function createStreamWithContent(string $content): Stream - { - $body = new Stream('php://temp', 'wr'); - $body->write($content); - $body->rewind(); - - return $body; - } - - private function clientException(): ClientException - { - return new ClientException( - '', - new Request(RequestMethodInterface::METHOD_GET, ''), - new Response(), - ); - } - - public function urlValidator(bool $autoResolveTitles = false): UrlValidator - { - return new UrlValidator($this->httpClient, new UrlShortenerOptions(autoResolveTitles: $autoResolveTitles)); - } -} diff --git a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php index 04e3f84c..3e50faf0 100644 --- a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php @@ -9,11 +9,11 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; use Shlinkio\Shlink\Core\Visit\Model\Visitor; -use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter; -use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; -use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -21,13 +21,13 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase { private OrphanVisitsPaginatorAdapter $adapter; private MockObject & VisitRepositoryInterface $repo; - private VisitsParams $params; + private OrphanVisitsParams $params; private ApiKey $apiKey; protected function setUp(): void { $this->repo = $this->createMock(VisitRepositoryInterface::class); - $this->params = VisitsParams::fromRawData([]); + $this->params = OrphanVisitsParams::fromRawData([]); $this->apiKey = ApiKey::create(); $this->adapter = new OrphanVisitsPaginatorAdapter($this->repo, $this->params, $this->apiKey); @@ -38,7 +38,7 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase { $expectedCount = 5; $this->repo->expects($this->once())->method('countOrphanVisits')->with( - new VisitsCountFiltering($this->params->dateRange, apiKey: $this->apiKey), + new OrphanVisitsCountFiltering($this->params->dateRange, apiKey: $this->apiKey), )->willReturn($expectedCount); $result = $this->adapter->getNbResults(); @@ -55,12 +55,12 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase { $visitor = Visitor::emptyInstance(); $list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)]; - $this->repo->expects($this->once())->method('findOrphanVisits')->with(new VisitsListFiltering( - $this->params->dateRange, - $this->params->excludeBots, - $this->apiKey, - $limit, - $offset, + $this->repo->expects($this->once())->method('findOrphanVisits')->with(new OrphanVisitsListFiltering( + dateRange: $this->params->dateRange, + excludeBots: $this->params->excludeBots, + apiKey: $this->apiKey, + limit: $limit, + offset: $offset, ))->willReturn($list); $result = $this->adapter->getSlice($offset, $limit); diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index dd11fdef..f6bb5464 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -19,13 +19,16 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository; @@ -87,7 +90,7 @@ class VisitsStatsHelperTest extends TestCase $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); $spec = $apiKey?->spec(); - $repo = $this->createMock(ShortUrlRepositoryInterface::class); + $repo = $this->createMock(ShortUrlRepository::class); $repo->expects($this->once())->method('shortCodeIsInUse')->with($identifier, $spec)->willReturn(true); $list = array_map( @@ -120,7 +123,7 @@ class VisitsStatsHelperTest extends TestCase $shortCode = '123ABC'; $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); - $repo = $this->createMock(ShortUrlRepositoryInterface::class); + $repo = $this->createMock(ShortUrlRepository::class); $repo->expects($this->once())->method('shortCodeIsInUse')->with($identifier, null)->willReturn(false); $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($repo); @@ -251,14 +254,14 @@ class VisitsStatsHelperTest extends TestCase $list = array_map(static fn () => Visit::forBasePath(Visitor::emptyInstance()), range(0, 3)); $repo = $this->createMock(VisitRepository::class); $repo->expects($this->once())->method('countOrphanVisits')->with( - $this->isInstanceOf(VisitsCountFiltering::class), + $this->isInstanceOf(OrphanVisitsCountFiltering::class), )->willReturn(count($list)); $repo->expects($this->once())->method('findOrphanVisits')->with( - $this->isInstanceOf(VisitsListFiltering::class), + $this->isInstanceOf(OrphanVisitsListFiltering::class), )->willReturn($list); $this->em->expects($this->once())->method('getRepository')->with(Visit::class)->willReturn($repo); - $paginator = $this->helper->orphanVisits(new VisitsParams()); + $paginator = $this->helper->orphanVisits(new OrphanVisitsParams()); self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); } diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index acca571d..9396dd38 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -12,6 +12,7 @@ use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider; use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Options; +use Shlinkio\Shlink\Core\RedirectRule; use Shlinkio\Shlink\Core\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\Tag\TagService; @@ -46,6 +47,8 @@ return [ Action\Tag\UpdateTagAction::class => ConfigAbstractFactory::class, Action\Domain\ListDomainsAction::class => ConfigAbstractFactory::class, Action\Domain\DomainRedirectsAction::class => ConfigAbstractFactory::class, + Action\RedirectRule\ListRedirectRulesAction::class => ConfigAbstractFactory::class, + Action\RedirectRule\SetRedirectRulesAction::class => ConfigAbstractFactory::class, ImplicitOptionsMiddleware::class => Middleware\EmptyResponseImplicitOptionsMiddlewareFactory::class, Middleware\BodyParserMiddleware::class => InvokableFactory::class, @@ -55,7 +58,6 @@ return [ Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => ConfigAbstractFactory::class, Middleware\ShortUrl\OverrideDomainMiddleware::class => ConfigAbstractFactory::class, Middleware\Mercure\NotConfiguredMercureErrorHandler::class => ConfigAbstractFactory::class, - Middleware\ErrorHandler\BackwardsCompatibleProblemDetailsHandler::class => InvokableFactory::class, ], ], @@ -104,6 +106,14 @@ return [ Action\Tag\UpdateTagAction::class => [TagService::class], Action\Domain\ListDomainsAction::class => [DomainService::class, Options\NotFoundRedirectOptions::class], Action\Domain\DomainRedirectsAction::class => [DomainService::class], + Action\RedirectRule\ListRedirectRulesAction::class => [ + ShortUrl\ShortUrlResolver::class, + RedirectRule\ShortUrlRedirectRuleService::class, + ], + Action\RedirectRule\SetRedirectRulesAction::class => [ + ShortUrl\ShortUrlResolver::class, + RedirectRule\ShortUrlRedirectRuleService::class, + ], Middleware\CrossDomainMiddleware::class => ['config.cors'], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'], diff --git a/module/Rest/src/Action/RedirectRule/ListRedirectRulesAction.php b/module/Rest/src/Action/RedirectRule/ListRedirectRulesAction.php new file mode 100644 index 00000000..c6c12fd9 --- /dev/null +++ b/module/Rest/src/Action/RedirectRule/ListRedirectRulesAction.php @@ -0,0 +1,38 @@ +urlResolver->resolveShortUrl( + ShortUrlIdentifier::fromApiRequest($request), + AuthenticationMiddleware::apiKeyFromRequest($request), + ); + $rules = $this->ruleService->rulesForShortUrl($shortUrl); + + return new JsonResponse([ + 'defaultLongUrl' => $shortUrl->getLongUrl(), + 'redirectRules' => $rules, + ]); + } +} diff --git a/module/Rest/src/Action/RedirectRule/SetRedirectRulesAction.php b/module/Rest/src/Action/RedirectRule/SetRedirectRulesAction.php new file mode 100644 index 00000000..913a833d --- /dev/null +++ b/module/Rest/src/Action/RedirectRule/SetRedirectRulesAction.php @@ -0,0 +1,43 @@ +urlResolver->resolveShortUrl( + ShortUrlIdentifier::fromApiRequest($request), + AuthenticationMiddleware::apiKeyFromRequest($request), + ); + $data = RedirectRulesData::fromRawData((array) $request->getParsedBody()); + + $result = $this->ruleService->setRulesForShortUrl($shortUrl, $data); + + return new JsonResponse([ + 'defaultLongUrl' => $shortUrl->getLongUrl(), + 'redirectRules' => $result, + ]); + } +} diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index 9674d5bc..13898584 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -8,14 +8,11 @@ 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\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagsParams; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; -use function array_map; - class ListTagsAction extends AbstractRestAction { use PagerfantaUtilsTrait; @@ -32,17 +29,8 @@ class ListTagsAction extends AbstractRestAction $params = TagsParams::fromRawData($request->getQueryParams()); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - if (! $params->withStats) { - return new JsonResponse([ - 'tags' => $this->serializePaginator($this->tagService->listTags($params, $apiKey)), - ]); - } - - // This part is deprecated. To get tags with stats, the /tags/stats endpoint should be used instead - $tagsInfo = $this->tagService->tagsInfo($params, $apiKey); - $rawTags = $this->serializePaginator($tagsInfo, dataProp: 'stats'); - $rawTags['data'] = array_map(static fn (TagInfo $info) => $info->tag, [...$tagsInfo]); - - return new JsonResponse(['tags' => $rawTags]); + return new JsonResponse([ + 'tags' => $this->serializePaginator($this->tagService->listTags($params, $apiKey)), + ]); } } diff --git a/module/Rest/src/Action/Visit/OrphanVisitsAction.php b/module/Rest/src/Action/Visit/OrphanVisitsAction.php index c7adf3a1..57244197 100644 --- a/module/Rest/src/Action/Visit/OrphanVisitsAction.php +++ b/module/Rest/src/Action/Visit/OrphanVisitsAction.php @@ -9,7 +9,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; -use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; @@ -29,7 +29,7 @@ class OrphanVisitsAction extends AbstractRestAction public function handle(ServerRequestInterface $request): ResponseInterface { - $params = VisitsParams::fromRawData($request->getQueryParams()); + $params = OrphanVisitsParams::fromRawData($request->getQueryParams()); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); $visits = $this->visitsHelper->orphanVisits($params, $apiKey); diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php index dd2d8ae7..4f3685db 100644 --- a/module/Rest/src/ApiKey/Role.php +++ b/module/Rest/src/ApiKey/Role.php @@ -40,8 +40,8 @@ enum Role: string public static function toSpec(ApiKeyRole $role, ?string $context = null): Specification { - return match ($role->role()) { - self::AUTHORED_SHORT_URLS => new BelongsToApiKey($role->apiKey(), $context), + return match ($role->role) { + self::AUTHORED_SHORT_URLS => new BelongsToApiKey($role->apiKey, $context), self::DOMAIN_SPECIFIC => new BelongsToDomain(self::domainIdFromMeta($role->meta()), $context), default => Spec::andX(), }; @@ -49,8 +49,8 @@ enum Role: string public static function toInlinedSpec(ApiKeyRole $role): Specification { - return match ($role->role()) { - self::AUTHORED_SHORT_URLS => Spec::andX(new BelongsToApiKeyInlined($role->apiKey())), + return match ($role->role) { + self::AUTHORED_SHORT_URLS => Spec::andX(new BelongsToApiKeyInlined($role->apiKey)), self::DOMAIN_SPECIFIC => Spec::andX(new BelongsToDomainInlined(self::domainIdFromMeta($role->meta()))), default => Spec::andX(), }; diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index dae30de0..9ad3fcf4 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -156,7 +156,7 @@ class ApiKey extends AbstractEntity */ public function mapRoles(callable $fun): array { - return $this->roles->map(fn (ApiKeyRole $role) => $fun($role->role(), $role->meta()))->getValues(); + return $this->roles->map(fn (ApiKeyRole $role) => $fun($role->role, $role->meta()))->getValues(); } public function registerRole(RoleDefinition $roleDefinition): void diff --git a/module/Rest/src/Entity/ApiKeyRole.php b/module/Rest/src/Entity/ApiKeyRole.php index 6fadb839..5053b74d 100644 --- a/module/Rest/src/Entity/ApiKeyRole.php +++ b/module/Rest/src/Entity/ApiKeyRole.php @@ -13,22 +13,6 @@ class ApiKeyRole extends AbstractEntity { } - /** - * @deprecated Use property access directly - */ - public function role(): Role - { - return $this->role; - } - - /** - * @deprecated Use property access directly - */ - public function apiKey(): ApiKey - { - return $this->apiKey; - } - public function meta(): array { return $this->meta; diff --git a/module/Rest/src/Exception/BackwardsCompatibleProblemDetailsException.php b/module/Rest/src/Exception/BackwardsCompatibleProblemDetailsException.php deleted file mode 100644 index 8cfb918c..00000000 --- a/module/Rest/src/Exception/BackwardsCompatibleProblemDetailsException.php +++ /dev/null @@ -1,99 +0,0 @@ -getMessage(), $e->getCode(), $e); - } - - public static function fromProblemDetails(ProblemDetailsExceptionInterface $e): self - { - return new self($e); - } - - public function getStatus(): int - { - return $this->e->getStatus(); - } - - public function getType(): string - { - return $this->remapType($this->e->getType()); - } - - public function getTitle(): string - { - return $this->e->getTitle(); - } - - public function getDetail(): string - { - return $this->e->getDetail(); - } - - public function getAdditionalData(): array - { - return $this->e->getAdditionalData(); - } - - public function toArray(): array - { - return $this->remapTypeInArray($this->e->toArray()); - } - - public function jsonSerialize(): array - { - return $this->remapTypeInArray($this->e->jsonSerialize()); - } - - private function remapTypeInArray(array $wrappedArray): array - { - if (! isset($wrappedArray['type'])) { - return $wrappedArray; - } - - return [...$wrappedArray, 'type' => $this->remapType($wrappedArray['type'])]; - } - - private function remapType(string $wrappedType): string - { - $segments = explode('/', $wrappedType); - $lastSegment = end($segments); - - return match ($lastSegment) { - ValidationException::ERROR_CODE => 'INVALID_ARGUMENT', - DeleteShortUrlException::ERROR_CODE => 'INVALID_SHORT_URL_DELETION', - DomainNotFoundException::ERROR_CODE => 'DOMAIN_NOT_FOUND', - ForbiddenTagOperationException::ERROR_CODE => 'FORBIDDEN_OPERATION', - InvalidUrlException::ERROR_CODE => 'INVALID_URL', - NonUniqueSlugException::ERROR_CODE => 'INVALID_SLUG', - ShortUrlNotFoundException::ERROR_CODE => 'INVALID_SHORTCODE', - TagConflictException::ERROR_CODE => 'TAG_CONFLICT', - TagNotFoundException::ERROR_CODE => 'TAG_NOT_FOUND', - MercureException::ERROR_CODE => 'MERCURE_NOT_CONFIGURED', - MissingAuthenticationException::ERROR_CODE => 'INVALID_AUTHORIZATION', - VerifyAuthenticationException::ERROR_CODE => 'INVALID_API_KEY', - default => $wrappedType, - }; - } -} diff --git a/module/Rest/src/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandler.php b/module/Rest/src/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandler.php deleted file mode 100644 index c099ad70..00000000 --- a/module/Rest/src/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandler.php +++ /dev/null @@ -1,30 +0,0 @@ -handle($request); - } catch (ProblemDetailsExceptionInterface $e) { - $version = $request->getAttribute('version') ?? '2'; - throw version_compare($version, '3', '>=') - ? $e - : BackwardsCompatibleProblemDetailsException::fromProblemDetails($e); - } - } -} diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 01592129..42742bbb 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -8,6 +8,7 @@ use Cake\Chronos\Chronos; use GuzzleHttp\RequestOptions; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestWith; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use function array_map; @@ -19,7 +20,7 @@ class CreateShortUrlTest extends ApiTestCase #[Test] public function createsNewShortUrlWhenOnlyLongUrlIsProvided(): void { - $expectedKeys = ['shortCode', 'shortUrl', 'longUrl', 'dateCreated', 'visitsCount', 'tags']; + $expectedKeys = ['shortCode', 'shortUrl', 'longUrl', 'dateCreated', 'tags']; [$statusCode, $payload] = $this->createShortUrl(); self::assertEquals(self::STATUS_OK, $statusCode); @@ -48,7 +49,7 @@ class CreateShortUrlTest extends ApiTestCase self::assertEquals(self::STATUS_BAD_REQUEST, $statusCode); self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); self::assertEquals($detail, $payload['detail']); - self::assertEquals('INVALID_SLUG', $payload['type']); + self::assertEquals('https://shlink.io/api/error/non-unique-slug', $payload['type']); self::assertEquals('Invalid custom slug', $payload['title']); self::assertEquals($slug, $payload['customSlug']); @@ -70,8 +71,8 @@ class CreateShortUrlTest extends ApiTestCase public static function provideDuplicatedSlugApiVersions(): iterable { - yield ['1', 'INVALID_SLUG']; - yield ['2', 'INVALID_SLUG']; + yield ['1', 'https://shlink.io/api/error/non-unique-slug']; + yield ['2', 'https://shlink.io/api/error/non-unique-slug']; yield ['3', 'https://shlink.io/api/error/non-unique-slug']; } @@ -224,27 +225,6 @@ class CreateShortUrlTest extends ApiTestCase yield ['http://téstb.shlink.io']; // Redirects to http://tést.shlink.io } - #[Test, DataProvider('provideInvalidUrls')] - public function failsToCreateShortUrlWithInvalidLongUrl(string $url, string $version, string $expectedType): void - { - $expectedDetail = sprintf('Provided URL %s is invalid. Try with a different one.', $url); - - [$statusCode, $payload] = $this->createShortUrl(['longUrl' => $url, 'validateUrl' => true], version: $version); - - self::assertEquals(self::STATUS_BAD_REQUEST, $statusCode); - self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); - self::assertEquals($expectedType, $payload['type']); - self::assertEquals($expectedDetail, $payload['detail']); - self::assertEquals('Invalid URL', $payload['title']); - self::assertEquals($url, $payload['url']); - } - - public static function provideInvalidUrls(): iterable - { - yield 'API version 2' => ['https://this-has-to-be-invalid.com', '2', 'INVALID_URL']; - yield 'API version 3' => ['https://this-has-to-be-invalid.com', '3', 'https://shlink.io/api/error/invalid-url']; - } - #[Test, DataProvider('provideInvalidArgumentApiVersions')] public function failsToCreateShortUrlWithoutLongUrl(array $payload, string $version, string $expectedType): void { @@ -264,24 +244,12 @@ class CreateShortUrlTest extends ApiTestCase public static function provideInvalidArgumentApiVersions(): iterable { - yield 'missing long url v2' => [[], '2', 'INVALID_ARGUMENT']; + yield 'missing long url v2' => [[], '2', 'https://shlink.io/api/error/invalid-data']; yield 'missing long url v3' => [[], '3', 'https://shlink.io/api/error/invalid-data']; - yield 'empty long url v2' => [['longUrl' => null], '2', 'INVALID_ARGUMENT']; + yield 'empty long url v2' => [['longUrl' => null], '2', 'https://shlink.io/api/error/invalid-data']; yield 'empty long url v3' => [['longUrl' => ' '], '3', 'https://shlink.io/api/error/invalid-data']; - yield 'missing url schema v2' => [['longUrl' => 'foo.com'], '2', 'INVALID_ARGUMENT']; + yield 'missing url schema v2' => [['longUrl' => 'foo.com'], '2', 'https://shlink.io/api/error/invalid-data']; yield 'missing url schema v3' => [['longUrl' => 'foo.com'], '3', 'https://shlink.io/api/error/invalid-data']; - yield 'empty device long url v2' => [[ - 'longUrl' => 'foo', - 'deviceLongUrls' => [ - 'android' => null, - ], - ], '2', 'INVALID_ARGUMENT']; - yield 'empty device long url v3' => [[ - 'longUrl' => 'foo', - 'deviceLongUrls' => [ - 'ios' => ' ', - ], - ], '3', 'https://shlink.io/api/error/invalid-data']; } #[Test] @@ -334,19 +302,29 @@ class CreateShortUrlTest extends ApiTestCase } #[Test] - public function canCreateShortUrlsWithDeviceLongUrls(): void + public function titleIsIgnoredIfLongUrlTimesOut(): void { [$statusCode, $payload] = $this->createShortUrl([ - 'longUrl' => 'https://github.com/shlinkio/shlink/issues/1557', - 'deviceLongUrls' => [ - 'ios' => 'https://github.com/shlinkio/shlink/ios', - 'android' => 'https://github.com/shlinkio/shlink/android', - ], + 'longUrl' => 'http://127.0.0.1:9999/api-tests/long-url-with-timeout', ]); self::assertEquals(self::STATUS_OK, $statusCode); - self::assertEquals('https://github.com/shlinkio/shlink/ios', $payload['deviceLongUrls']['ios'] ?? null); - self::assertEquals('https://github.com/shlinkio/shlink/android', $payload['deviceLongUrls']['android'] ?? null); + self::assertNull($payload['title']); + } + + #[Test] + #[TestWith([null])] + #[TestWith(['my-custom-slug'])] + public function prefixCanBeSet(?string $customSlug): void + { + [$statusCode, $payload] = $this->createShortUrl([ + 'longUrl' => 'https://github.com/shlinkio/shlink/issues/1557', + 'pathPrefix' => 'foo/b ar-baz', + 'customSlug' => $customSlug, + ]); + + self::assertEquals(self::STATUS_OK, $statusCode); + self::assertStringStartsWith('foo-b--ar-baz', $payload['shortCode']); } /** diff --git a/module/Rest/test-api/Action/DeleteShortUrlTest.php b/module/Rest/test-api/Action/DeleteShortUrlTest.php index 7bd3dfea..06848c48 100644 --- a/module/Rest/test-api/Action/DeleteShortUrlTest.php +++ b/module/Rest/test-api/Action/DeleteShortUrlTest.php @@ -31,7 +31,7 @@ class DeleteShortUrlTest extends ApiTestCase self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - self::assertEquals('INVALID_SHORTCODE', $payload['type']); + self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Short URL not found', $payload['title']); self::assertEquals($shortCode, $payload['shortCode']); @@ -52,8 +52,8 @@ class DeleteShortUrlTest extends ApiTestCase public static function provideApiVersions(): iterable { - yield ['1', 'INVALID_SHORTCODE']; - yield ['2', 'INVALID_SHORTCODE']; + yield ['1', 'https://shlink.io/api/error/short-url-not-found']; + yield ['2', 'https://shlink.io/api/error/short-url-not-found']; yield ['3', 'https://shlink.io/api/error/short-url-not-found']; } diff --git a/module/Rest/test-api/Action/DeleteTagsTest.php b/module/Rest/test-api/Action/DeleteTagsTest.php index b04fbaf5..a269d2db 100644 --- a/module/Rest/test-api/Action/DeleteTagsTest.php +++ b/module/Rest/test-api/Action/DeleteTagsTest.php @@ -30,8 +30,8 @@ class DeleteTagsTest extends ApiTestCase public static function provideNonAdminApiKeys(): iterable { - yield 'author' => ['author_api_key', '2', 'FORBIDDEN_OPERATION']; - yield 'domain' => ['domain_api_key', '2', 'FORBIDDEN_OPERATION']; + yield 'author' => ['author_api_key', '2', 'https://shlink.io/api/error/forbidden-tag-operation']; + yield 'domain' => ['domain_api_key', '2', 'https://shlink.io/api/error/forbidden-tag-operation']; yield 'version 3' => ['domain_api_key', '3', 'https://shlink.io/api/error/forbidden-tag-operation']; } } diff --git a/module/Rest/test-api/Action/DomainRedirectsTest.php b/module/Rest/test-api/Action/DomainRedirectsTest.php index bc78d035..d97092d6 100644 --- a/module/Rest/test-api/Action/DomainRedirectsTest.php +++ b/module/Rest/test-api/Action/DomainRedirectsTest.php @@ -21,7 +21,7 @@ class DomainRedirectsTest extends ApiTestCase self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); - self::assertEquals('INVALID_ARGUMENT', $payload['type']); + self::assertEquals('https://shlink.io/api/error/invalid-data', $payload['type']); self::assertEquals('Provided data is not valid', $payload['detail']); self::assertEquals('Invalid data', $payload['title']); } diff --git a/module/Rest/test-api/Action/DomainVisitsTest.php b/module/Rest/test-api/Action/DomainVisitsTest.php index 2c1d1d2e..3a06257b 100644 --- a/module/Rest/test-api/Action/DomainVisitsTest.php +++ b/module/Rest/test-api/Action/DomainVisitsTest.php @@ -49,7 +49,7 @@ class DomainVisitsTest extends ApiTestCase self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - self::assertEquals('DOMAIN_NOT_FOUND', $payload['type']); + self::assertEquals('https://shlink.io/api/error/domain-not-found', $payload['type']); self::assertEquals(sprintf('Domain with authority "%s" could not be found', $domain), $payload['detail']); self::assertEquals('Domain not found', $payload['title']); self::assertEquals($domain, $payload['authority']); @@ -73,8 +73,8 @@ class DomainVisitsTest extends ApiTestCase public static function provideApiVersions(): iterable { - yield ['1', 'DOMAIN_NOT_FOUND']; - yield ['2', 'DOMAIN_NOT_FOUND']; + yield ['1', 'https://shlink.io/api/error/domain-not-found']; + yield ['2', 'https://shlink.io/api/error/domain-not-found']; yield ['3', 'https://shlink.io/api/error/domain-not-found']; } } diff --git a/module/Rest/test-api/Action/EditShortUrlTest.php b/module/Rest/test-api/Action/EditShortUrlTest.php index a55fb066..24f91e58 100644 --- a/module/Rest/test-api/Action/EditShortUrlTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTest.php @@ -75,28 +75,16 @@ class EditShortUrlTest extends ApiTestCase return $matchingShortUrl['meta'] ?? []; } - #[Test, DataProvider('provideLongUrls')] - public function longUrlCanBeEditedIfItIsValid(string $longUrl, int $expectedStatus, ?string $expectedError): void + public function longUrlCanBeEdited(): void { $shortCode = 'abc123'; $url = sprintf('/short-urls/%s', $shortCode); $resp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => [ - 'longUrl' => $longUrl, - 'validateUrl' => true, + 'longUrl' => 'https://shlink.io', ]]); - self::assertEquals($expectedStatus, $resp->getStatusCode()); - if ($expectedError !== null) { - $payload = $this->getJsonResponsePayload($resp); - self::assertEquals($expectedError, $payload['type']); - } - } - - public static function provideLongUrls(): iterable - { - yield 'valid URL' => ['https://shlink.io', self::STATUS_OK, null]; - yield 'invalid URL' => ['http://foo', self::STATUS_BAD_REQUEST, 'INVALID_URL']; + self::assertEquals(self::STATUS_OK, $resp->getStatusCode()); } #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] @@ -112,7 +100,7 @@ class EditShortUrlTest extends ApiTestCase self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - self::assertEquals('INVALID_SHORTCODE', $payload['type']); + self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Short URL not found', $payload['title']); self::assertEquals($shortCode, $payload['shortCode']); @@ -131,7 +119,7 @@ class EditShortUrlTest extends ApiTestCase self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); - self::assertEquals('INVALID_ARGUMENT', $payload['type']); + self::assertEquals('https://shlink.io/api/error/invalid-data', $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Invalid data', $payload['title']); } @@ -165,27 +153,4 @@ class EditShortUrlTest extends ApiTestCase ]; yield 'no domain' => [null, 'https://shlink.io/documentation/']; } - - #[Test] - public function deviceLongUrlsCanBeEdited(): void - { - $shortCode = 'def456'; - $url = new Uri(sprintf('/short-urls/%s', $shortCode)); - $editResp = $this->callApiWithKey(self::METHOD_PATCH, (string) $url, [RequestOptions::JSON => [ - 'deviceLongUrls' => [ - 'android' => null, // This one will get removed - 'ios' => 'https://blog.alejandrocelaya.com/ios/edited', // This one will be edited - 'desktop' => 'https://blog.alejandrocelaya.com/desktop', // This one is new and will be created - ], - ]]); - $deviceLongUrls = $this->getJsonResponsePayload($editResp)['deviceLongUrls'] ?? []; - - self::assertEquals(self::STATUS_OK, $editResp->getStatusCode()); - self::assertArrayHasKey('ios', $deviceLongUrls); - self::assertEquals('https://blog.alejandrocelaya.com/ios/edited', $deviceLongUrls['ios']); - self::assertArrayHasKey('desktop', $deviceLongUrls); - self::assertEquals('https://blog.alejandrocelaya.com/desktop', $deviceLongUrls['desktop']); - self::assertArrayHasKey('android', $deviceLongUrls); - self::assertNull($deviceLongUrls['android']); - } } diff --git a/module/Rest/test-api/Action/GlobalVisitsTest.php b/module/Rest/test-api/Action/GlobalVisitsTest.php index 657f16a6..30c880d5 100644 --- a/module/Rest/test-api/Action/GlobalVisitsTest.php +++ b/module/Rest/test-api/Action/GlobalVisitsTest.php @@ -17,10 +17,8 @@ class GlobalVisitsTest extends ApiTestCase $payload = $this->getJsonResponsePayload($resp); self::assertArrayHasKey('visits', $payload); - self::assertArrayHasKey('visitsCount', $payload['visits']); - self::assertArrayHasKey('orphanVisitsCount', $payload['visits']); - self::assertEquals($expectedVisits, $payload['visits']['visitsCount']); - self::assertEquals($expectedOrphanVisits, $payload['visits']['orphanVisitsCount']); + self::assertEquals($expectedVisits, $payload['visits']['nonOrphanVisits']['total']); + self::assertEquals($expectedOrphanVisits, $payload['visits']['orphanVisits']['total']); } public static function provideApiKeys(): iterable diff --git a/module/Rest/test-api/Action/ListRedirectRulesTest.php b/module/Rest/test-api/Action/ListRedirectRulesTest.php new file mode 100644 index 00000000..c53986c1 --- /dev/null +++ b/module/Rest/test-api/Action/ListRedirectRulesTest.php @@ -0,0 +1,99 @@ + 'language', + 'matchKey' => null, + 'matchValue' => 'en', + ]; + private const QUERY_FOO_BAR_CONDITION = [ + 'type' => 'query-param', + 'matchKey' => 'foo', + 'matchValue' => 'bar', + ]; + + #[Test] + public function errorIsReturnedWhenInvalidUrlIsFetched(): void + { + $response = $this->callApiWithKey(self::METHOD_GET, '/short-urls/invalid/redirect-rules'); + $payload = $this->getJsonResponsePayload($response); + + self::assertEquals(404, $response->getStatusCode()); + self::assertEquals(404, $payload['status']); + self::assertEquals('invalid', $payload['shortCode']); + self::assertEquals('No URL found with short code "invalid"', $payload['detail']); + self::assertEquals('Short URL not found', $payload['title']); + self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']); + } + + #[Test] + #[TestWith(['abc123', []])] + #[TestWith(['def456', [ + [ + 'longUrl' => 'https://example.com/english-and-foo-query', + 'priority' => 1, + 'conditions' => [ + self::LANGUAGE_EN_CONDITION, + self::QUERY_FOO_BAR_CONDITION, + ], + ], + [ + 'longUrl' => 'https://example.com/multiple-query-params', + 'priority' => 2, + 'conditions' => [ + [ + 'type' => 'query-param', + 'matchKey' => 'hello', + 'matchValue' => 'world', + ], + self::QUERY_FOO_BAR_CONDITION, + ], + ], + [ + 'longUrl' => 'https://example.com/only-english', + 'priority' => 3, + 'conditions' => [self::LANGUAGE_EN_CONDITION], + ], + [ + 'longUrl' => 'https://blog.alejandrocelaya.com/android', + 'priority' => 4, + 'conditions' => [ + [ + 'type' => 'device', + 'matchKey' => null, + 'matchValue' => 'android', + ], + ], + ], + [ + 'longUrl' => 'https://blog.alejandrocelaya.com/ios', + 'priority' => 5, + 'conditions' => [ + [ + 'type' => 'device', + 'matchKey' => null, + 'matchValue' => 'ios', + ], + ], + ], + ]])] + public function returnsListOfRulesForShortUrl(string $shortCode, array $expectedRules): void + { + $response = $this->callApiWithKey(self::METHOD_GET, sprintf('/short-urls/%s/redirect-rules', $shortCode)); + $payload = $this->getJsonResponsePayload($response); + + self::assertEquals(200, $response->getStatusCode()); + self::assertEquals($expectedRules, $payload['redirectRules']); + } +} diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index bb6296f7..c3b9b41e 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -8,7 +8,6 @@ use Cake\Chronos\Chronos; use GuzzleHttp\RequestOptions; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use function count; @@ -20,7 +19,6 @@ class ListShortUrlsTest extends ApiTestCase 'shortUrl' => 'http://s.test/abc123', 'longUrl' => 'https://shlink.io', 'dateCreated' => '2018-05-01T00:00:00+00:00', - 'visitsCount' => 3, 'visitsSummary' => [ 'total' => 3, 'nonBots' => 3, @@ -42,7 +40,6 @@ class ListShortUrlsTest extends ApiTestCase 'shortUrl' => 'http://s.test/ghi789', 'longUrl' => 'https://shlink.io/documentation/', 'dateCreated' => '2018-05-01T00:00:00+00:00', - 'visitsCount' => 2, 'visitsSummary' => [ 'total' => 2, 'nonBots' => 2, @@ -64,7 +61,6 @@ class ListShortUrlsTest extends ApiTestCase 'shortUrl' => 'http://some-domain.com/custom-with-domain', 'longUrl' => 'https://google.com', 'dateCreated' => '2018-10-20T00:00:00+00:00', - 'visitsCount' => 0, 'visitsSummary' => [ 'total' => 0, 'nonBots' => 0, @@ -88,7 +84,6 @@ class ListShortUrlsTest extends ApiTestCase 'https://blog.alejandrocelaya.com/2017/12/09' . '/acmailer-7-0-the-most-important-release-in-a-long-time/', 'dateCreated' => '2019-01-01T00:00:10+00:00', - 'visitsCount' => 2, 'visitsSummary' => [ 'total' => 2, 'nonBots' => 1, @@ -110,7 +105,6 @@ class ListShortUrlsTest extends ApiTestCase 'shortUrl' => 'http://s.test/custom', 'longUrl' => 'https://shlink.io', 'dateCreated' => '2019-01-01T00:00:20+00:00', - 'visitsCount' => 0, 'visitsSummary' => [ 'total' => 0, 'nonBots' => 0, @@ -134,7 +128,6 @@ class ListShortUrlsTest extends ApiTestCase 'https://blog.alejandrocelaya.com/2019/04/27' . '/considerations-to-properly-use-open-source-software-projects/', 'dateCreated' => '2019-01-01T00:00:30+00:00', - 'visitsCount' => 0, 'visitsSummary' => [ 'total' => 0, 'nonBots' => 0, @@ -169,123 +162,109 @@ class ListShortUrlsTest extends ApiTestCase public static function provideFilteredLists(): iterable { - $withDeviceLongUrls = static fn (array $shortUrl, ?array $longUrls = null) => [ - ...$shortUrl, - 'deviceLongUrls' => $longUrls ?? [ - DeviceType::ANDROID->value => null, - DeviceType::IOS->value => null, - DeviceType::DESKTOP->value => null, - ], - ]; - $shortUrlMeta = $withDeviceLongUrls(self::SHORT_URL_META, [ - DeviceType::ANDROID->value => 'https://blog.alejandrocelaya.com/android', - DeviceType::IOS->value => 'https://blog.alejandrocelaya.com/ios', - DeviceType::DESKTOP->value => null, - ]); - yield [[], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), - $withDeviceLongUrls(self::SHORT_URL_DOCS), + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, + self::SHORT_URL_SHLINK_WITH_TITLE, + self::SHORT_URL_DOCS, ], 'valid_api_key']; yield [['excludePastValidUntil' => 'true'], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['excludeMaxVisitsReached' => 'true'], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_DOCS), + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, + self::SHORT_URL_DOCS, ], 'valid_api_key']; yield [['orderBy' => 'shortCode'], [ - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_DOCS), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + self::SHORT_URL_SHLINK_WITH_TITLE, + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, + self::SHORT_URL_META, + self::SHORT_URL_DOCS, + self::SHORT_URL_CUSTOM_DOMAIN, ], 'valid_api_key']; yield [['orderBy' => 'shortCode-DESC'], [ - $withDeviceLongUrls(self::SHORT_URL_DOCS), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + self::SHORT_URL_DOCS, + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['orderBy' => 'title-DESC'], [ - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), - $withDeviceLongUrls(self::SHORT_URL_DOCS), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_DOCS, + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['startDate' => Chronos::parse('2018-12-01')->toAtomString()], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), - $shortUrlMeta, + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_META, ], 'valid_api_key']; yield [['endDate' => Chronos::parse('2018-12-01')->toAtomString()], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), - $withDeviceLongUrls(self::SHORT_URL_DOCS), + self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, + self::SHORT_URL_SHLINK_WITH_TITLE, + self::SHORT_URL_DOCS, ], 'valid_api_key']; yield [['tags' => ['foo']], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_META, + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['tags' => ['bar']], [ - $shortUrlMeta, + self::SHORT_URL_META, ], 'valid_api_key']; yield [['tags' => ['foo', 'bar']], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_META, + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'any'], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_META, + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'all'], [ - $shortUrlMeta, + self::SHORT_URL_META, ], 'valid_api_key']; yield [['tags' => ['foo', 'bar', 'baz']], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_META, + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['tags' => ['foo', 'bar', 'baz'], 'tagsMode' => 'all'], [], 'valid_api_key']; yield [['tags' => ['foo'], 'endDate' => Chronos::parse('2018-12-01')->toAtomString()], [ - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['searchTerm' => 'alejandro'], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $shortUrlMeta, + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_META, ], 'valid_api_key']; yield [['searchTerm' => 'cool'], [ - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['searchTerm' => 'example.com'], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + self::SHORT_URL_CUSTOM_DOMAIN, ], 'valid_api_key']; yield [[], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_META, + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'author_api_key']; yield [[], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + self::SHORT_URL_CUSTOM_DOMAIN, ], 'domain_api_key']; } @@ -310,7 +289,7 @@ class ListShortUrlsTest extends ApiTestCase self::assertEquals([ 'invalidElements' => $expectedInvalidElements, 'title' => 'Invalid data', - 'type' => 'INVALID_ARGUMENT', + 'type' => 'https://shlink.io/api/error/invalid-data', 'status' => 400, 'detail' => 'Provided data is not valid', ], $respPayload); diff --git a/module/Rest/test-api/Action/OrphanVisitsTest.php b/module/Rest/test-api/Action/OrphanVisitsTest.php index 2c8b2479..cf7cee0f 100644 --- a/module/Rest/test-api/Action/OrphanVisitsTest.php +++ b/module/Rest/test-api/Action/OrphanVisitsTest.php @@ -8,6 +8,7 @@ use GuzzleHttp\RequestOptions; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\Common\Paginator\Paginator; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class OrphanVisitsTest extends ApiTestCase @@ -68,6 +69,23 @@ class OrphanVisitsTest extends ApiTestCase 1, [self::REGULAR_NOT_FOUND], ]; + yield 'base_url only' => [['type' => OrphanVisitType::BASE_URL->value], 1, 1, [self::BASE_URL]]; + yield 'regular_404 only' => [['type' => OrphanVisitType::REGULAR_404->value], 1, 1, [self::REGULAR_NOT_FOUND]]; + yield 'invalid_short_url only' => [ + ['type' => OrphanVisitType::INVALID_SHORT_URL->value], + 1, + 1, + [self::INVALID_SHORT_URL], + ]; + } + + #[Test] + public function errorIsReturnedForInvalidType(): void + { + $resp = $this->callApiWithKey(self::METHOD_GET, '/visits/orphan', [ + RequestOptions::QUERY => ['type' => 'invalid'], + ]); + self::assertEquals(400, $resp->getStatusCode()); } #[Test] diff --git a/module/Rest/test-api/Action/RenameTagTest.php b/module/Rest/test-api/Action/RenameTagTest.php index e401da1d..35a3e1b2 100644 --- a/module/Rest/test-api/Action/RenameTagTest.php +++ b/module/Rest/test-api/Action/RenameTagTest.php @@ -24,7 +24,7 @@ class RenameTagTest extends ApiTestCase self::assertEquals(self::STATUS_FORBIDDEN, $resp->getStatusCode()); self::assertEquals(self::STATUS_FORBIDDEN, $payload['status']); - self::assertEquals('FORBIDDEN_OPERATION', $payload['type']); + self::assertEquals('https://shlink.io/api/error/forbidden-tag-operation', $payload['type']); self::assertEquals('You are not allowed to rename tags', $payload['detail']); self::assertEquals('Forbidden tag operation', $payload['title']); } diff --git a/module/Rest/test-api/Action/ResolveShortUrlTest.php b/module/Rest/test-api/Action/ResolveShortUrlTest.php index c10abc74..0c0ce5ec 100644 --- a/module/Rest/test-api/Action/ResolveShortUrlTest.php +++ b/module/Rest/test-api/Action/ResolveShortUrlTest.php @@ -58,7 +58,7 @@ class ResolveShortUrlTest extends ApiTestCase self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - self::assertEquals('INVALID_SHORTCODE', $payload['type']); + self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Short URL not found', $payload['title']); self::assertEquals($shortCode, $payload['shortCode']); diff --git a/module/Rest/test-api/Action/SetRedirectRulesTest.php b/module/Rest/test-api/Action/SetRedirectRulesTest.php new file mode 100644 index 00000000..a1172d65 --- /dev/null +++ b/module/Rest/test-api/Action/SetRedirectRulesTest.php @@ -0,0 +1,98 @@ + 'language', + 'matchKey' => null, + 'matchValue' => 'en', + ]; + private const QUERY_FOO_BAR_CONDITION = [ + 'type' => 'query-param', + 'matchKey' => 'foo', + 'matchValue' => 'bar', + ]; + + #[Test] + public function errorIsReturnedWhenInvalidUrlProvided(): void + { + $response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/invalid/redirect-rules'); + $payload = $this->getJsonResponsePayload($response); + + self::assertEquals(404, $response->getStatusCode()); + self::assertEquals(404, $payload['status']); + self::assertEquals('invalid', $payload['shortCode']); + self::assertEquals('No URL found with short code "invalid"', $payload['detail']); + self::assertEquals('Short URL not found', $payload['title']); + self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']); + } + + #[Test] + public function errorIsReturnedWhenInvalidDataProvided(): void + { + $response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/abc123/redirect-rules', [ + RequestOptions::JSON => [ + 'redirectRules' => [ + [ + 'longUrl' => 'invalid', + ], + ], + ], + ]); + $payload = $this->getJsonResponsePayload($response); + + self::assertEquals(400, $response->getStatusCode()); + self::assertEquals(400, $payload['status']); + self::assertEquals('Provided data is not valid', $payload['detail']); + self::assertEquals('Invalid data', $payload['title']); + self::assertEquals('https://shlink.io/api/error/invalid-data', $payload['type']); + } + + #[Test] + #[TestWith(['def456', []])] + #[TestWith(['abc123', [ + [ + 'longUrl' => 'https://example.com/english-and-foo-query', + 'priority' => 1, + 'conditions' => [ + self::LANGUAGE_EN_CONDITION, + self::QUERY_FOO_BAR_CONDITION, + ], + ], + [ + 'longUrl' => 'https://example.com/multiple-query-params', + 'priority' => 2, + 'conditions' => [ + [ + 'type' => 'query-param', + 'matchKey' => 'hello', + 'matchValue' => 'world', + ], + self::QUERY_FOO_BAR_CONDITION, + ], + ], + ]])] + public function setsListOfRulesForShortUrl(string $shortCode, array $expectedRules): void + { + $response = $this->callApiWithKey(self::METHOD_POST, sprintf('/short-urls/%s/redirect-rules', $shortCode), [ + RequestOptions::JSON => [ + 'redirectRules' => $expectedRules, + ], + ]); + $payload = $this->getJsonResponsePayload($response); + + self::assertEquals(200, $response->getStatusCode()); + self::assertEquals($expectedRules, $payload['redirectRules']); + } +} diff --git a/module/Rest/test-api/Action/ShortUrlVisitsTest.php b/module/Rest/test-api/Action/ShortUrlVisitsTest.php index 6a7e6a7e..8db002c4 100644 --- a/module/Rest/test-api/Action/ShortUrlVisitsTest.php +++ b/module/Rest/test-api/Action/ShortUrlVisitsTest.php @@ -34,7 +34,7 @@ class ShortUrlVisitsTest extends ApiTestCase self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - self::assertEquals('INVALID_SHORTCODE', $payload['type']); + self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Short URL not found', $payload['title']); self::assertEquals($shortCode, $payload['shortCode']); diff --git a/module/Rest/test-api/Action/SingleStepCreateShortUrlTest.php b/module/Rest/test-api/Action/SingleStepCreateShortUrlTest.php index faed281d..038e3f38 100644 --- a/module/Rest/test-api/Action/SingleStepCreateShortUrlTest.php +++ b/module/Rest/test-api/Action/SingleStepCreateShortUrlTest.php @@ -38,7 +38,7 @@ class SingleStepCreateShortUrlTest extends ApiTestCase self::assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode()); self::assertEquals(self::STATUS_UNAUTHORIZED, $payload['status']); - self::assertEquals('INVALID_AUTHORIZATION', $payload['type']); + self::assertEquals('https://shlink.io/api/error/missing-authentication', $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Invalid authorization', $payload['title']); } diff --git a/module/Rest/test-api/Action/TagVisitsTest.php b/module/Rest/test-api/Action/TagVisitsTest.php index fc54c111..c51f02fb 100644 --- a/module/Rest/test-api/Action/TagVisitsTest.php +++ b/module/Rest/test-api/Action/TagVisitsTest.php @@ -53,7 +53,7 @@ class TagVisitsTest extends ApiTestCase self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - self::assertEquals('TAG_NOT_FOUND', $payload['type']); + self::assertEquals('https://shlink.io/api/error/tag-not-found', $payload['type']); self::assertEquals(sprintf('Tag with name "%s" could not be found', $tag), $payload['detail']); self::assertEquals('Tag not found', $payload['title']); } diff --git a/module/Rest/test-api/Action/TagsStatsTest.php b/module/Rest/test-api/Action/TagsStatsTest.php index 4ee94d42..9bf01474 100644 --- a/module/Rest/test-api/Action/TagsStatsTest.php +++ b/module/Rest/test-api/Action/TagsStatsTest.php @@ -25,29 +25,12 @@ class TagsStatsTest extends ApiTestCase self::assertEquals($expectedPagination, $tags['pagination']); } - #[Test, DataProvider('provideQueries')] - public function expectedListOfTagsIsReturnedForDeprecatedApproach( - string $apiKey, - array $query, - array $expectedStats, - array $expectedPagination, - ): void { - $query['withStats'] = 'true'; - $resp = $this->callApiWithKey(self::METHOD_GET, '/tags', [RequestOptions::QUERY => $query], $apiKey); - ['tags' => $tags] = $this->getJsonResponsePayload($resp); - - self::assertEquals($expectedStats, $tags['stats']); - self::assertEquals($expectedPagination, $tags['pagination']); - self::assertArrayHasKey('data', $tags); - } - public static function provideQueries(): iterable { yield 'admin API key' => ['valid_api_key', [], [ [ 'tag' => 'bar', 'shortUrlsCount' => 1, - 'visitsCount' => 2, 'visitsSummary' => [ 'total' => 2, 'nonBots' => 1, @@ -57,7 +40,6 @@ class TagsStatsTest extends ApiTestCase [ 'tag' => 'baz', 'shortUrlsCount' => 0, - 'visitsCount' => 0, 'visitsSummary' => [ 'total' => 0, 'nonBots' => 0, @@ -67,7 +49,6 @@ class TagsStatsTest extends ApiTestCase [ 'tag' => 'foo', 'shortUrlsCount' => 3, - 'visitsCount' => 5, 'visitsSummary' => [ 'total' => 5, 'nonBots' => 4, @@ -85,7 +66,6 @@ class TagsStatsTest extends ApiTestCase [ 'tag' => 'bar', 'shortUrlsCount' => 1, - 'visitsCount' => 2, 'visitsSummary' => [ 'total' => 2, 'nonBots' => 1, @@ -95,7 +75,6 @@ class TagsStatsTest extends ApiTestCase [ 'tag' => 'baz', 'shortUrlsCount' => 0, - 'visitsCount' => 0, 'visitsSummary' => [ 'total' => 0, 'nonBots' => 0, @@ -113,7 +92,6 @@ class TagsStatsTest extends ApiTestCase [ 'tag' => 'bar', 'shortUrlsCount' => 1, - 'visitsCount' => 2, 'visitsSummary' => [ 'total' => 2, 'nonBots' => 1, @@ -123,7 +101,6 @@ class TagsStatsTest extends ApiTestCase [ 'tag' => 'foo', 'shortUrlsCount' => 2, - 'visitsCount' => 5, 'visitsSummary' => [ 'total' => 5, 'nonBots' => 4, @@ -141,7 +118,6 @@ class TagsStatsTest extends ApiTestCase [ 'tag' => 'foo', 'shortUrlsCount' => 2, - 'visitsCount' => 5, 'visitsSummary' => [ 'total' => 5, 'nonBots' => 4, @@ -159,7 +135,6 @@ class TagsStatsTest extends ApiTestCase [ 'tag' => 'foo', 'shortUrlsCount' => 1, - 'visitsCount' => 0, 'visitsSummary' => [ 'total' => 0, 'nonBots' => 0, diff --git a/module/Rest/test-api/Action/UpdateTagTest.php b/module/Rest/test-api/Action/UpdateTagTest.php index 96b8ed62..3bced135 100644 --- a/module/Rest/test-api/Action/UpdateTagTest.php +++ b/module/Rest/test-api/Action/UpdateTagTest.php @@ -23,7 +23,7 @@ class UpdateTagTest extends ApiTestCase self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); - self::assertEquals('INVALID_ARGUMENT', $payload['type']); + self::assertEquals('https://shlink.io/api/error/invalid-data', $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Invalid data', $payload['title']); } @@ -55,8 +55,8 @@ class UpdateTagTest extends ApiTestCase public static function provideTagNotFoundApiVersions(): iterable { - yield 'version 1' => ['1', 'TAG_NOT_FOUND']; - yield 'version 2' => ['2', 'TAG_NOT_FOUND']; + yield 'version 1' => ['1', 'https://shlink.io/api/error/tag-not-found']; + yield 'version 2' => ['2', 'https://shlink.io/api/error/tag-not-found']; yield 'version 3' => ['3', 'https://shlink.io/api/error/tag-not-found']; } @@ -80,8 +80,8 @@ class UpdateTagTest extends ApiTestCase public static function provideTagConflictsApiVersions(): iterable { - yield 'version 1' => ['1', 'TAG_CONFLICT']; - yield 'version 2' => ['2', 'TAG_CONFLICT']; + yield 'version 1' => ['1', 'https://shlink.io/api/error/tag-conflict']; + yield 'version 2' => ['2', 'https://shlink.io/api/error/tag-conflict']; yield 'version 3' => ['3', 'https://shlink.io/api/error/tag-conflict']; } diff --git a/module/Rest/test-api/Action/VisitStatsTest.php b/module/Rest/test-api/Action/VisitStatsTest.php index 10a4de0c..2adf5a6a 100644 --- a/module/Rest/test-api/Action/VisitStatsTest.php +++ b/module/Rest/test-api/Action/VisitStatsTest.php @@ -32,8 +32,6 @@ class VisitStatsTest extends ApiTestCase 'nonBots' => 2, 'bots' => 1, ], - 'visitsCount' => 7, - 'orphanVisitsCount' => 3, ]]; yield 'domain-only API key' => ['domain_api_key', [ 'nonOrphanVisits' => [ @@ -46,8 +44,6 @@ class VisitStatsTest extends ApiTestCase 'nonBots' => 2, 'bots' => 1, ], - 'visitsCount' => 0, - 'orphanVisitsCount' => 3, ]]; yield 'author API key' => ['author_api_key', [ 'nonOrphanVisits' => [ @@ -60,8 +56,6 @@ class VisitStatsTest extends ApiTestCase 'nonBots' => 2, 'bots' => 1, ], - 'visitsCount' => 5, - 'orphanVisitsCount' => 3, ]]; } } diff --git a/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php b/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php new file mode 100644 index 00000000..267969f1 --- /dev/null +++ b/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php @@ -0,0 +1,75 @@ +getReference('def456_short_url'); + + // Create rules disordered to make sure the order by priority works + $multipleQueryParamsRule = new ShortUrlRedirectRule( + shortUrl: $defShortUrl, + priority: 2, + longUrl: 'https://example.com/multiple-query-params', + conditions: new ArrayCollection( + [RedirectCondition::forQueryParam('hello', 'world'), RedirectCondition::forQueryParam('foo', 'bar')], + ), + ); + $manager->persist($multipleQueryParamsRule); + + $englishAndFooQueryRule = new ShortUrlRedirectRule( + shortUrl: $defShortUrl, + priority: 1, + longUrl: 'https://example.com/english-and-foo-query', + conditions: new ArrayCollection( + [RedirectCondition::forLanguage('en'), RedirectCondition::forQueryParam('foo', 'bar')], + ), + ); + $manager->persist($englishAndFooQueryRule); + + $androidRule = new ShortUrlRedirectRule( + shortUrl: $defShortUrl, + priority: 4, + longUrl: 'https://blog.alejandrocelaya.com/android', + conditions: new ArrayCollection([RedirectCondition::forDevice(DeviceType::ANDROID)]), + ); + $manager->persist($androidRule); + + $onlyEnglishRule = new ShortUrlRedirectRule( + shortUrl: $defShortUrl, + priority: 3, + longUrl: 'https://example.com/only-english', + conditions: new ArrayCollection([RedirectCondition::forLanguage('en')]), + ); + $manager->persist($onlyEnglishRule); + + $iosRule = new ShortUrlRedirectRule( + shortUrl: $defShortUrl, + priority: 5, + longUrl: 'https://blog.alejandrocelaya.com/ios', + conditions: new ArrayCollection([RedirectCondition::forDevice(DeviceType::IOS)]), + ); + $manager->persist($iosRule); + + $manager->flush(); + } +} diff --git a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php index 03f3f603..31ca6361 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php @@ -9,7 +9,6 @@ use Doctrine\Common\DataFixtures\AbstractFixture; use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; use ReflectionObject; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; @@ -49,10 +48,6 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf 'apiKey' => $authorApiKey, 'longUrl' => 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', - 'deviceLongUrls' => [ - DeviceType::ANDROID->value => 'https://blog.alejandrocelaya.com/android', - DeviceType::IOS->value => 'https://blog.alejandrocelaya.com/ios', - ], 'tags' => ['foo', 'bar'], ]), $relationResolver), '2019-01-01 00:00:10'); $manager->persist($defShortUrl); diff --git a/module/Rest/test-api/Middleware/AuthenticationTest.php b/module/Rest/test-api/Middleware/AuthenticationTest.php index d086f6a6..1c164c85 100644 --- a/module/Rest/test-api/Middleware/AuthenticationTest.php +++ b/module/Rest/test-api/Middleware/AuthenticationTest.php @@ -29,8 +29,8 @@ class AuthenticationTest extends ApiTestCase public static function provideApiVersions(): iterable { - yield 'version 1' => ['1', 'INVALID_AUTHORIZATION']; - yield 'version 2' => ['2', 'INVALID_AUTHORIZATION']; + yield 'version 1' => ['1', 'https://shlink.io/api/error/missing-authentication']; + yield 'version 2' => ['2', 'https://shlink.io/api/error/missing-authentication']; yield 'version 3' => ['3', 'https://shlink.io/api/error/missing-authentication']; } @@ -58,9 +58,9 @@ class AuthenticationTest extends ApiTestCase public static function provideInvalidApiKeys(): iterable { - yield 'key which does not exist' => ['invalid', '2', 'INVALID_API_KEY']; - yield 'key which is expired' => ['expired_api_key', '2', 'INVALID_API_KEY']; - yield 'key which is disabled' => ['disabled_api_key', '2', 'INVALID_API_KEY']; + yield 'key which does not exist' => ['invalid', '2', 'https://shlink.io/api/error/invalid-api-key']; + yield 'key which is expired' => ['expired_api_key', '2', 'https://shlink.io/api/error/invalid-api-key']; + yield 'key which is disabled' => ['disabled_api_key', '2', 'https://shlink.io/api/error/invalid-api-key']; yield 'version 3' => ['disabled_api_key', '3', 'https://shlink.io/api/error/invalid-api-key']; } } diff --git a/module/Rest/test-api/Middleware/RequestIdTest.php b/module/Rest/test-api/Middleware/RequestIdTest.php new file mode 100644 index 00000000..bdc390a7 --- /dev/null +++ b/module/Rest/test-api/Middleware/RequestIdTest.php @@ -0,0 +1,30 @@ +callApi('GET', '/health'); + self::assertTrue($response->hasHeader('X-Request-Id')); + } + + #[Test] + public function keepsProvidedRequestId(): void + { + $response = $this->callApi('GET', '/health', [ + RequestOptions::HEADERS => [ + 'X-Request-Id' => 'foobar', + ], + ]); + self::assertEquals('foobar', $response->hasHeader('X-Request-Id')); + } +} diff --git a/module/Rest/test/Action/RedirectRule/ListRedirectRulesActionTest.php b/module/Rest/test/Action/RedirectRule/ListRedirectRulesActionTest.php new file mode 100644 index 00000000..d2e92240 --- /dev/null +++ b/module/Rest/test/Action/RedirectRule/ListRedirectRulesActionTest.php @@ -0,0 +1,58 @@ +urlResolver = $this->createMock(ShortUrlResolverInterface::class); + $this->ruleService = $this->createMock(ShortUrlRedirectRuleServiceInterface::class); + + $this->action = new ListRedirectRulesAction($this->urlResolver, $this->ruleService); + } + + #[Test] + public function requestIsHandledAndRulesAreReturned(): void + { + $shortUrl = ShortUrl::withLongUrl('https://example.com'); + $request = ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, ApiKey::create()); + $conditions = [RedirectCondition::forDevice(DeviceType::ANDROID), RedirectCondition::forLanguage('en-US')]; + $redirectRules = [ + new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com/rule', new ArrayCollection($conditions)), + ]; + + $this->urlResolver->expects($this->once())->method('resolveShortUrl')->willReturn($shortUrl); + $this->ruleService->expects($this->once())->method('rulesForShortUrl')->willReturn($redirectRules); + + /** @var JsonResponse $response */ + $response = $this->action->handle($request); + $payload = $response->getPayload(); + + self::assertEquals([ + 'defaultLongUrl' => $shortUrl->getLongUrl(), + 'redirectRules' => $redirectRules, + ], $payload); + } +} diff --git a/module/Rest/test/Action/RedirectRule/SetRedirectRulesActionTest.php b/module/Rest/test/Action/RedirectRule/SetRedirectRulesActionTest.php new file mode 100644 index 00000000..e330839c --- /dev/null +++ b/module/Rest/test/Action/RedirectRule/SetRedirectRulesActionTest.php @@ -0,0 +1,58 @@ +urlResolver = $this->createMock(ShortUrlResolverInterface::class); + $this->ruleService = $this->createMock(ShortUrlRedirectRuleServiceInterface::class); + + $this->action = new SetRedirectRulesAction($this->urlResolver, $this->ruleService); + } + + #[Test] + public function requestIsHandledAndRulesAreReturned(): void + { + $shortUrl = ShortUrl::withLongUrl('https://example.com'); + $request = ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, ApiKey::create()); + $conditions = [RedirectCondition::forDevice(DeviceType::ANDROID), RedirectCondition::forLanguage('en-US')]; + $redirectRules = [ + new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com/rule', new ArrayCollection($conditions)), + ]; + + $this->urlResolver->expects($this->once())->method('resolveShortUrl')->willReturn($shortUrl); + $this->ruleService->expects($this->once())->method('setRulesForShortUrl')->willReturn($redirectRules); + + /** @var JsonResponse $response */ + $response = $this->action->handle($request); + $payload = $response->getPayload(); + + self::assertEquals([ + 'defaultLongUrl' => $shortUrl->getLongUrl(), + 'redirectRules' => $redirectRules, + ], $payload); + } +} diff --git a/module/Rest/test/Action/Tag/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php index 447e8331..a63041dd 100644 --- a/module/Rest/test/Action/Tag/ListTagsActionTest.php +++ b/module/Rest/test/Action/Tag/ListTagsActionTest.php @@ -7,14 +7,12 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequestFactory; use Pagerfanta\Adapter\ArrayAdapter; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Tag\Entity\Tag; -use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -32,8 +30,8 @@ class ListTagsActionTest extends TestCase $this->action = new ListTagsAction($this->tagService); } - #[Test, DataProvider('provideNoStatsQueries')] - public function returnsBaseDataWhenStatsAreNotRequested(array $query): void + #[Test] + public function returnsBaseDataWhenStatsAreNotRequested(): void { $tags = [new Tag('foo'), new Tag('bar')]; $tagsCount = count($tags); @@ -43,7 +41,7 @@ class ListTagsActionTest extends TestCase )->willReturn(new Paginator(new ArrayAdapter($tags))); /** @var JsonResponse $resp */ - $resp = $this->action->handle($this->requestWithApiKey()->withQueryParams($query)); + $resp = $this->action->handle($this->requestWithApiKey()); $payload = $resp->getPayload(); self::assertEquals([ @@ -60,46 +58,6 @@ class ListTagsActionTest extends TestCase ], $payload); } - public static function provideNoStatsQueries(): iterable - { - yield 'no query' => [[]]; - yield 'withStats is false' => [['withStats' => 'withStats']]; - yield 'withStats is something else' => [['withStats' => 'foo']]; - } - - #[Test] - public function returnsStatsWhenRequested(): void - { - $stats = [ - new TagInfo('foo', 1, 1), - new TagInfo('bar', 3, 10), - ]; - $itemsCount = count($stats); - $this->tagService->expects($this->once())->method('tagsInfo')->with( - $this->anything(), - $this->isInstanceOf(ApiKey::class), - )->willReturn(new Paginator(new ArrayAdapter($stats))); - $req = $this->requestWithApiKey()->withQueryParams(['withStats' => 'true']); - - /** @var JsonResponse $resp */ - $resp = $this->action->handle($req); - $payload = $resp->getPayload(); - - self::assertEquals([ - 'tags' => [ - 'data' => ['foo', 'bar'], - 'stats' => $stats, - 'pagination' => [ - 'currentPage' => 1, - 'pagesCount' => 1, - 'itemsPerPage' => 10, - 'itemsInCurrentPage' => $itemsCount, - 'totalItems' => $itemsCount, - ], - ], - ], $payload); - } - private function requestWithApiKey(): ServerRequestInterface { return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, ApiKey::create()); diff --git a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php index da660d0e..efa14caa 100644 --- a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php @@ -12,9 +12,10 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; +use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; use Shlinkio\Shlink\Core\Visit\Model\Visitor; -use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\Visit\OrphanVisitsAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -41,7 +42,7 @@ class OrphanVisitsActionTest extends TestCase $visitor = Visitor::emptyInstance(); $visits = [Visit::forInvalidShortUrl($visitor), Visit::forRegularNotFound($visitor)]; $this->visitsHelper->expects($this->once())->method('orphanVisits')->with( - $this->isInstanceOf(VisitsParams::class), + $this->isInstanceOf(OrphanVisitsParams::class), )->willReturn(new Paginator(new ArrayAdapter($visits))); $visitsAmount = count($visits); $this->orphanVisitTransformer->expects($this->exactly($visitsAmount))->method('transform')->with( @@ -57,4 +58,15 @@ class OrphanVisitsActionTest extends TestCase self::assertCount($visitsAmount, $payload['visits']['data']); self::assertEquals(200, $response->getStatusCode()); } + + #[Test] + public function exceptionIsThrownIfInvalidDataIsProvided(): void + { + $this->expectException(ValidationException::class); + $this->action->handle( + ServerRequestFactory::fromGlobals() + ->withAttribute(ApiKey::class, ApiKey::create()) + ->withQueryParams(['type' => 'invalidType']), + ); + } } diff --git a/module/Rest/test/Exception/BackwardsCompatibleProblemDetailsExceptionTest.php b/module/Rest/test/Exception/BackwardsCompatibleProblemDetailsExceptionTest.php deleted file mode 100644 index e51a5ac1..00000000 --- a/module/Rest/test/Exception/BackwardsCompatibleProblemDetailsExceptionTest.php +++ /dev/null @@ -1,113 +0,0 @@ -type; - } - - public function getTitle(): string - { - return 'title'; - } - - public function getDetail(): string - { - return 'detail'; - } - - public function getAdditionalData(): array - { - return []; - } - - public function toArray(): array - { - return ['type' => $this->type]; - } - - public function jsonSerialize(): array - { - return ['type' => $this->type]; - } - }; - $e = BackwardsCompatibleProblemDetailsException::fromProblemDetails($original); - - self::assertEquals($e->getType(), $expectedType); - self::assertEquals($e->toArray(), ['type' => $expectedType]); - self::assertEquals($e->jsonSerialize(), ['type' => $expectedType]); - - self::assertEquals($original->getTitle(), $e->getTitle()); - self::assertEquals($original->getDetail(), $e->getDetail()); - self::assertEquals($original->getAdditionalData(), $e->getAdditionalData()); - - if ($expectSameType) { - self::assertEquals($original->getType(), $e->getType()); - self::assertEquals($original->toArray(), $e->toArray()); - self::assertEquals($original->jsonSerialize(), $e->jsonSerialize()); - } else { - self::assertNotEquals($original->getType(), $e->getType()); - self::assertNotEquals($original->toArray(), $e->toArray()); - self::assertNotEquals($original->jsonSerialize(), $e->jsonSerialize()); - } - } - - public static function provideTypes(): iterable - { - yield ['foo', 'foo', true]; - yield ['bar', 'bar', true]; - yield [ValidationException::ERROR_CODE, 'INVALID_ARGUMENT']; - yield [DeleteShortUrlException::ERROR_CODE, 'INVALID_SHORT_URL_DELETION']; - yield [DomainNotFoundException::ERROR_CODE, 'DOMAIN_NOT_FOUND']; - yield [ForbiddenTagOperationException::ERROR_CODE, 'FORBIDDEN_OPERATION']; - yield [InvalidUrlException::ERROR_CODE, 'INVALID_URL']; - yield [NonUniqueSlugException::ERROR_CODE, 'INVALID_SLUG']; - yield [ShortUrlNotFoundException::ERROR_CODE, 'INVALID_SHORTCODE']; - yield [TagConflictException::ERROR_CODE, 'TAG_CONFLICT']; - yield [TagNotFoundException::ERROR_CODE, 'TAG_NOT_FOUND']; - yield [MercureException::ERROR_CODE, 'MERCURE_NOT_CONFIGURED']; - yield [MissingAuthenticationException::ERROR_CODE, 'INVALID_AUTHORIZATION']; - yield [VerifyAuthenticationException::ERROR_CODE, 'INVALID_API_KEY']; - } -} diff --git a/module/Rest/test/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandlerTest.php b/module/Rest/test/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandlerTest.php deleted file mode 100644 index 78862980..00000000 --- a/module/Rest/test/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandlerTest.php +++ /dev/null @@ -1,74 +0,0 @@ -handler = new BackwardsCompatibleProblemDetailsHandler(); - } - - /** - * @param class-string $expectedException - */ - #[Test, DataProvider('provideExceptions')] - public function expectedExceptionIsThrownBasedOnTheRequestVersion( - ServerRequestInterface $request, - Throwable $thrownException, - string $expectedException, - ): void { - $handler = $this->createMock(RequestHandlerInterface::class); - $handler->expects($this->once())->method('handle')->with($request)->willThrowException($thrownException); - - $this->expectException($expectedException); - - $this->handler->process($request, $handler); - } - - public static function provideExceptions(): iterable - { - $baseRequest = ServerRequestFactory::fromGlobals(); - - yield 'no version' => [ - $baseRequest, - ValidationException::fromArray([]), - BackwardsCompatibleProblemDetailsException::class, - ]; - yield 'version 1' => [ - $baseRequest->withAttribute('version', '1'), - ValidationException::fromArray([]), - BackwardsCompatibleProblemDetailsException::class, - ]; - yield 'version 2' => [ - $baseRequest->withAttribute('version', '2'), - ValidationException::fromArray([]), - BackwardsCompatibleProblemDetailsException::class, - ]; - yield 'version 3' => [ - $baseRequest->withAttribute('version', '3'), - ValidationException::fromArray([]), - ValidationException::class, - ]; - yield 'version 4' => [ - $baseRequest->withAttribute('version', '3'), - ValidationException::fromArray([]), - ValidationException::class, - ]; - } -} diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index 3740bbfe..f45e6ca5 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -14,7 +14,7 @@ use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; -use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface; +use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepository; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyService; @@ -22,12 +22,12 @@ class ApiKeyServiceTest extends TestCase { private ApiKeyService $service; private MockObject & EntityManager $em; - private MockObject & ApiKeyRepositoryInterface $repo; + private MockObject & ApiKeyRepository $repo; protected function setUp(): void { $this->em = $this->createMock(EntityManager::class); - $this->repo = $this->createMock(ApiKeyRepositoryInterface::class); + $this->repo = $this->createMock(ApiKeyRepository::class); $this->service = new ApiKeyService($this->em); } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5abec3eb..9c85d2c4 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -5,6 +5,7 @@ bootstrap="./vendor/autoload.php" colors="true" cacheDirectory="build/.phpunit/unit-tests.cache" + displayDetailsOnTestsThatTriggerWarnings="true" >