diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d2067da..051be99f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,29 +8,12 @@ on: - develop jobs: - lint: - runs-on: ubuntu-20.04 - strategy: - matrix: - php-version: ['8.0'] - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Use PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - tools: composer - extensions: swoole-4.7.1 - coverage: none - - run: composer install --no-interaction --prefer-dist - - run: composer cs - static-analysis: runs-on: ubuntu-20.04 strategy: matrix: php-version: ['8.0'] + command: ['cs', 'stan', 'swagger:validate'] steps: - name: Checkout code uses: actions/checkout@v2 @@ -39,223 +22,90 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.7.1 + extensions: openswoole-4.8.1 coverage: none - run: composer install --no-interaction --prefer-dist - - run: composer stan + - run: composer ${{ matrix.command }} - unit-tests: + tests: runs-on: ubuntu-20.04 strategy: matrix: php-version: ['8.0', '8.1'] - continue-on-error: ${{ matrix.php-version == '8.1' }} + test-group: ['unit', 'api'] steps: - name: Checkout code uses: actions/checkout@v2 + - name: Start database server + if: ${{ matrix.test-group == 'api' }} + run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres - name: Use PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.7.1 + extensions: openswoole-4.8.1 coverage: pcov ini-values: pcov.directory=module - - if: ${{ matrix.php-version == '8.1' }} - run: composer install --no-interaction --prefer-dist --ignore-platform-req=php - - if: ${{ matrix.php-version != '8.1' }} - run: composer install --no-interaction --prefer-dist - - run: composer test:unit:ci + - run: composer install --no-interaction --prefer-dist + - run: composer test:${{ matrix.test-group }}:ci - uses: actions/upload-artifact@v2 if: ${{ matrix.php-version == '8.0' }} with: - name: coverage-unit + name: coverage-${{ matrix.test-group }} path: | - build/coverage-unit - build/coverage-unit.cov + build/coverage-${{ matrix.test-group }} + build/coverage-${{ matrix.test-group }}.cov - db-tests-sqlite: + db-tests: runs-on: ubuntu-20.04 strategy: matrix: php-version: ['8.0', '8.1'] - continue-on-error: ${{ matrix.php-version == '8.1' }} + platform: ['sqlite:ci', 'mysql', 'maria', 'postgres', 'ms'] + env: + LC_ALL: C steps: - name: Checkout code uses: actions/checkout@v2 + - name: Install MSSQL ODBC + if: ${{ matrix.platform == 'ms' }} + run: sudo ./data/infra/ci/install-ms-odbc.sh + - name: Start database server + if: ${{ matrix.platform != 'sqlite:ci' }} + run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_${{ matrix.platform }} - name: Use PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.7.1 + extensions: openswoole-4.8.1, pdo_sqlsrv-5.10.0beta2 coverage: pcov ini-values: pcov.directory=module - - if: ${{ matrix.php-version == '8.1' }} - run: composer install --no-interaction --prefer-dist --ignore-platform-req=php - - if: ${{ matrix.php-version != '8.1' }} - run: composer install --no-interaction --prefer-dist - - run: composer test:db:sqlite:ci - - uses: actions/upload-artifact@v2 - if: ${{ matrix.php-version == '8.0' }} + - run: composer install --no-interaction --prefer-dist + - name: Create test database + if: ${{ matrix.platform == 'ms' }} + run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;" + - name: Run tests + run: composer test:db:${{ matrix.platform }} + - name: Upload code coverage + uses: actions/upload-artifact@v2 + if: ${{ matrix.php-version == '8.0' && matrix.platform == 'sqlite:ci' }} with: name: coverage-db path: | build/coverage-db build/coverage-db.cov - db-tests-mysql: - runs-on: ubuntu-20.04 - strategy: - matrix: - php-version: ['8.0', '8.1'] - continue-on-error: ${{ matrix.php-version == '8.1' }} - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Start database server - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db - - name: Use PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - tools: composer - extensions: swoole-4.7.1 - coverage: none - - if: ${{ matrix.php-version == '8.1' }} - run: composer install --no-interaction --prefer-dist --ignore-platform-req=php - - if: ${{ matrix.php-version != '8.1' }} - run: composer install --no-interaction --prefer-dist - - run: composer test:db:mysql - - db-tests-maria: - runs-on: ubuntu-20.04 - strategy: - matrix: - php-version: ['8.0', '8.1'] - continue-on-error: ${{ matrix.php-version == '8.1' }} - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Start database server - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_maria - - name: Use PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - tools: composer - extensions: swoole-4.7.1 - coverage: none - - if: ${{ matrix.php-version == '8.1' }} - run: composer install --no-interaction --prefer-dist --ignore-platform-req=php - - if: ${{ matrix.php-version != '8.1' }} - run: composer install --no-interaction --prefer-dist - - run: composer test:db:maria - - db-tests-postgres: - runs-on: ubuntu-20.04 - strategy: - matrix: - php-version: ['8.0', '8.1'] - continue-on-error: ${{ matrix.php-version == '8.1' }} - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Start database server - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres - - name: Use PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - tools: composer - extensions: swoole-4.7.1 - coverage: none - - if: ${{ matrix.php-version == '8.1' }} - run: composer install --no-interaction --prefer-dist --ignore-platform-req=php - - if: ${{ matrix.php-version != '8.1' }} - run: composer install --no-interaction --prefer-dist - - run: composer test:db:postgres - - db-tests-ms: - runs-on: ubuntu-20.04 - strategy: - matrix: - php-version: ['8.0', '8.1'] - continue-on-error: ${{ matrix.php-version == '8.1' }} - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Install MSSQL ODBC - run: sudo ./data/infra/ci/install-ms-odbc.sh - - name: Start database server - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms - - name: Use PHP - if: ${{ matrix.php-version == '8.1' }} - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - tools: composer - extensions: swoole-4.7.1, pdo_sqlsrv-5.10.0beta1 - coverage: none - - name: Use PHP - if: ${{ matrix.php-version != '8.1' }} - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - tools: composer - extensions: swoole-4.7.1, pdo_sqlsrv-5.9.0 - coverage: none - - if: ${{ matrix.php-version == '8.1' }} - run: composer install --no-interaction --prefer-dist --ignore-platform-req=php - - if: ${{ matrix.php-version != '8.1' }} - run: composer install --no-interaction --prefer-dist - - name: Create test database - run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;" - - run: composer test:db:ms - - api-tests: - runs-on: ubuntu-20.04 - strategy: - matrix: - php-version: ['8.0', '8.1'] - continue-on-error: ${{ matrix.php-version == '8.1' }} - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Start database server - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres - - name: Use PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - tools: composer - extensions: swoole-4.7.1 - coverage: pcov - ini-values: pcov.directory=module - - if: ${{ matrix.php-version == '8.1' }} - run: composer install --no-interaction --prefer-dist --ignore-platform-req=php - - if: ${{ matrix.php-version != '8.1' }} - run: composer install --no-interaction --prefer-dist - - run: bin/test/run-api-tests.sh - - uses: actions/upload-artifact@v2 - if: ${{ matrix.php-version == '8.0' }} - with: - name: coverage-api - path: | - build/coverage-api - build/coverage-api.cov - mutation-tests: needs: - - unit-tests - - db-tests-sqlite - - api-tests + - tests + - db-tests runs-on: ubuntu-20.04 strategy: matrix: php-version: ['8.0', '8.1'] - test-group: ['unit', 'db'] - continue-on-error: ${{ matrix.php-version == '8.1' }} + test-group: ['unit', 'db', 'api'] steps: - name: Checkout code uses: actions/checkout@v2 @@ -264,23 +114,24 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.7.1 + extensions: openswoole-4.8.1 coverage: pcov ini-values: pcov.directory=module - - if: ${{ matrix.php-version == '8.1' }} - run: composer install --no-interaction --prefer-dist --ignore-platform-req=php - - if: ${{ matrix.php-version != '8.1' }} - run: composer install --no-interaction --prefer-dist + - run: composer install --no-interaction --prefer-dist - uses: actions/download-artifact@v2 with: path: build - - run: composer infect:ci:${{ matrix.test-group }} + - if: ${{ matrix.test-group == 'unit' }} + run: composer infect:ci:unit + env: + INFECTION_BADGE_API_KEY: ${{ secrets.INFECTION_BADGE_API_KEY }} + - if: ${{ matrix.test-group != 'unit' }} + run: composer infect:ci:${{ matrix.test-group }} upload-coverage: needs: - - unit-tests - - db-tests-sqlite - - api-tests + - tests + - db-tests runs-on: ubuntu-20.04 strategy: matrix: diff --git a/.github/workflows/docker-image-build.yml b/.github/workflows/docker-image-build.yml index ebdd1d05..a4f47026 100644 --- a/.github/workflows/docker-image-build.yml +++ b/.github/workflows/docker-image-build.yml @@ -9,7 +9,7 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - name: Checkout code uses: actions/checkout@v2 diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index b45ee370..56a60e5b 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -20,7 +20,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.7 + extensions: openswoole-4.8.1 - if: ${{ matrix.swoole == 'yes' }} run: ./build.sh ${GITHUB_REF#refs/tags/v} - if: ${{ matrix.swoole == 'no' }} diff --git a/.github/workflows/publish-swagger-spec.yml b/.github/workflows/publish-swagger-spec.yml new file mode 100644 index 00000000..3264fad9 --- /dev/null +++ b/.github/workflows/publish-swagger-spec.yml @@ -0,0 +1,40 @@ +name: Publish swagger spec + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-20.04 + strategy: + matrix: + php-version: ['8.0'] + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Determine version + id: determine_version + run: echo "::set-output name=version::${GITHUB_REF#refs/tags/}" + shell: bash + - name: Use PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + tools: composer + extensions: openswoole-4.8.1 + coverage: none + - run: composer install --no-interaction --prefer-dist + - run: composer swagger:inline + - run: mkdir ${{ steps.determine_version.outputs.version }} + - run: mv docs/swagger/swagger-inline.json ${{ steps.determine_version.outputs.version }}/oas.json + - name: Publish spec + uses: JamesIves/github-pages-deploy-action@4.1.7 + with: + token: ${{ secrets.OAS_PUBLISH_TOKEN }} + repository-name: 'shlinkio/shlink-open-api-specs' + branch: main + folder: ${{ steps.determine_version.outputs.version }} + target-folder: specs/${{ steps.determine_version.outputs.version }} + clean: false diff --git a/.gitignore b/.gitignore index 32942a29..933c25ee 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ docs/swagger-ui* docs/mercure.html docker-compose.override.yml .phpunit.result.cache +docs/swagger/swagger-inlined.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c4ac366..ee4a64c6 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). +## [2.10.0] - 2021-12-12 +### Added +* [#1163](https://github.com/shlinkio/shlink/issues/1163) Allowed setting not-found redirects for default domain in the same way it's done for any other domain. + + This implies a few non-breaking changes: + + * The domains list no longer has the values of `INVALID_SHORT_URL_REDIRECT_TO`, `REGULAR_404_REDIRECT_TO` and `BASE_URL_REDIRECT_TO` on the default domain redirects. + * The `GET /domains` endpoint includes a new `defaultRedirects` property in the response, with the default redirects set via config or env vars. + * The `INVALID_SHORT_URL_REDIRECT_TO`, `REGULAR_404_REDIRECT_TO` and `BASE_URL_REDIRECT_TO` env vars are now deprecated, and should be replaced by `DEFAULT_INVALID_SHORT_URL_REDIRECT`, `DEFAULT_REGULAR_404_REDIRECT` and `DEFAULT_BASE_URL_REDIRECT` respectively. Deprecated ones will continue to work until v3.0.0, where they will be removed. + +* [#868](https://github.com/shlinkio/shlink/issues/868) Added support to publish real-time updates in a RabbitMQ server. + + Shlink will create new exchanges and queues for every topic documented in the [Async API spec](https://api-spec.shlink.io/async-api/), meaning, you will have one queue for orphan visits, one for regular visits, and one queue for every short URL with its visits. + + The RabbitMQ server config can be provided via installer config options, or via environment variables. + +* [#1204](https://github.com/shlinkio/shlink/issues/1204) Added support for `openswoole` and migrated official docker image to `openswoole`. +* [#1242](https://github.com/shlinkio/shlink/issues/1242) Added support to import urls and visits from YOURLS. + + In order to do it, you need to first install this [dedicated plugin](https://slnk.to/yourls-import) in YOURLS, and then run the `short-url:import yourls` command, as with any other source. + +* [#1235](https://github.com/shlinkio/shlink/issues/1235) Added support to disable rounding QR codes block sizing via config option, env var or query param. +* [#1188](https://github.com/shlinkio/shlink/issues/1188) Added support for PHP 8.1. + + The official docker image has also been updated to use PHP 8.1 by default. + +### Changed +* [#844](https://github.com/shlinkio/shlink/issues/844) Added mutation checks to API tests. +* [#1218](https://github.com/shlinkio/shlink/issues/1218) Updated to symfony/mercure 0.6. +* [#1223](https://github.com/shlinkio/shlink/issues/1223) Updated to phpstan 1.0. +* [#1258](https://github.com/shlinkio/shlink/issues/1258) Updated to Symfony 6 components, except symfony/console. +* Added `domain` field to `DeleteShortUrlException` exception. + +### Deprecated +* [#1260](https://github.com/shlinkio/shlink/issues/1260) Deprecated `USE_HTTPS` env var that was added in previous release, in favor of the new `IS_HTTPS_ENABLED`. + + The old one proved to be confusing and misleading, making people think it was used to actually enable HTTPS transparently, instead of its actual purpose, which is just telling Shlink it is being served with HTTPS. + +### Removed +* *Nothing* + +### Fixed +* [#1206](https://github.com/shlinkio/shlink/issues/1206) Fixed debugging of the docker image, so that it does not run the commands with `-q` when the `SHELL_VERBOSITY` env var has been provided. +* [#1254](https://github.com/shlinkio/shlink/issues/1254) Fixed examples in swagger docs. + + ## [2.9.3] - 2021-11-15 ### Added * *Nothing* diff --git a/Dockerfile b/Dockerfile index aea95a86..30ca29e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ -FROM php:8.0.9-alpine3.14 as base +FROM php:8.1.0-alpine3.15 as base ARG SHLINK_VERSION=latest ENV SHLINK_VERSION ${SHLINK_VERSION} -ENV SWOOLE_VERSION 4.7.1 -ENV PDO_SQLSRV_VERSION 5.9.0 +ENV OPENSWOOLE_VERSION 4.8.1 +ENV PDO_SQLSRV_VERSION 5.10.0beta2 ENV MS_ODBC_SQL_VERSION 17.5.2.2 ENV LC_ALL "C" @@ -11,8 +11,8 @@ WORKDIR /etc/shlink # Install required PHP extensions RUN \ - # Install mysql and calendar - docker-php-ext-install -j"$(nproc)" pdo_mysql calendar && \ + # Install extensions with no extra dependencies + docker-php-ext-install -j"$(nproc)" pdo_mysql calendar sockets bcmath && \ # Install sqlite apk add --no-cache sqlite-libs sqlite-dev && \ docker-php-ext-install -j"$(nproc)" pdo_sqlite && \ @@ -40,10 +40,10 @@ RUN if [ $(uname -m) == "x86_64" ]; then \ rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk ; \ fi -# Install swoole +# Install openswoole RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} && \ - pecl install swoole-${SWOOLE_VERSION} && \ - docker-php-ext-enable swoole && \ + pecl install openswoole-${OPENSWOOLE_VERSION} && \ + docker-php-ext-enable openswoole && \ apk del .phpize-deps @@ -65,7 +65,7 @@ LABEL maintainer="Alejandro Celaya " COPY --from=builder /etc/shlink . RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink -# Expose default swoole port +# Expose default openswoole port EXPOSE 8080 # Copy config specific for the image diff --git a/README.md b/README.md index cd15bb5c..9aad62d9 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Build Status](https://img.shields.io/github/workflow/status/shlinkio/shlink/Continuous%20integration/develop?logo=github&style=flat-square)](https://github.com/shlinkio/shlink/actions?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) @@ -33,10 +34,11 @@ The idea is that you can just generate a container using the image and provide t First, make sure the host where you are going to run shlink fulfills these requirements: -* PHP 8.0 +* PHP 8.0 or 8.1 * The next PHP extensions: json, curl, pdo, intl, gd and gmp. - * apcu extension is recommended if you don't plan to use swoole. + * apcu extension is recommended if you don't plan to use swoole or openswoole. * 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, Microsoft SQL Server or SQLite. * The web server of your choice with PHP integration (Apache or Nginx recommended). @@ -48,7 +50,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 swoole 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 and with/without swoole/openswoole integration. Finally, decompress the file in the location of your choice. diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index dbd87a84..3e8530b6 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -2,6 +2,7 @@ export APP_ENV=test export DB_DRIVER=postgres export TEST_ENV=api +export GENERATE_COVERAGE=${GENERATE_COVERAGE:-"no"} rm -rf data/log/api-tests diff --git a/composer.json b/composer.json index d2a4a97e..9652af32 100644 --- a/composer.json +++ b/composer.json @@ -15,68 +15,69 @@ "php": "^8.0", "ext-json": "*", "ext-pdo": "*", - "akrabat/ip-address-middleware": "^2.0", - "cakephp/chronos": "^2.2", + "akrabat/ip-address-middleware": "^2.1", + "cakephp/chronos": "^2.3", "cocur/slugify": "^4.0", - "doctrine/dbal": "^3.1.4", - "doctrine/migrations": "^3.3 <3.3.2", - "doctrine/orm": "^2.9", - "endroid/qr-code": "^4.2", - "geoip2/geoip2": "^2.11", - "guzzlehttp/guzzle": "^7.3", + "doctrine/migrations": "^3.3", + "doctrine/orm": "^2.10", + "endroid/qr-code": "^4.4", + "geoip2/geoip2": "^2.12", + "guzzlehttp/guzzle": "^7.4", "happyr/doctrine-specification": "^2.0", - "jaybizzle/crawler-detect": "^1.2", - "laminas/laminas-config": "^3.5", - "laminas/laminas-config-aggregator": "^1.5", - "laminas/laminas-diactoros": "^2.6", - "laminas/laminas-inputfilter": "^2.12", - "laminas/laminas-servicemanager": "^3.7", - "laminas/laminas-stdlib": "^3.5", + "jaybizzle/crawler-detect": "^1.2.110", + "laminas/laminas-config": "^3.7", + "laminas/laminas-config-aggregator": "^1.7", + "laminas/laminas-diactoros": "^2.8", + "laminas/laminas-inputfilter": "^2.13", + "laminas/laminas-servicemanager": "^3.10", + "laminas/laminas-stdlib": "^3.6", "lcobucci/jwt": "^4.1", "league/uri": "^6.4", "lstrojny/functional-php": "^1.17", - "mezzio/mezzio": "^3.5", - "mezzio/mezzio-fastroute": "^3.2", - "mezzio/mezzio-problem-details": "^1.4", - "mezzio/mezzio-swoole": "^3.3", + "mezzio/mezzio": "^3.7", + "mezzio/mezzio-fastroute": "^3.3", + "mezzio/mezzio-problem-details": "^1.5", + "mezzio/mezzio-swoole": "^3.5", + "mlocati/ip-lib": "^1.17", "monolog/monolog": "^2.3", "nikolaposa/monolog-factory": "^3.1", "ocramius/proxy-manager": "^2.11", - "pagerfanta/core": "^2.7", + "pagerfanta/core": "^3.5", + "php-amqplib/php-amqplib": "^3.1", "php-middleware/request-id": "^4.1", "predis/predis": "^1.1", - "pugx/shortid-php": "^0.7", - "ramsey/uuid": "^3.9", - "rlanvin/php-ip": "3.0.0-rc2", - "shlinkio/shlink-common": "^4.0", - "shlinkio/shlink-config": "^1.2", - "shlinkio/shlink-event-dispatcher": "^2.1", - "shlinkio/shlink-importer": "^2.3.1", - "shlinkio/shlink-installer": "^6.2.1", - "shlinkio/shlink-ip-geolocation": "^2.0", - "symfony/console": "^5.3", - "symfony/filesystem": "^5.3", - "symfony/lock": "^5.3", - "symfony/mercure": "^0.5.3", - "symfony/process": "^5.3", - "symfony/string": "^5.3" + "pugx/shortid-php": "^1.0", + "ramsey/uuid": "^4.2", + "shlinkio/shlink-common": "^4.2", + "shlinkio/shlink-config": "^1.4", + "shlinkio/shlink-event-dispatcher": "^2.3", + "shlinkio/shlink-importer": "^2.5", + "shlinkio/shlink-installer": "^6.3", + "shlinkio/shlink-ip-geolocation": "^2.2", + "symfony/console": "^5.4", + "symfony/filesystem": "^6.0 || ^5.4", + "symfony/lock": "^6.0 || ^5.4", + "symfony/mercure": "^0.6", + "symfony/process": "^6.0 || ^5.4", + "symfony/string": "^6.0 || ^5.4" }, "require-dev": { + "cebe/php-openapi": "^1.5", "devster/ubench": "^2.1", "dms/phpunit-arraysubset-asserts": "^0.3.0", "eaglewu/swoole-ide-helper": "dev-master", - "infection/infection": "^0.25.0", + "infection/infection": "^0.25.4", "phpspec/prophecy-phpunit": "^2.0", - "phpstan/phpstan": "^0.12.94", - "phpstan/phpstan-doctrine": "^0.12.42", - "phpstan/phpstan-symfony": "^0.12.41", + "phpstan/phpstan": "^1.2", + "phpstan/phpstan-doctrine": "^1.0", + "phpstan/phpstan-symfony": "^1.0", "phpunit/php-code-coverage": "^9.2", "phpunit/phpunit": "^9.5", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.2.0", - "shlinkio/shlink-test-utils": "^2.3", - "symfony/var-dumper": "^5.3", - "veewee/composer-run-parallel": "^1.0" + "shlinkio/shlink-test-utils": "^2.5", + "symfony/var-dumper": "^6.0", + "veewee/composer-run-parallel": "^1.1" }, "autoload": { "psr-4": { @@ -107,12 +108,13 @@ "ci": [ "@cs", "@stan", + "@swagger:validate", "@test:ci", "@infect:ci" ], "ci:parallel": [ - "@parallel cs stan test:unit:ci test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms", - "@parallel test:api infect:ci:unit infect:ci:db" + "@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:ci:unit infect:ci:db" ], "cs": "phpcs", "cs:fix": "phpcbf", @@ -125,11 +127,11 @@ "test:ci": [ "@test:unit:ci", "@test:db", - "@test:api" + "@test:api:ci" ], "test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox", "test:unit:ci": "@test:unit --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml", - "test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html", + "test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --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", @@ -138,18 +140,30 @@ "test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite", "test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite", "test:api": "bin/test/run-api-tests.sh", + "test:api:ci": "GENERATE_COVERAGE=yes composer test:api", "infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --only-covering-test-cases --skip-initial-tests", "infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80", "infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json", - "infect:ci": "@parallel infect:ci:unit infect:ci:db", + "infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=80 --configuration=infection-api.json", + "infect:ci": "@parallel infect:ci:unit infect:ci:db infect:ci:api", "infect:test": [ - "@parallel test:unit:ci test:db:sqlite:ci", + "@parallel test:unit:ci test:db:sqlite:ci test:api:ci", "@infect:ci" ], + "infect:test:unit": [ + "@test:unit:ci", + "@infect:ci:unit" + ], + "infect:test:api": [ + "@test:api:ci", + "@infect:ci:api" + ], + "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\", \"test:ci\" and \"infect:ci\"", + "ci": "Alias for \"cs\", \"stan\", \"swagger:validate\", \"test:ci\" and \"infect:ci\"", "ci:parallel": "Same as \"ci\", but parallelizing tasks as much as possible", "cs": "Checks coding styles", "cs:fix": "Fixes coding styles, when possible", @@ -158,6 +172,7 @@ "test:ci": "Runs all test suites, generating all needed reports and logs for CI envs", "test:unit": "Runs unit test suites", "test:unit:ci": "Runs unit test suites, generating all needed reports and logs for CI envs", + "test:unit:pretty": "Runs unit test suites and generates an HTML code coverage report", "test:db": "Runs database test suites on a SQLite, MySQL, MariaDB, PostgreSQL and MsSQL", "test:db:sqlite": "Runs database test suites on a SQLite database", "test:db:sqlite:ci": "Runs database test suites on a SQLite database, generating all needed reports and logs for CI envs", @@ -166,11 +181,13 @@ "test:db:postgres": "Runs database test suites on a PostgreSQL database", "test:db:ms": "Runs database test suites on a Miscrosoft SQL Server database", "test:api": "Runs API test suites", - "test:unit:pretty": "Runs unit test suites and generates an HTML code coverage report", + "test:api:ci": "Runs API test suites, and generates code coverage reports", "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" }, "config": { diff --git a/config/autoload/entity-manager.local.php.dist b/config/autoload/entity-manager.local.php.dist index c4d2b921..ef5cabf8 100644 --- a/config/autoload/entity-manager.local.php.dist +++ b/config/autoload/entity-manager.local.php.dist @@ -9,7 +9,7 @@ return [ 'user' => 'root', 'password' => 'root', 'driver' => 'pdo_mysql', - 'host' => 'shlink_db', + 'host' => 'shlink_db_mysql', 'dbname' => 'shlink', 'charset' => 'utf8', ], diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 24461e70..238dea42 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -56,6 +56,13 @@ return [ Option\QrCode\DefaultMarginConfigOption::class, Option\QrCode\DefaultFormatConfigOption::class, Option\QrCode\DefaultErrorCorrectionConfigOption::class, + Option\QrCode\DefaultRoundBlockSizeConfigOption::class, + Option\RabbitMq\RabbitMqEnabledConfigOption::class, + Option\RabbitMq\RabbitMqHostConfigOption::class, + Option\RabbitMq\RabbitMqPortConfigOption::class, + Option\RabbitMq\RabbitMqUserConfigOption::class, + Option\RabbitMq\RabbitMqPasswordConfigOption::class, + Option\RabbitMq\RabbitMqVhostConfigOption::class, ], 'installation_commands' => [ diff --git a/config/autoload/logger.local.php.dist b/config/autoload/logger.local.php.dist index 4aa46c68..1da0384b 100644 --- a/config/autoload/logger.local.php.dist +++ b/config/autoload/logger.local.php.dist @@ -5,7 +5,7 @@ declare(strict_types=1); use Monolog\Handler\StreamHandler; use Monolog\Logger; -$isSwoole = extension_loaded('swoole'); +$isSwoole = extension_loaded('openswoole'); // For swoole, send logs to standard output $handler = $isSwoole diff --git a/config/autoload/qr-codes.global.php b/config/autoload/qr-codes.global.php index 1cf6fecb..5f528620 100644 --- a/config/autoload/qr-codes.global.php +++ b/config/autoload/qr-codes.global.php @@ -7,6 +7,7 @@ use function Shlinkio\Shlink\Common\env; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION; use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT; use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_ROUND_BLOCK_SIZE; use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE; return [ @@ -16,6 +17,7 @@ return [ 'margin' => (int) env('DEFAULT_QR_CODE_MARGIN', DEFAULT_QR_CODE_MARGIN), 'format' => env('DEFAULT_QR_CODE_FORMAT', DEFAULT_QR_CODE_FORMAT), 'error_correction' => env('DEFAULT_QR_CODE_ERROR_CORRECTION', DEFAULT_QR_CODE_ERROR_CORRECTION), + 'round_block_size' => (bool) env('DEFAULT_QR_CODE_ROUND_BLOCK_SIZE', DEFAULT_QR_CODE_ROUND_BLOCK_SIZE), ], ]; diff --git a/config/autoload/rabbit.global.php b/config/autoload/rabbit.global.php new file mode 100644 index 00000000..b08dccf2 --- /dev/null +++ b/config/autoload/rabbit.global.php @@ -0,0 +1,48 @@ + [ + 'enabled' => (bool) env('RABBITMQ_ENABLED', false), + 'host' => env('RABBITMQ_HOST'), + 'port' => (int) env('RABBITMQ_PORT', '5672'), + 'user' => env('RABBITMQ_USER'), + 'password' => env('RABBITMQ_PASSWORD'), + 'vhost' => env('RABBITMQ_VHOST', '/'), + ], + + 'dependencies' => [ + 'factories' => [ + AMQPStreamConnection::class => ConfigAbstractFactory::class, + ], + 'delegators' => [ + AMQPStreamConnection::class => [ + LazyServiceFactory::class, + ], + ], + 'lazy_services' => [ + 'class_map' => [ + AMQPStreamConnection::class => AMQPStreamConnection::class, + ], + ], + ], + + ConfigAbstractFactory::class => [ + AMQPStreamConnection::class => [ + 'config.rabbitmq.host', + 'config.rabbitmq.port', + 'config.rabbitmq.user', + 'config.rabbitmq.password', + 'config.rabbitmq.vhost', + ], + ], + +]; diff --git a/config/autoload/rabbit.local.php.dist b/config/autoload/rabbit.local.php.dist new file mode 100644 index 00000000..83cd4a88 --- /dev/null +++ b/config/autoload/rabbit.local.php.dist @@ -0,0 +1,14 @@ + [ + 'enabled' => true, + 'host' => 'shlink_rabbitmq', + 'user' => 'rabbit', + 'password' => 'rabbit', + ], + +]; diff --git a/config/autoload/redirects.global.php b/config/autoload/redirects.global.php index 339ca27d..d2c73884 100644 --- a/config/autoload/redirects.global.php +++ b/config/autoload/redirects.global.php @@ -10,9 +10,10 @@ use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE; return [ 'not_found_redirects' => [ - 'invalid_short_url' => env('INVALID_SHORT_URL_REDIRECT_TO'), - 'regular_404' => env('REGULAR_404_REDIRECT_TO'), - 'base_url' => env('BASE_URL_REDIRECT_TO'), + // Deprecated env vars + 'invalid_short_url' => env('DEFAULT_INVALID_SHORT_URL_REDIRECT', env('INVALID_SHORT_URL_REDIRECT_TO')), + 'regular_404' => env('DEFAULT_REGULAR_404_REDIRECT', env('REGULAR_404_REDIRECT_TO')), + 'base_url' => env('DEFAULT_BASE_URL_REDIRECT', env('BASE_URL_REDIRECT_TO')), ], 'url_shortener' => [ diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index aedab669..e14ceddb 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -8,13 +8,17 @@ use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH; return (static function (): array { - $shortCodesLength = (int) env('DEFAULT_SHORT_CODES_LENGTH', DEFAULT_SHORT_CODES_LENGTH); - $shortCodesLength = $shortCodesLength < MIN_SHORT_CODES_LENGTH ? MIN_SHORT_CODES_LENGTH : $shortCodesLength; + $shortCodesLength = max( + (int) env('DEFAULT_SHORT_CODES_LENGTH', DEFAULT_SHORT_CODES_LENGTH), + MIN_SHORT_CODES_LENGTH, + ); $resolveSchema = static function (): string { - $useHttps = env('USE_HTTPS'); // Deprecated. For v3, set this to true by default, instead of null - if ($useHttps !== null) { - $boolUseHttps = (bool) $useHttps; - return $boolUseHttps ? 'https' : 'http'; + // Deprecated. For v3, IS_HTTPS_ENABLED should be true by default, instead of null +// return ((bool) env('IS_HTTPS_ENABLED', true)) ? 'https' : 'http'; + $isHttpsEnabled = env('IS_HTTPS_ENABLED', env('USE_HTTPS')); + if ($isHttpsEnabled !== null) { + $boolIsHttpsEnabled = (bool) $isHttpsEnabled; + return $boolIsHttpsEnabled ? 'https' : 'http'; } return env('SHORT_DOMAIN_SCHEMA', 'http'); diff --git a/config/autoload/url-shortener.local.php.dist b/config/autoload/url-shortener.local.php.dist index f34245fb..20140a9b 100644 --- a/config/autoload/url-shortener.local.php.dist +++ b/config/autoload/url-shortener.local.php.dist @@ -2,7 +2,7 @@ declare(strict_types=1); -$isSwoole = extension_loaded('swoole'); +$isSwoole = extension_loaded('openswoole'); return [ diff --git a/config/config.php b/config/config.php index 887aa365..ccb61cbb 100644 --- a/config/config.php +++ b/config/config.php @@ -13,11 +13,17 @@ use Mezzio\Swoole; use function class_exists; use function Shlinkio\Shlink\Common\env; +use const PHP_SAPI; + +$isCli = PHP_SAPI === 'cli'; + return (new ConfigAggregator\ConfigAggregator([ Mezzio\ConfigProvider::class, Mezzio\Router\ConfigProvider::class, Mezzio\Router\FastRouteRouter\ConfigProvider::class, - class_exists(Swoole\ConfigProvider::class) ? Swoole\ConfigProvider::class : new ConfigAggregator\ArrayProvider([]), + $isCli && 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 6c7aa09e..8171cd66 100644 --- a/config/constants.php +++ b/config/constants.php @@ -18,4 +18,5 @@ const DEFAULT_QR_CODE_SIZE = 300; const DEFAULT_QR_CODE_MARGIN = 0; const DEFAULT_QR_CODE_FORMAT = 'png'; const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l'; +const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true; const MIN_TASK_WORKERS = 4; diff --git a/config/test/bootstrap_api_tests.php b/config/test/bootstrap_api_tests.php index 8d22d029..52c9d4fb 100644 --- a/config/test/bootstrap_api_tests.php +++ b/config/test/bootstrap_api_tests.php @@ -20,8 +20,7 @@ $config = $container->get('config'); $em = $container->get(EntityManager::class); $httpClient = $container->get('shlink_test_api_client'); -// Start code coverage collecting on swoole process, and stop it when process shuts down -$httpClient->request('GET', sprintf('http://%s:%s/api-tests/start-coverage', SWOOLE_TESTING_HOST, SWOOLE_TESTING_PORT)); +// Dump code coverage when process shuts down register_shutdown_function(function () use ($httpClient): void { $httpClient->request( 'GET', diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index 68d1011c..0898c732 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -8,13 +8,16 @@ use GuzzleHttp\Client; use Laminas\ConfigAggregator\ConfigAggregator; use Laminas\Diactoros\Response\EmptyResponse; use Laminas\ServiceManager\Factory\InvokableFactory; -use Laminas\Stdlib\Glob; use Monolog\Handler\StreamHandler; use Monolog\Logger; use PHPUnit\Runner\Version; +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; @@ -27,18 +30,17 @@ use const ShlinkioTest\Shlink\SWOOLE_TESTING_HOST; use const ShlinkioTest\Shlink\SWOOLE_TESTING_PORT; $isApiTest = env('TEST_ENV') === 'api'; -if ($isApiTest) { +$generateCoverage = env('GENERATE_COVERAGE') === 'yes'; +if ($isApiTest && $generateCoverage) { $filter = new Filter(); - foreach (Glob::glob(__DIR__ . '/../../module/*/src') as $item) { - $filter->includeDirectory($item); - } + $filter->includeDirectory(__DIR__ . '/../../module/Core/src'); + $filter->includeDirectory(__DIR__ . '/../../module/Rest/src'); $coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter); } $buildDbConnection = static function (): array { $driver = env('DB_DRIVER', 'sqlite'); $isCi = env('CI', false); - $getMysqlHost = static fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria'); $getCiMysqlPort = static fn (string $driver) => $driver === 'mysql' ? '3307' : '3308'; return match ($driver) { @@ -64,7 +66,7 @@ $buildDbConnection = static function (): array { ], default => [ // mysql and maria 'driver' => 'pdo_mysql', - 'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver), + 'host' => $isCi ? '127.0.0.1' : sprintf('shlink_db_%s', $driver), 'port' => $isCi ? $getCiMysqlPort($driver) : '3306', 'user' => 'root', 'password' => 'root', @@ -113,26 +115,18 @@ return [ ], 'routes' => !$isApiTest ? [] : [ - [ - 'name' => 'start_collecting_coverage', - 'path' => '/api-tests/start-coverage', - 'middleware' => middleware(static function () use (&$coverage) { - if ($coverage) { // @phpstan-ignore-line - $coverage->start('API tests'); - } - return new EmptyResponse(); - }), - 'allowed_methods' => ['GET'], - ], [ 'name' => 'dump_coverage', 'path' => '/api-tests/stop-coverage', 'middleware' => middleware(static function () use (&$coverage) { + // TODO I have tried moving this block to a listener so that it's invoked automatically, + // but then the coverage is generated empty ¯\_(ツ)_/¯ if ($coverage) { // @phpstan-ignore-line $basePath = __DIR__ . '/../../build/coverage-api'; - $coverage->stop(); + (new PHP())->process($coverage, $basePath . '.cov'); (new Xml(Version::getVersionString()))->process($coverage, $basePath . '/coverage-xml'); + (new Html())->process($coverage, $basePath . '/coverage-html'); } return new EmptyResponse(); @@ -141,6 +135,24 @@ return [ ], ], + '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(); + } + }), + 'priority' => 9999, + ], + ], + 'mercure' => [ 'public_hub_url' => null, 'internal_hub_url' => null, diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 1503ddf2..96556869 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -1,8 +1,8 @@ -FROM php:8.0.9-fpm-alpine3.14 +FROM php:8.1.0-fpm-alpine3.15 MAINTAINER Alejandro Celaya -ENV APCU_VERSION 5.1.20 -ENV PDO_SQLSRV_VERSION 5.9.0 +ENV APCU_VERSION 5.1.21 +ENV PDO_SQLSRV_VERSION 5.10.0beta2 ENV MS_ODBC_SQL_VERSION 17.5.2.2 RUN apk update @@ -34,6 +34,9 @@ RUN docker-php-ext-install pdo_pgsql RUN apk add --no-cache gmp-dev RUN docker-php-ext-install gmp +RUN docker-php-ext-install sockets +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 \ diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index 5b4fac1c..570ca2a9 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -1,10 +1,10 @@ -FROM php:8.0.9-alpine3.14 +FROM php:8.1.0-alpine3.15 MAINTAINER Alejandro Celaya -ENV APCU_VERSION 5.1.20 +ENV APCU_VERSION 5.1.21 ENV INOTIFY_VERSION 3.0.0 -ENV SWOOLE_VERSION 4.7.1 -ENV PDO_SQLSRV_VERSION 5.9.0 +ENV OPENSWOOLE_VERSION 4.8.1 +ENV PDO_SQLSRV_VERSION 5.10.0beta2 ENV MS_ODBC_SQL_VERSION 17.5.2.2 RUN apk update @@ -36,6 +36,9 @@ RUN docker-php-ext-install pdo_pgsql RUN apk add --no-cache gmp-dev RUN docker-php-ext-install gmp +RUN docker-php-ext-install sockets +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 \ @@ -54,12 +57,12 @@ RUN mkdir -p /usr/src/php/ext/inotify \ && docker-php-ext-install inotify \ && rm /tmp/inotify.tar.gz -# Install swoole, pcov and mssql driver +# Install openswoole, pcov and mssql driver RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ - pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \ - docker-php-ext-enable swoole pdo_sqlsrv pcov && \ + pecl install openswoole-${OPENSWOOLE_VERSION} pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \ + docker-php-ext-enable openswoole pdo_sqlsrv pcov && \ apk del .phpize-deps && \ rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk @@ -72,12 +75,12 @@ RUN chmod 777 /home VOLUME /home/shlink WORKDIR /home/shlink -# Expose swoole port +# 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, swoole might think it is already in execution + # 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/docker-compose.ci.yml b/docker-compose.ci.yml index 3783fef2..f4235dcb 100644 --- a/docker-compose.ci.yml +++ b/docker-compose.ci.yml @@ -1,7 +1,7 @@ version: '3' services: - shlink_db: + shlink_db_mysql: environment: MYSQL_DATABASE: shlink_test diff --git a/docker-compose.override.yml.dist b/docker-compose.override.yml.dist index ea0bee84..990d1b5d 100644 --- a/docker-compose.override.yml.dist +++ b/docker-compose.override.yml.dist @@ -13,7 +13,7 @@ services: - /etc/passwd:/etc/passwd:ro - /etc/group:/etc/group:ro - shlink_db: + shlink_db_mysql: user: 1000:1000 volumes: - /etc/passwd:/etc/passwd:ro diff --git a/docker-compose.yml b/docker-compose.yml index ab7baf1f..3d552f9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,15 +22,18 @@ services: - ./:/home/shlink/www - ./data/infra/php.ini:/usr/local/etc/php/php.ini links: - - shlink_db + - shlink_db_mysql - shlink_db_postgres - shlink_db_maria - shlink_db_ms - shlink_redis - shlink_mercure - shlink_mercure_proxy + - shlink_rabbitmq environment: LC_ALL: C + extra_hosts: + - 'host.docker.internal:host-gateway' shlink_swoole_proxy: container_name: shlink_swoole_proxy @@ -55,18 +58,21 @@ services: - ./:/home/shlink - ./data/infra/php.ini:/usr/local/etc/php/php.ini links: - - shlink_db + - shlink_db_mysql - shlink_db_postgres - shlink_db_maria - shlink_db_ms - shlink_redis - shlink_mercure - shlink_mercure_proxy + - shlink_rabbitmq environment: LC_ALL: C + extra_hosts: + - 'host.docker.internal:host-gateway' - shlink_db: - container_name: shlink_db + shlink_db_mysql: + container_name: shlink_db_mysql image: mysql:5.7 ports: - "3307:3306" @@ -131,10 +137,21 @@ services: shlink_mercure: container_name: shlink_mercure - image: dunglas/mercure:v0.10 + image: dunglas/mercure:v0.13 ports: - "3080:80" environment: - CORS_ALLOWED_ORIGINS: "*" - JWT_KEY: "mercure_jwt_key" - USE_FORWARDED_HEADERS: "1" + SERVER_NAME: ":80" + MERCURE_PUBLISHER_JWT_KEY: mercure_jwt_key + MERCURE_SUBSCRIBER_JWT_KEY: mercure_jwt_key + MERCURE_EXTRA_DIRECTIVES: "cors_origins https://app.shlink.io http://localhost:3000 http://127.0.0.1:3000" + + shlink_rabbitmq: + container_name: shlink_rabbitmq + image: rabbitmq:3.9-management-alpine + ports: + - "15672:15672" + - "5672:5672" + environment: + RABBITMQ_DEFAULT_USER: "rabbit" + RABBITMQ_DEFAULT_PASS: "rabbit" diff --git a/docker/README.md b/docker/README.md index 9f97642c..b7b92dcf 100644 --- a/docker/README.md +++ b/docker/README.md @@ -5,14 +5,14 @@ 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 [swoole](https://www.swoole.co.uk/), which can be linked to external databases to persist data. +It exposes a shlink instance served with [openswoole](https://www.swoole.co.uk/), which can be linked to external databases to persist data. ## Usage The most basic way to run Shlink's docker image is by providing these mandatory env vars. * `DEFAULT_DOMAIN`: The default short domain used for this shlink instance. For example **doma.in**. -* `USE_HTTPS`: Either **true** or **false**. +* `IS_HTTPS_ENABLED`: Either **true** or **false**. Tells if Shlink is being served with HTTPs or not. * `GEOLITE_LICENSE_KEY`: Your GeoLite2 license key. [Learn more](https://shlink.io/documentation/geolite-license-key/) about this. To run shlink on top of a local docker service, and using an internal SQLite database, do the following: @@ -22,7 +22,7 @@ docker run \ --name shlink \ -p 8080:8080 \ -e DEFAULT_DOMAIN=doma.in \ - -e USE_HTTPS=true \ + -e IS_HTTPS_ENABLED=true \ -e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 \ shlinkio/shlink:stable ``` diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 8847b757..8f48e20a 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -1,24 +1,27 @@ #!/usr/bin/env sh set -e +# If SHELL_VERBOSITY was not explicitly provided, run commands in quite mode (-q) +[ $SHELL_VERBOSITY ] && flags="" || flags="-q" + cd /etc/shlink echo "Creating fresh database if needed..." -php bin/cli db:create -n -q +php bin/cli db:create -n ${flags} echo "Updating database..." -php bin/cli db:migrate -n -q +php bin/cli db:migrate -n ${flags} echo "Generating proxies..." -php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies -n -q +php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies -n ${flags} echo "Clearing entities cache..." -php vendor/doctrine/orm/bin/doctrine.php orm:clear-cache:metadata -n -q +php vendor/doctrine/orm/bin/doctrine.php orm:clear-cache:metadata -n ${flags} # Try to download GeoLite2 db file only if the license key env var was defined if [ ! -z "${GEOLITE_LICENSE_KEY}" ]; then echo "Downloading GeoLite2 db file..." - php bin/cli visit:download-db -n -q + php bin/cli visit:download-db -n ${flags} fi # Periodicaly run visit:locate every hour @@ -30,6 +33,6 @@ if [ $ENABLE_PERIODIC_VISIT_LOCATE ]; then /usr/sbin/crond & fi -# When restarting the container, swoole might think it is already in execution +# 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/docs/async-api/async-api.json b/docs/async-api/async-api.json index 0b546377..82da91c5 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -11,7 +11,7 @@ }, "defaultContentType": "application/json", "channels": { - "http://shlink.io/new-visit": { + "https://shlink.io/new-visit": { "subscribe": { "summary": "Receive information about any new visit occurring on any short URL.", "operationId": "newVisit", @@ -31,7 +31,7 @@ } } }, - "http://shlink.io/new-visit/{shortCode}": { + "https://shlink.io/new-visit/{shortCode}": { "parameters": { "shortCode": { "description": "The short code of the short URL", @@ -59,7 +59,7 @@ } } }, - "http://shlink.io/new-orphan-visit": { + "https://shlink.io/new-orphan-visit": { "subscribe": { "summary": "Receive information about any new orphan visit.", "operationId": "newOrphanVisit", diff --git a/docs/swagger/examples/short-url-invalid-args.json b/docs/swagger/examples/short-url-invalid-args.json new file mode 100644 index 00000000..d85a5eed --- /dev/null +++ b/docs/swagger/examples/short-url-invalid-args.json @@ -0,0 +1,9 @@ +{ + "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.json b/docs/swagger/examples/short-url-not-found.json new file mode 100644 index 00000000..74a5661c --- /dev/null +++ b/docs/swagger/examples/short-url-not-found.json @@ -0,0 +1,9 @@ +{ + "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.json b/docs/swagger/examples/tag-not-found.json new file mode 100644 index 00000000..46018121 --- /dev/null +++ b/docs/swagger/examples/tag-not-found.json @@ -0,0 +1,9 @@ +{ + "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/health.json b/docs/swagger/paths/health.json index 60d96ccc..8dc5e7da 100644 --- a/docs/swagger/paths/health.json +++ b/docs/swagger/paths/health.json @@ -13,16 +13,14 @@ "application/json": { "schema": { "$ref": "../definitions/Health.json" - } - } - }, - "examples": { - "application/json": { - "status": "pass", - "version": "1.16.0", - "links": { - "about": "https://shlink.io", - "project": "https://github.com/shlinkio/shlink" + }, + "example": { + "status": "pass", + "version": "2.10.0", + "links": { + "about": "https://shlink.io", + "project": "https://github.com/shlinkio/shlink" + } } } } @@ -33,21 +31,19 @@ "application/json": { "schema": { "$ref": "../definitions/Health.json" - } - } - }, - "examples": { - "application/json": { - "status": "fail", - "version": "1.16.0", - "links": { - "about": "https://shlink.io", - "project": "https://github.com/shlinkio/shlink" + }, + "example": { + "status": "fail", + "version": "2.10.0", + "links": { + "about": "https://shlink.io", + "project": "https://github.com/shlinkio/shlink" + } } } } }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/json": { diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index a4643058..04afdd3a 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -117,79 +117,77 @@ } } } - } - } - }, - "examples": { - "application/json": { - "shortUrls": { - "data": [ - { - "shortCode": "12C18", - "shortUrl": "https://doma.in/12C18", - "longUrl": "https://store.steampowered.com", - "dateCreated": "2016-08-21T20:34:16+02:00", - "visitsCount": 328, - "tags": [ - "games", - "tech" - ], - "meta": { - "validSince": "2017-01-21T00:00:00+02:00", - "validUntil": null, - "maxVisits": 100 + }, + "example": { + "shortUrls": { + "data": [ + { + "shortCode": "12C18", + "shortUrl": "https://doma.in/12C18", + "longUrl": "https://store.steampowered.com", + "dateCreated": "2016-08-21T20:34:16+02:00", + "visitsCount": 328, + "tags": [ + "games", + "tech" + ], + "meta": { + "validSince": "2017-01-21T00:00:00+02:00", + "validUntil": null, + "maxVisits": 100 + }, + "domain": null, + "title": "Welcome to Steam", + "crawlable": false }, - "domain": null, - "title": "Welcome to Steam", - "crawlable": false - }, - { - "shortCode": "12Kb3", - "shortUrl": "https://doma.in/12Kb3", - "longUrl": "https://shlink.io", - "dateCreated": "2016-05-01T20:34:16+02:00", - "visitsCount": 1029, - "tags": [ - "shlink" - ], - "meta": { - "validSince": null, - "validUntil": null, - "maxVisits": null + { + "shortCode": "12Kb3", + "shortUrl": "https://doma.in/12Kb3", + "longUrl": "https://shlink.io", + "dateCreated": "2016-05-01T20:34:16+02:00", + "visitsCount": 1029, + "tags": [ + "shlink" + ], + "meta": { + "validSince": null, + "validUntil": null, + "maxVisits": null + }, + "domain": null, + "title": null, + "crawlable": false }, - "domain": null, - "title": null, - "crawlable": false - }, - { - "shortCode": "123bA", - "shortUrl": "https://example.com/123bA", - "longUrl": "https://www.google.com", - "dateCreated": "2015-10-01T20:34:16+02:00", - "visitsCount": 25, - "tags": [], - "meta": { - "validSince": "2017-01-21T00:00:00+02:00", - "validUntil": null, - "maxVisits": null - }, - "domain": "example.com", - "title": null, - "crawlable": false + { + "shortCode": "123bA", + "shortUrl": "https://example.com/123bA", + "longUrl": "https://www.google.com", + "dateCreated": "2015-10-01T20:34:16+02:00", + "visitsCount": 25, + "tags": [], + "meta": { + "validSince": "2017-01-21T00:00:00+02:00", + "validUntil": null, + "maxVisits": null + }, + "domain": "example.com", + "title": null, + "crawlable": false + } + ], + "pagination": { + "currentPage": 5, + "pagesCount": 12, + "itemsPerPage": 10, + "itemsInCurrentPage": 10, + "totalItems": 115 } - ], - "pagination": { - "currentPage": 5, - "pagesCount": 12, - "itemsPerPage": 10, - "itemsInCurrentPage": 10, - "totalItems": 115 } } } } }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/problem+json": { @@ -267,28 +265,26 @@ "application/json": { "schema": { "$ref": "../definitions/ShortUrl.json" - } - } - }, - "examples": { - "application/json": { - "shortCode": "12C18", - "shortUrl": "https://doma.in/12C18", - "longUrl": "https://store.steampowered.com", - "dateCreated": "2016-08-21T20:34:16+02:00", - "visitsCount": 0, - "tags": [ - "games", - "tech" - ], - "meta": { - "validSince": "2017-01-21T00:00:00+02:00", - "validUntil": null, - "maxVisits": 500 }, - "domain": null, - "title": null, - "crawlable": false + "example": { + "shortCode": "12C18", + "shortUrl": "https://doma.in/12C18", + "longUrl": "https://store.steampowered.com", + "dateCreated": "2016-08-21T20:34:16+02:00", + "visitsCount": 0, + "tags": [ + "games", + "tech" + ], + "meta": { + "validSince": "2017-01-21T00:00:00+02:00", + "validUntil": null, + "maxVisits": 500 + }, + "domain": null, + "title": null, + "crawlable": false + } } } }, @@ -326,15 +322,42 @@ "customSlug": { "type": "string", "description": "Provided custom slug when the error type is INVALID_SLUG" + }, + "domain": { + "type": "string", + "description": "The domain for which you were trying to create the new short URL" } } } ] + }, + "examples": { + "Invalid arguments": { + "$ref": "../examples/short-url-invalid-args.json" + }, + "Invalid long URL": { + "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": { + "value": { + "title": "Invalid custom slug", + "type": "INVALID_SLUG", + "detail": "Provided slug \"my-slug\" is already in use.", + "status": 400, + "customSlug": "my-slug" + } + } } } } }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/problem+json": { diff --git a/docs/swagger/paths/v1_short-urls_shorten.json b/docs/swagger/paths/v1_short-urls_shorten.json index 90c3eda5..722476bb 100644 --- a/docs/swagger/paths/v1_short-urls_shorten.json +++ b/docs/swagger/paths/v1_short-urls_shorten.json @@ -49,35 +49,33 @@ "application/json": { "schema": { "$ref": "../definitions/ShortUrl.json" + }, + "example": { + "longUrl": "https://github.com/shlinkio/shlink", + "shortUrl": "https://doma.in/abc123", + "shortCode": "abc123", + "dateCreated": "2016-08-21T20:34:16+02:00", + "visitsCount": 0, + "tags": [ + "games", + "tech" + ], + "meta": { + "validSince": "2017-01-21T00:00:00+02:00", + "validUntil": null, + "maxVisits": 100 + }, + "domain": null, + "title": null, + "crawlable": false } }, "text/plain": { "schema": { "type": "string" - } - } - }, - "examples": { - "application/json": { - "longUrl": "https://github.com/shlinkio/shlink", - "shortUrl": "https://doma.in/abc123", - "shortCode": "abc123", - "dateCreated": "2016-08-21T20:34:16+02:00", - "visitsCount": 0, - "tags": [ - "games", - "tech" - ], - "meta": { - "validSince": "2017-01-21T00:00:00+02:00", - "validUntil": null, - "maxVisits": 100 }, - "domain": null, - "title": null, - "crawlable": false - }, - "text/plain": "https://doma.in/abc123" + "example": "https://doma.in/abc123" + } } }, "400": { @@ -86,26 +84,24 @@ "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" + }, + "example": { + "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" - } + }, + "example": "INVALID_URL" } - }, - "examples": { - "application/problem+json": { - "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": "INVALID_URL" } }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/problem+json": { @@ -118,13 +114,6 @@ "type": "string" } } - }, - "examples": { - "application/problem+json": { - "error": "INTERNAL_SERVER_ERROR", - "message": "Unexpected error occurred" - }, - "text/plain": "INTERNAL_SERVER_ERROR" } } } diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index e37df965..eec1cec3 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -35,27 +35,25 @@ "application/json": { "schema": { "$ref": "../definitions/ShortUrl.json" - } - } - }, - "examples": { - "application/json": { - "shortCode": "12Kb3", - "shortUrl": "https://doma.in/12Kb3", - "longUrl": "https://shlink.io", - "dateCreated": "2016-05-01T20:34:16+02:00", - "visitsCount": 1029, - "tags": [ - "shlink" - ], - "meta": { - "validSince": "2017-01-21T00:00:00+02:00", - "validUntil": null, - "maxVisits": 100 }, - "domain": null, - "title": null, - "crawlable": false + "example": { + "shortCode": "12Kb3", + "shortUrl": "https://doma.in/12Kb3", + "longUrl": "https://shlink.io", + "dateCreated": "2016-05-01T20:34:16+02:00", + "visitsCount": 1029, + "tags": [ + "shlink" + ], + "meta": { + "validSince": "2017-01-21T00:00:00+02:00", + "validUntil": null, + "maxVisits": 100 + }, + "domain": null, + "title": null, + "crawlable": false + } } } }, @@ -64,12 +62,35 @@ "content": { "application/problem+json": { "schema": { - "$ref": "../definitions/Error.json" + "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": { + "Not found": { + "$ref": "../examples/short-url-not-found.json" + } } } } }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/problem+json": { @@ -129,27 +150,25 @@ "application/json": { "schema": { "$ref": "../definitions/ShortUrl.json" - } - } - }, - "examples": { - "application/json": { - "shortCode": "12Kb3", - "shortUrl": "https://doma.in/12Kb3", - "longUrl": "https://shlink.io", - "dateCreated": "2016-05-01T20:34:16+02:00", - "visitsCount": 1029, - "tags": [ - "shlink" - ], - "meta": { - "validSince": "2017-01-21T00:00:00+02:00", - "validUntil": null, - "maxVisits": 100 }, - "domain": null, - "title": "Shlink - The URL shortener", - "crawlable": false + "example": { + "shortCode": "12Kb3", + "shortUrl": "https://doma.in/12Kb3", + "longUrl": "https://shlink.io", + "dateCreated": "2016-05-01T20:34:16+02:00", + "visitsCount": 1029, + "tags": [ + "shlink" + ], + "meta": { + "validSince": "2017-01-21T00:00:00+02:00", + "validUntil": null, + "maxVisits": 100 + }, + "domain": null, + "title": "Shlink - The URL shortener", + "crawlable": false + } } } }, @@ -182,21 +201,49 @@ } } ] + }, + "examples": { + "Invalid arguments": { + "$ref": "../examples/short-url-invalid-args.json" + } } } } }, "404": { - "description": "No short URL was found for provided short code.", + "description": "No URL was found for provided short code.", "content": { "application/problem+json": { "schema": { - "$ref": "../definitions/Error.json" + "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": { + "Not found": { + "$ref": "../examples/short-url-not-found.json" + } } } } }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/problem+json": { @@ -247,30 +294,75 @@ "content": { "application/problem+json": { "schema": { - "$ref": "../definitions/Error.json" + "allOf": [ + { + "$ref": "../definitions/Error.json" + }, + { + "type": "object", + "required": ["shortCode", "threshold"], + "properties": { + "shortCode": { + "type": "string", + "description": "The short code with which we tried to find the short URL to delete" + }, + "domain": { + "type": "string", + "description": "The domain with which we tried to find the short URL to delete" + }, + "threshold": { + "type": "number", + "description": "The amount of visits currently configured as threshold to allow deleting short UYRLs or not" + } + } + } + ] + }, + "example": { + "title": "Cannot delete short URL", + "type": "INVALID_SHORTCODE_DELETION", + "detail": "Impossible to delete short URL with short code \"abc123\", since it has more than \"15\" visits.", + "status": 422, + "shortCode": "abc123", + "threshold": 15 } } - }, - "examples": { - "application/problem+json": { - "title": "Cannot delete short URL", - "type": "INVALID_SHORTCODE_DELETION", - "detail": "It is not possible to delete URL with short code \"abc123\" because it has reached more than \"15\" visits.", - "status": 422 - } } }, "404": { - "description": "No short URL was found for provided short code.", + "description": "No URL was found for provided short code.", "content": { "application/problem+json": { "schema": { - "$ref": "../definitions/Error.json" + "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": { + "Not found": { + "$ref": "../examples/short-url-not-found.json" + } } } } }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/problem+json": { diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json b/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json index 6ea642b0..645c6ef2 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json @@ -69,14 +69,6 @@ } } } - }, - "examples": { - "application/json": { - "tags": [ - "games", - "tech" - ] - } } }, "400": { @@ -99,7 +91,7 @@ } } }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/json": { diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json b/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json index e5bbbe86..08a93b68 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json @@ -97,49 +97,47 @@ } } } - } - } - }, - "examples": { - "application/json": { - "visits": { - "data": [ - { - "referer": "https://twitter.com", - "date": "2015-08-20T05:05:03+04:00", - "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0", - "visitLocation": null, - "potentialBot": false - }, - { - "referer": "https://t.co", - "date": "2015-08-20T05:05:03+04:00", - "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", - "visitLocation": { - "cityName": "Cupertino", - "countryCode": "US", - "countryName": "United States", - "latitude": 37.3042, - "longitude": -122.0946, - "regionName": "California", - "timezone": "America/Los_Angeles" + }, + "example": { + "visits": { + "data": [ + { + "referer": "https://twitter.com", + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0", + "visitLocation": null, + "potentialBot": false }, - "potentialBot": false - }, - { - "referer": null, - "date": "2015-08-20T05:05:03+04:00", - "userAgent": "some_web_crawler/1.4", - "visitLocation": null, - "potentialBot": true + { + "referer": "https://t.co", + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", + "visitLocation": { + "cityName": "Cupertino", + "countryCode": "US", + "countryName": "United States", + "latitude": 37.3042, + "longitude": -122.0946, + "regionName": "California", + "timezone": "America/Los_Angeles" + }, + "potentialBot": false + }, + { + "referer": null, + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "some_web_crawler/1.4", + "visitLocation": null, + "potentialBot": true + } + ], + "pagination": { + "currentPage": 5, + "pagesCount": 12, + "itemsPerPage": 10, + "itemsInCurrentPage": 10, + "totalItems": 115 } - ], - "pagination": { - "currentPage": 5, - "pagesCount": 12, - "itemsPerPage": 10, - "itemsInCurrentPage": 10, - "totalItems": 115 } } } @@ -151,11 +149,16 @@ "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" + }, + "examples": { + "Short URL not found": { + "$ref": "../examples/short-url-not-found.json" + } } } } }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/problem+json": { diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json index 8c3ada73..12cdef81 100644 --- a/docs/swagger/paths/v1_tags.json +++ b/docs/swagger/paths/v1_tags.json @@ -57,23 +57,47 @@ } } } - } - } - }, - "examples": { - "application/json": { - "tags": { - "data": [ - "games", - "php", - "shlink", - "tech" - ] + }, + "examples": { + "Without stats": { + "value": { + "tags": { + "data": [ + "games", + "php", + "shlink", + "tech" + ] + } + } + }, + "With stats": { + "value": { + "tags": { + "data": [ + "games", + "shlink" + ], + "stats": [ + { + "tag": "games", + "shortUrlsCount": 10, + "visitsCount": 521 + }, + { + "tag": "shlink", + "shortUrlsCount": 7, + "visitsCount": 1087 + } + ] + } + } + } } } } }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/problem+json": { @@ -149,21 +173,9 @@ } } } - }, - "examples": { - "application/json": { - "tags": { - "data": [ - "games", - "php", - "shlink", - "tech" - ] - } - } } }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/problem+json": { @@ -228,6 +240,13 @@ "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" + }, + "example": { + "title": "Invalid data", + "type": "INVALID_ARGUMENT", + "detail": "Provided data is not valid", + "status": 400, + "invalidElements": ["oldName", "newName"] } } } @@ -238,6 +257,12 @@ "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" + }, + "example": { + "detail": "You are not allowed to rename tags", + "title": "Forbidden tag operation", + "type": "FORBIDDEN_OPERATION", + "status": 403 } } } @@ -248,6 +273,11 @@ "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" + }, + "examples": { + "Tag not found": { + "$ref": "../examples/tag-not-found.json" + } } } } @@ -258,11 +288,19 @@ "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" + }, + "example": { + "detail": "You cannot rename tag foo, because it already exists", + "title": "Tag conflict", + "type": "TAG_CONFLICT", + "status": 409, + "oldName": "bar", + "newName": "foo" } } } }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/problem+json": { @@ -314,11 +352,17 @@ "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" + }, + "example": { + "detail": "You are not allowed to delete tags", + "title": "Forbidden tag operation", + "type": "FORBIDDEN_OPERATION", + "status": 403 } } } }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/problem+json": { diff --git a/docs/swagger/paths/v2_domains.json b/docs/swagger/paths/v2_domains.json index ef63ee4e..7568c64a 100644 --- a/docs/swagger/paths/v2_domains.json +++ b/docs/swagger/paths/v2_domains.json @@ -4,8 +4,8 @@ "tags": [ "Domains" ], - "summary": "List existing domains", - "description": "Returns the list of all domains ever used, with a flag that tells if they are the default domain", + "summary": "List configured domains", + "description": "Returns the list of all domains that have been either used for some short URL, or have explicitly configured redirects.
It also includes the domain redirects, plus the default redirects that will be used for any non-explicitly-configured one.", "security": [ { "ApiKey": [] @@ -46,50 +46,56 @@ } } } + }, + "defaultRedirects": { + "$ref": "../definitions/NotFoundRedirects.json" } } } } - } - } - }, - "examples": { - "application/json": { - "domains": { - "data": [ - { - "domain": "example.com", - "isDefault": true, - "redirects": { - "baseUrlRedirect": "https://example.com/my-landing-page", - "regular404Redirect": null, - "invalidShortUrlRedirect": "https://example.com/invalid-url" - } - }, - { - "domain": "aaa.com", - "isDefault": false, - "redirects": { - "baseUrlRedirect": null, - "regular404Redirect": null, - "invalidShortUrlRedirect": null - } - }, - { - "domain": "bbb.com", - "isDefault": false, - "redirects": { - "baseUrlRedirect": null, - "regular404Redirect": null, - "invalidShortUrlRedirect": "https://example.com/invalid-url" + }, + "example": { + "domains": { + "data": [ + { + "domain": "example.com", + "isDefault": true, + "redirects": { + "baseUrlRedirect": "https://example.com/my-landing-page", + "regular404Redirect": null, + "invalidShortUrlRedirect": "https://example.com/invalid-url" + } + }, + { + "domain": "aaa.com", + "isDefault": false, + "redirects": { + "baseUrlRedirect": null, + "regular404Redirect": null, + "invalidShortUrlRedirect": null + } + }, + { + "domain": "bbb.com", + "isDefault": false, + "redirects": { + "baseUrlRedirect": null, + "regular404Redirect": null, + "invalidShortUrlRedirect": "https://example.com/invalid-url" + } } + ], + "defaultRedirects": { + "baseUrlRedirect": "https://somewhere.com", + "regular404Redirect": null, + "invalidShortUrlRedirect": null } - ] + } } } } }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/problem+json": { diff --git a/docs/swagger/paths/v2_domains_redirects.json b/docs/swagger/paths/v2_domains_redirects.json index d9863dcd..d4d4338c 100644 --- a/docs/swagger/paths/v2_domains_redirects.json +++ b/docs/swagger/paths/v2_domains_redirects.json @@ -55,15 +55,13 @@ "$ref": "../definitions/NotFoundRedirects.json" } ] + }, + "example": { + "baseUrlRedirect": "https://example.com/my-landing-page", + "regular404Redirect": null, + "invalidShortUrlRedirect": "https://example.com/invalid-url" } } - }, - "examples": { - "application/json": { - "baseUrlRedirect": "https://example.com/my-landing-page", - "regular404Redirect": null, - "invalidShortUrlRedirect": "https://example.com/invalid-url" - } } }, "400": { @@ -95,21 +93,18 @@ } } ] + }, + "example": { + "title": "Invalid data", + "type": "INVALID_ARGUMENT", + "detail": "Provided data is not valid", + "status": 400, + "invalidElements": ["domain", "invalidShortUrlRedirect"] } } } }, - "403": { - "description": "Default domain was provided, and it cannot be edited this way.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "../definitions/Error.json" - } - } - } - }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/problem+json": { diff --git a/docs/swagger/paths/v2_mercure-info.json b/docs/swagger/paths/v2_mercure-info.json index 24f7fb5f..a341573f 100644 --- a/docs/swagger/paths/v2_mercure-info.json +++ b/docs/swagger/paths/v2_mercure-info.json @@ -23,15 +23,13 @@ "application/json": { "schema": { "$ref": "../definitions/MercureInfo.json" + }, + "example": { + "mercureHubUrl": "https://example.com/.well-known/mercure", + "jwt": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTaGxpbmsiLCJpYXQiOjE1ODY2ODY3MzIsImV4cCI6MTU4Njk0NTkzMiwibWVyY3VyZSI6eyJzdWJzY3JpYmUiOltdfX0.P-519lgU7dFz0bbNlRG1CXyqugGbaHon4kw6fu4QBdQ", + "jwtExpiration": "2020-04-15T12:18:52+02:00" } } - }, - "examples": { - "application/json": { - "mercureHubUrl": "https://example.com/.well-known/mercure", - "jwt": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTaGxpbmsiLCJpYXQiOjE1ODY2ODY3MzIsImV4cCI6MTU4Njk0NTkzMiwibWVyY3VyZSI6eyJzdWJzY3JpYmUiOltdfX0.P-519lgU7dFz0bbNlRG1CXyqugGbaHon4kw6fu4QBdQ", - "jwtExpiration": "2020-04-15T12:18:52+02:00" - } } }, "501": { @@ -40,19 +38,17 @@ "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" + }, + "example": { + "title": "Mercure integration not configured", + "type": "MERCURE_NOT_CONFIGURED", + "detail": "This Shlink instance is not integrated with a mercure hub.", + "status": 501 } } - }, - "examples": { - "application/json": { - "title": "Mercure integration not configured", - "type": "MERCURE_NOT_CONFIGURED", - "detail": "This Shlink instance is not integrated with a mercure hub.", - "status": 501 - } } }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/problem+json": { diff --git a/docs/swagger/paths/v2_tags_{tag}_visits.json b/docs/swagger/paths/v2_tags_{tag}_visits.json index df1242f6..109cb1d0 100644 --- a/docs/swagger/paths/v2_tags_{tag}_visits.json +++ b/docs/swagger/paths/v2_tags_{tag}_visits.json @@ -94,49 +94,47 @@ } } } - } - } - }, - "examples": { - "application/json": { - "visits": { - "data": [ - { - "referer": "https://twitter.com", - "date": "2015-08-20T05:05:03+04:00", - "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0", - "visitLocation": null, - "potentialBot": false - }, - { - "referer": "https://t.co", - "date": "2015-08-20T05:05:03+04:00", - "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", - "visitLocation": { - "cityName": "Cupertino", - "countryCode": "US", - "countryName": "United States", - "latitude": 37.3042, - "longitude": -122.0946, - "regionName": "California", - "timezone": "America/Los_Angeles" + }, + "example": { + "visits": { + "data": [ + { + "referer": "https://twitter.com", + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0", + "visitLocation": null, + "potentialBot": false }, - "potentialBot": false - }, - { - "referer": null, - "date": "2015-08-20T05:05:03+04:00", - "userAgent": "some_web_crawler/1.4", - "visitLocation": null, - "potentialBot": true + { + "referer": "https://t.co", + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", + "visitLocation": { + "cityName": "Cupertino", + "countryCode": "US", + "countryName": "United States", + "latitude": 37.3042, + "longitude": -122.0946, + "regionName": "California", + "timezone": "America/Los_Angeles" + }, + "potentialBot": false + }, + { + "referer": null, + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "some_web_crawler/1.4", + "visitLocation": null, + "potentialBot": true + } + ], + "pagination": { + "currentPage": 5, + "pagesCount": 12, + "itemsPerPage": 10, + "itemsInCurrentPage": 10, + "totalItems": 115 } - ], - "pagination": { - "currentPage": 5, - "pagesCount": 12, - "itemsPerPage": 10, - "itemsInCurrentPage": 10, - "totalItems": 115 } } } @@ -148,11 +146,16 @@ "application/problem+json": { "schema": { "$ref": "../definitions/Error.json" + }, + "examples": { + "Tag not found": { + "$ref": "../examples/tag-not-found.json" + } } } } }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/problem+json": { diff --git a/docs/swagger/paths/v2_visits.json b/docs/swagger/paths/v2_visits.json index 3c712b1f..ded6ac6b 100644 --- a/docs/swagger/paths/v2_visits.json +++ b/docs/swagger/paths/v2_visits.json @@ -28,19 +28,17 @@ "$ref": "../definitions/VisitStats.json" } } - } - } - }, - "examples": { - "application/json": { - "visits": { - "visitsCount": 1569874, - "orphanVisitsCount": 71345 + }, + "example": { + "visits": { + "visitsCount": 1569874, + "orphanVisitsCount": 71345 + } } } } }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/problem+json": { diff --git a/docs/swagger/paths/v2_visits_orphan.json b/docs/swagger/paths/v2_visits_orphan.json index ce52b197..03d56553 100644 --- a/docs/swagger/paths/v2_visits_orphan.json +++ b/docs/swagger/paths/v2_visits_orphan.json @@ -85,61 +85,59 @@ } } } - } - } - }, - "examples": { - "application/json": { - "visits": { - "data": [ - { - "referer": "https://twitter.com", - "date": "2015-08-20T05:05:03+04:00", - "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0", - "visitLocation": null, - "potentialBot": false, - "visitedUrl": "https://doma.in", - "type": "base_url" - }, - { - "referer": "https://t.co", - "date": "2015-08-20T05:05:03+04:00", - "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", - "visitLocation": { - "cityName": "Cupertino", - "countryCode": "US", - "countryName": "United States", - "latitude": 37.3042, - "longitude": -122.0946, - "regionName": "California", - "timezone": "America/Los_Angeles" + }, + "example": { + "visits": { + "data": [ + { + "referer": "https://twitter.com", + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0", + "visitLocation": null, + "potentialBot": false, + "visitedUrl": "https://doma.in", + "type": "base_url" }, - "potentialBot": false, - "visitedUrl": "https://doma.in/foo", - "type": "invalid_short_url" - }, - { - "referer": null, - "date": "2015-08-20T05:05:03+04:00", - "userAgent": "some_web_crawler/1.4", - "visitLocation": null, - "potentialBot": true, - "visitedUrl": "https://doma.in/foo/bar/baz", - "type": "regular_404" + { + "referer": "https://t.co", + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", + "visitLocation": { + "cityName": "Cupertino", + "countryCode": "US", + "countryName": "United States", + "latitude": 37.3042, + "longitude": -122.0946, + "regionName": "California", + "timezone": "America/Los_Angeles" + }, + "potentialBot": false, + "visitedUrl": "https://doma.in/foo", + "type": "invalid_short_url" + }, + { + "referer": null, + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "some_web_crawler/1.4", + "visitLocation": null, + "potentialBot": true, + "visitedUrl": "https://doma.in/foo/bar/baz", + "type": "regular_404" + } + ], + "pagination": { + "currentPage": 5, + "pagesCount": 12, + "itemsPerPage": 10, + "itemsInCurrentPage": 10, + "totalItems": 115 } - ], - "pagination": { - "currentPage": 5, - "pagesCount": 12, - "itemsPerPage": 10, - "itemsInCurrentPage": 10, - "totalItems": 115 } } } } }, - "500": { + "default": { "description": "Unexpected error.", "content": { "application/problem+json": { diff --git a/docs/swagger/paths/{shortCode}_qr-code.json b/docs/swagger/paths/{shortCode}_qr-code.json index 04a88fd7..104860eb 100644 --- a/docs/swagger/paths/{shortCode}_qr-code.json +++ b/docs/swagger/paths/{shortCode}_qr-code.json @@ -60,6 +60,17 @@ "enum": ["L", "M", "Q", "H"], "default": "L" } + }, + { + "name": "roundBlockSize", + "in": "query", + "description": "Allows to disable block size rounding, which might reduce the readability of the QR code, but ensures no extra margin is added.", + "required": false, + "schema": { + "type": "string", + "enum": ["true", "false"], + "default": "false" + } } ], "responses": { diff --git a/docs/swagger/paths/{shortCode}_qr-code_{size}.json b/docs/swagger/paths/{shortCode}_qr-code_{size}.json index fb5dd33e..54c5152e 100644 --- a/docs/swagger/paths/{shortCode}_qr-code_{size}.json +++ b/docs/swagger/paths/{shortCode}_qr-code_{size}.json @@ -21,7 +21,7 @@ "name": "size", "in": "path", "description": "The size of the image to be returned.", - "required": false, + "required": true, "schema": { "type": "integer", "minimum": 50, diff --git a/infection-api.json b/infection-api.json new file mode 100644 index 00000000..398cd653 --- /dev/null +++ b/infection-api.json @@ -0,0 +1,23 @@ +{ + "source": { + "directories": [ + "module/*/src" + ] + }, + "timeout": 5, + "logs": { + "text": "build/infection-api/infection-log.txt", + "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.json b/infection.json index b182bddf..1b4ed6b5 100644 --- a/infection.json +++ b/infection.json @@ -8,7 +8,10 @@ "logs": { "text": "build/infection-unit/infection-log.txt", "summary": "build/infection-unit/summary-log.txt", - "debug": "build/infection-unit/debug-log.txt" + "debug": "build/infection-unit/debug-log.txt", + "badge": { + "branch": "develop" + } }, "tmpDir": "build/infection-unit/temp", "phpUnit": { diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 46bb90ef..e06ad727 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -8,7 +8,7 @@ return [ 'cli' => [ 'commands' => [ - Command\ShortUrl\GenerateShortUrlCommand::NAME => Command\ShortUrl\GenerateShortUrlCommand::class, + Command\ShortUrl\CreateShortUrlCommand::NAME => Command\ShortUrl\CreateShortUrlCommand::class, Command\ShortUrl\ResolveUrlCommand::NAME => Command\ShortUrl\ResolveUrlCommand::class, Command\ShortUrl\ListShortUrlsCommand::NAME => Command\ShortUrl\ListShortUrlsCommand::class, Command\ShortUrl\GetVisitsCommand::NAME => Command\ShortUrl\GetVisitsCommand::class, diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index d89a8af2..41d415dc 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -39,7 +39,7 @@ return [ ApiKey\RoleResolver::class => ConfigAbstractFactory::class, - Command\ShortUrl\GenerateShortUrlCommand::class => ConfigAbstractFactory::class, + Command\ShortUrl\CreateShortUrlCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\ListShortUrlsCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\GetVisitsCommand::class => ConfigAbstractFactory::class, @@ -75,10 +75,11 @@ return [ Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class], ApiKey\RoleResolver::class => [DomainService::class], - Command\ShortUrl\GenerateShortUrlCommand::class => [ + Command\ShortUrl\CreateShortUrlCommand::class => [ Service\UrlShortener::class, ShortUrlStringifier::class, 'config.url_shortener.default_short_codes_length', + 'config.url_shortener.domain.hostname', ], Command\ShortUrl\ResolveUrlCommand::class => [Service\ShortUrl\ShortUrlResolver::class], Command\ShortUrl\ListShortUrlsCommand::class => [ diff --git a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php similarity index 89% rename from module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php rename to module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index e43b4ec5..62b50456 100644 --- a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -26,14 +26,17 @@ use function method_exists; use function sprintf; use function str_contains; -class GenerateShortUrlCommand extends BaseCommand +class CreateShortUrlCommand extends BaseCommand { - public const NAME = 'short-url:generate'; + public const NAME = 'short-url:create'; + + private ?SymfonyStyle $io; public function __construct( private UrlShortenerInterface $urlShortener, private ShortUrlStringifierInterface $stringifier, private int $defaultShortCodeLength, + private string $defaultDomain, ) { parent::__construct(); } @@ -42,6 +45,7 @@ class GenerateShortUrlCommand extends BaseCommand { $this ->setName(self::NAME) + ->setAliases(['short-url:generate']) // Deprecated ->setDescription('Generates a short URL for provided long URL and returns it') ->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to parse') ->addOption( @@ -122,21 +126,33 @@ class GenerateShortUrlCommand extends BaseCommand protected function interact(InputInterface $input, OutputInterface $output): void { - $io = new SymfonyStyle($input, $output); + $this->verifyLongUrlArgument($input, $output); + $this->verifyDomainArgument($input); + } + + private function verifyLongUrlArgument(InputInterface $input, OutputInterface $output): void + { $longUrl = $input->getArgument('longUrl'); if (! empty($longUrl)) { return; } + $io = $this->getIO($input, $output); $longUrl = $io->ask('Which URL do you want to shorten?'); if (! empty($longUrl)) { $input->setArgument('longUrl', $longUrl); } } + private function verifyDomainArgument(InputInterface $input): void + { + $domain = $input->getOption('domain'); + $input->setOption('domain', $domain === $this->defaultDomain ? null : $domain); + } + protected function execute(InputInterface $input, OutputInterface $output): ?int { - $io = new SymfonyStyle($input, $output); + $io = $this->getIO($input, $output); $longUrl = $input->getArgument('longUrl'); if (empty($longUrl)) { $io->error('A URL was not provided!'); @@ -196,4 +212,9 @@ class GenerateShortUrlCommand extends BaseCommand return null; } + + private function getIO(InputInterface $input, OutputInterface $output): SymfonyStyle + { + return $this->io ?? ($this->io = new SymfonyStyle($input, $output)); + } } diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 53e47d3c..cbc6e3ee 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -131,7 +131,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand ]; if ($all) { - $data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = -1; + $data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = Paginator::ALL_ITEMS; } do { diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index 61d4e6e0..9eebe36f 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -45,7 +45,8 @@ class ListTagsCommand extends Command return map( $tags, - fn (TagInfo $tagInfo) => [(string) $tagInfo->tag(), $tagInfo->shortUrlsCount(), $tagInfo->visitsCount()], + static fn (TagInfo $tagInfo) => + [$tagInfo->tag()->__toString(), $tagInfo->shortUrlsCount(), $tagInfo->visitsCount()], ); } } diff --git a/module/CLI/src/Util/ProcessRunner.php b/module/CLI/src/Util/ProcessRunner.php index 66e94eb6..1a5471e5 100644 --- a/module/CLI/src/Util/ProcessRunner.php +++ b/module/CLI/src/Util/ProcessRunner.php @@ -34,7 +34,7 @@ class ProcessRunner implements ProcessRunnerInterface } /** @var DebugFormatterHelper $formatter */ - $formatter = $this->helper->getHelperSet()->get('debug_formatter'); + $formatter = $this->helper->getHelperSet()?->get('debug_formatter') ?? new DebugFormatterHelper(); /** @var Process $process */ $process = ($this->createProcess)($cmd); diff --git a/module/CLI/test/Command/Api/ListKeysCommandTest.php b/module/CLI/test/Command/Api/ListKeysCommandTest.php index a124993f..68c1e844 100644 --- a/module/CLI/test/Command/Api/ListKeysCommandTest.php +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Api; +use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand; @@ -45,19 +46,25 @@ class ListKeysCommandTest extends TestCase public function provideKeysAndOutputs(): iterable { + $dateInThePast = Chronos::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'); + yield 'all keys' => [ - [$apiKey1 = ApiKey::create(), $apiKey2 = ApiKey::create(), $apiKey3 = ApiKey::create()], + [ + $apiKey1 = ApiKey::create()->disable(), + $apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withExpirationDate($dateInThePast)), + $apiKey3 = ApiKey::create(), + ], false, <<domainService->listDomains()->willReturn([ DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()), - DomainItem::forExistingDomain(Domain::withAuthority('existing-one.com')), - DomainItem::forExistingDomain(Domain::withAuthority($domainAuthority)), + DomainItem::forNonDefaultDomain(Domain::withAuthority('existing-one.com')), + DomainItem::forNonDefaultDomain(Domain::withAuthority($domainAuthority)), ]); $findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain); $configureRedirects = $this->domainService->configureNotFoundRedirects( @@ -156,8 +156,8 @@ class DomainRedirectsCommandTest extends TestCase $listDomains = $this->domainService->listDomains()->willReturn([ DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()), - DomainItem::forExistingDomain(Domain::withAuthority('existing-one.com')), - DomainItem::forExistingDomain(Domain::withAuthority('existing-two.com')), + DomainItem::forNonDefaultDomain(Domain::withAuthority('existing-one.com')), + DomainItem::forNonDefaultDomain(Domain::withAuthority('existing-two.com')), ]); $findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain); $configureRedirects = $this->domainService->configureNotFoundRedirects( diff --git a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php index 13e6d062..6d56ea69 100644 --- a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php +++ b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php @@ -47,8 +47,8 @@ class ListDomainsCommandTest extends TestCase 'base_url' => 'https://foo.com/default/base', 'invalid_short_url' => 'https://foo.com/default/invalid', ])), - DomainItem::forExistingDomain(Domain::withAuthority('bar.com')), - DomainItem::forExistingDomain($bazDomain), + DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com')), + DomainItem::forNonDefaultDomain($bazDomain), ]); $this->commandTester->execute($input); diff --git a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php similarity index 79% rename from module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php rename to module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index 19767dc7..08389d61 100644 --- a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -8,7 +8,7 @@ use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\CLI\Command\ShortUrl\GenerateShortUrlCommand; +use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; @@ -19,10 +19,12 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; -class GenerateShortUrlCommandTest extends TestCase +class CreateShortUrlCommandTest extends TestCase { use CliTestUtilsTrait; + private const DEFAULT_DOMAIN = 'default.com'; + private CommandTester $commandTester; private ObjectProphecy $urlShortener; private ObjectProphecy $stringifier; @@ -33,7 +35,12 @@ class GenerateShortUrlCommandTest extends TestCase $this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class); $this->stringifier->stringify(Argument::type(ShortUrl::class))->willReturn(''); - $command = new GenerateShortUrlCommand($this->urlShortener->reveal(), $this->stringifier->reveal(), 5); + $command = new CreateShortUrlCommand( + $this->urlShortener->reveal(), + $this->stringifier->reveal(), + 5, + self::DEFAULT_DOMAIN, + ); $this->commandTester = $this->testerForCommand($command); } @@ -110,6 +117,34 @@ class GenerateShortUrlCommandTest extends TestCase $stringify->shouldHaveBeenCalledOnce(); } + /** + * @test + * @dataProvider provideDomains + */ + public function properlyProcessesProvidedDomain(array $input, ?string $expectedDomain): void + { + $shorten = $this->urlShortener->shorten( + Argument::that(function (ShortUrlMeta $meta) use ($expectedDomain) { + Assert::assertEquals($expectedDomain, $meta->getDomain()); + return true; + }), + )->willReturn(ShortUrl::createEmpty()); + + $input['longUrl'] = 'http://domain.com/foo/bar'; + $this->commandTester->execute($input); + + self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); + $shorten->shouldHaveBeenCalledOnce(); + } + + public function provideDomains(): iterable + { + yield 'no domain' => [[], null]; + yield 'non-default domain foo' => [['--domain' => 'foo.com'], 'foo.com']; + yield 'non-default domain bar' => [['-d' => 'bar.com'], 'bar.com']; + yield 'default domain' => [['--domain' => self::DEFAULT_DOMAIN], null]; + } + /** * @test * @dataProvider provideFlags diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php index 765a1c4b..10a363c7 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php @@ -83,7 +83,10 @@ class DeleteShortUrlCommandTest extends TestCase $ignoreThreshold = array_pop($args); if (!$ignoreThreshold) { - throw Exception\DeleteShortUrlException::fromVisitsThreshold(10, $shortCode); + throw Exception\DeleteShortUrlException::fromVisitsThreshold( + 10, + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + ); } }, ); @@ -93,7 +96,7 @@ class DeleteShortUrlCommandTest extends TestCase $output = $this->commandTester->getDisplay(); self::assertStringContainsString(sprintf( - 'Impossible to delete short URL with short code "%s" since it has more than "10" visits.', + 'Impossible to delete short URL with short code "%s", since it has more than "10" visits.', $shortCode, ), $output); self::assertStringContainsString($expectedMessage, $output); @@ -112,7 +115,10 @@ class DeleteShortUrlCommandTest extends TestCase { $shortCode = 'abc123'; $deleteByShortCode = $this->service->deleteByShortCode(new ShortUrlIdentifier($shortCode), false)->willThrow( - Exception\DeleteShortUrlException::fromVisitsThreshold(10, $shortCode), + Exception\DeleteShortUrlException::fromVisitsThreshold( + 10, + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + ), ); $this->commandTester->setInputs(['no']); @@ -120,7 +126,7 @@ class DeleteShortUrlCommandTest extends TestCase $output = $this->commandTester->getDisplay(); self::assertStringContainsString(sprintf( - 'Impossible to delete short URL with short code "%s" since it has more than "10" visits.', + 'Impossible to delete short URL with short code "%s", since it has more than "10" visits.', $shortCode, ), $output); self::assertStringContainsString('Short URL was not deleted.', $output); diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 8150d0c8..4a974d73 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -271,7 +271,7 @@ class ListShortUrlsCommandTest extends TestCase 'startDate' => null, 'endDate' => null, 'orderBy' => null, - 'itemsPerPage' => -1, + 'itemsPerPage' => Paginator::ALL_ITEMS, ]))->willReturn(new Paginator(new ArrayAdapter([]))); $this->commandTester->execute(['--all' => true]); diff --git a/module/CLI/test/Command/Tag/ListTagsCommandTest.php b/module/CLI/test/Command/Tag/ListTagsCommandTest.php index 9ec42e54..f79aa03d 100644 --- a/module/CLI/test/Command/Tag/ListTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/ListTagsCommandTest.php @@ -49,12 +49,18 @@ class ListTagsCommandTest extends TestCase $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); - self::assertStringContainsString('| foo', $output); - self::assertStringContainsString('| bar', $output); - self::assertStringContainsString('| 10 ', $output); - self::assertStringContainsString('| 2 ', $output); - self::assertStringContainsString('| 7 ', $output); - self::assertStringContainsString('| 32 ', $output); + self::assertEquals( + <<shouldHaveBeenCalled(); } } diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 16b84819..fdfecef9 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -119,11 +119,7 @@ return [ ], Service\ShortUrl\ShortUrlResolver::class => ['em'], Service\ShortUrl\ShortCodeHelper::class => ['em'], - Domain\DomainService::class => [ - 'em', - 'config.url_shortener.domain.hostname', - Options\NotFoundRedirectOptions::class, - ], + Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'], Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class], Util\DoctrineBatchHelper::class => ['em'], diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index 5256bc92..d47cc128 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; +use PhpAmqpLib\Connection\AMQPStreamConnection; use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; @@ -22,6 +23,7 @@ return [ 'async' => [ EventDispatcher\Event\VisitLocated::class => [ EventDispatcher\NotifyVisitToMercure::class, + EventDispatcher\NotifyVisitToRabbitMq::class, EventDispatcher\NotifyVisitToWebHooks::class, EventDispatcher\UpdateGeoLiteDb::class, ], @@ -33,6 +35,7 @@ return [ EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class, + EventDispatcher\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class, EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class, ], @@ -40,6 +43,9 @@ return [ EventDispatcher\NotifyVisitToMercure::class => [ EventDispatcher\CloseDbConnectionEventListenerDelegator::class, ], + EventDispatcher\NotifyVisitToRabbitMq::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], EventDispatcher\NotifyVisitToWebHooks::class => [ EventDispatcher\CloseDbConnectionEventListenerDelegator::class, ], @@ -68,6 +74,13 @@ return [ 'em', 'Logger_Shlink', ], + EventDispatcher\NotifyVisitToRabbitMq::class => [ + AMQPStreamConnection::class, + 'em', + 'Logger_Shlink', + Visit\Transformer\OrphanVisitDataTransformer::class, + 'config.rabbitmq.enabled', + ], EventDispatcher\UpdateGeoLiteDb::class => [GeolocationDbUpdater::class, 'Logger_Shlink'], ], diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php index 0e889c32..03643e4c 100644 --- a/module/Core/src/Action/Model/QrCodeParams.php +++ b/module/Core/src/Action/Model/QrCodeParams.php @@ -9,6 +9,9 @@ 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\Writer\PngWriter; use Endroid\QrCode\Writer\SvgWriter; use Endroid\QrCode\Writer\WriterInterface; @@ -31,6 +34,7 @@ final class QrCodeParams private int $margin, private WriterInterface $writer, private ErrorCorrectionLevelInterface $errorCorrectionLevel, + private RoundBlockSizeModeInterface $roundBlockSizeMode, ) { } @@ -43,6 +47,7 @@ final class QrCodeParams self::resolveMargin($query, $defaults), self::resolveWriter($query, $defaults), self::resolveErrorCorrection($query, $defaults), + self::resolveRoundBlockSize($query, $defaults), ); } @@ -90,6 +95,14 @@ final class QrCodeParams }; } + private static function resolveRoundBlockSize(array $query, QrCodeOptions $defaults): RoundBlockSizeModeInterface + { + $doNotRoundBlockSize = isset($query['roundBlockSize']) + ? $query['roundBlockSize'] === 'false' + : ! $defaults->roundBlockSize(); + return $doNotRoundBlockSize ? new RoundBlockSizeModeNone() : new RoundBlockSizeModeMargin(); + } + private static function normalizeParam(string $param): string { return strtolower(trim($param)); @@ -114,4 +127,9 @@ final class QrCodeParams { return $this->errorCorrectionLevel; } + + public function roundBlockSizeMode(): RoundBlockSizeModeInterface + { + return $this->roundBlockSizeMode; + } } diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index f8d2e275..7772a5c8 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -45,7 +45,8 @@ class QrCodeAction implements MiddlewareInterface ->size($params->size()) ->margin($params->margin()) ->writer($params->writer()) - ->errorCorrectionLevel($params->errorCorrectionLevel()); + ->errorCorrectionLevel($params->errorCorrectionLevel()) + ->roundBlockSizeMode($params->roundBlockSizeMode()); return new QrCodeResponse($qrCodeBuilder->build()); } diff --git a/module/Core/src/Config/EmptyNotFoundRedirectConfig.php b/module/Core/src/Config/EmptyNotFoundRedirectConfig.php new file mode 100644 index 00000000..6ccb3848 --- /dev/null +++ b/module/Core/src/Config/EmptyNotFoundRedirectConfig.php @@ -0,0 +1,38 @@ +em->getRepository(Domain::class); - $domains = $repo->findDomainsWithout($this->defaultDomain, $apiKey); - $mappedDomains = map($domains, fn (Domain $domain) => DomainItem::forExistingDomain($domain)); + [$default, $domains] = $this->defaultDomainAndRest($apiKey); + $mappedDomains = map($domains, fn (Domain $domain) => DomainItem::forNonDefaultDomain($domain)); if ($apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) { return $mappedDomains; } return [ - DomainItem::forDefaultDomain($this->defaultDomain, $this->redirectOptions), + DomainItem::forDefaultDomain($this->defaultDomain, $default ?? new EmptyNotFoundRedirectConfig()), ...$mappedDomains, ]; } + /** + * @return array{Domain|null, Domain[]} + */ + private function defaultDomainAndRest(?ApiKey $apiKey): array + { + /** @var DomainRepositoryInterface $repo */ + $repo = $this->em->getRepository(Domain::class); + $groups = group( + $repo->findDomains($apiKey), + fn (Domain $domain) => $domain->getAuthority() === $this->defaultDomain ? 'default' : 'domains', + ); + + return [first($groups['default'] ?? []), $groups['domains'] ?? []]; + } + /** * @throws DomainNotFoundException */ @@ -62,8 +73,7 @@ class DomainService implements DomainServiceInterface public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain { - $repo = $this->em->getRepository(Domain::class); - return $repo->findOneByAuthority($authority, $apiKey); + return $this->em->getRepository(Domain::class)->findOneByAuthority($authority, $apiKey); } /** @@ -79,17 +89,12 @@ class DomainService implements DomainServiceInterface /** * @throws DomainNotFoundException - * @throws InvalidDomainException */ public function configureNotFoundRedirects( string $authority, NotFoundRedirects $notFoundRedirects, ?ApiKey $apiKey = null, ): Domain { - if ($authority === $this->defaultDomain) { - throw InvalidDomainException::forDefaultDomainRedirects(); - } - $domain = $this->getPersistedDomain($authority, $apiKey); $domain->configureNotFoundRedirects($notFoundRedirects); diff --git a/module/Core/src/Domain/DomainServiceInterface.php b/module/Core/src/Domain/DomainServiceInterface.php index 7748284d..9ac48e69 100644 --- a/module/Core/src/Domain/DomainServiceInterface.php +++ b/module/Core/src/Domain/DomainServiceInterface.php @@ -8,7 +8,6 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; -use Shlinkio\Shlink\Core\Exception\InvalidDomainException; use Shlinkio\Shlink\Rest\Entity\ApiKey; interface DomainServiceInterface @@ -32,7 +31,6 @@ interface DomainServiceInterface /** * @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided - * @throws InvalidDomainException If default domain is provided */ public function configureNotFoundRedirects( string $authority, diff --git a/module/Core/src/Domain/Model/DomainItem.php b/module/Core/src/Domain/Model/DomainItem.php index 909cca7d..5547fe8d 100644 --- a/module/Core/src/Domain/Model/DomainItem.php +++ b/module/Core/src/Domain/Model/DomainItem.php @@ -18,7 +18,7 @@ final class DomainItem implements JsonSerializable ) { } - public static function forExistingDomain(Domain $domain): self + public static function forNonDefaultDomain(Domain $domain): self { return new self($domain->getAuthority(), $domain, false); } diff --git a/module/Core/src/Domain/Repository/DomainRepository.php b/module/Core/src/Domain/Repository/DomainRepository.php index 1741cea7..4de3ea36 100644 --- a/module/Core/src/Domain/Repository/DomainRepository.php +++ b/module/Core/src/Domain/Repository/DomainRepository.php @@ -8,7 +8,6 @@ use Doctrine\ORM\Query\Expr\Join; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Happyr\DoctrineSpecification\Spec; use Shlinkio\Shlink\Core\Domain\Spec\IsDomain; -use Shlinkio\Shlink\Core\Domain\Spec\IsNotAuthority; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToApiKey; @@ -20,7 +19,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe /** * @return Domain[] */ - public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array + public function findDomains(?ApiKey $apiKey = null): array { $qb = $this->createQueryBuilder('d'); $qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d') @@ -31,7 +30,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe ->orHaving($qb->expr()->isNotNull('d.regular404Redirect')) ->orHaving($qb->expr()->isNotNull('d.invalidShortUrlRedirect')); - $specs = $this->determineExtraSpecs($excludedAuthority, $apiKey); + $specs = $this->determineExtraSpecs($apiKey); foreach ($specs as [$alias, $spec]) { $this->applySpecification($qb, $spec, $alias); } @@ -47,7 +46,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe ->setParameter('authority', $authority) ->setMaxResults(1); - $specs = $this->determineExtraSpecs(null, $apiKey); + $specs = $this->determineExtraSpecs($apiKey); foreach ($specs as [$alias, $spec]) { $this->applySpecification($qb, $spec, $alias); } @@ -55,12 +54,8 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe return $qb->getQuery()->getOneOrNullResult(); } - private function determineExtraSpecs(?string $excludedAuthority, ?ApiKey $apiKey): iterable + private function determineExtraSpecs(?ApiKey $apiKey): iterable { - if ($excludedAuthority !== null) { - yield ['d', new IsNotAuthority($excludedAuthority)]; - } - // FIXME The $apiKey->spec() method cannot be used here, as it returns a single spec which assumes the // ShortUrl is the root entity. Here, the Domain is the root entity. // Think on a way to centralize the conditional behavior and make $apiKey->spec() more flexible. diff --git a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php index 123e349d..69e74e5b 100644 --- a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php +++ b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php @@ -14,7 +14,7 @@ interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificatio /** * @return Domain[] */ - public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array; + public function findDomains(?ApiKey $apiKey = null): array; public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain; } diff --git a/module/Core/src/Domain/Spec/IsNotAuthority.php b/module/Core/src/Domain/Spec/IsNotAuthority.php deleted file mode 100644 index 0f0f0653..00000000 --- a/module/Core/src/Domain/Spec/IsNotAuthority.php +++ /dev/null @@ -1,22 +0,0 @@ -authority)); - } -} diff --git a/module/Core/src/Entity/Visit.php b/module/Core/src/Entity/Visit.php index 8174e8be..c509bcc3 100644 --- a/module/Core/src/Entity/Visit.php +++ b/module/Core/src/Entity/Visit.php @@ -103,7 +103,7 @@ class Visit extends AbstractEntity implements JsonSerializable } try { - return (string) IpAddress::fromString($address)->getAnonymizedCopy(); + return IpAddress::fromString($address)->getAnonymizedCopy()->__toString(); } catch (InvalidArgumentException) { return null; } diff --git a/module/Core/src/EventDispatcher/NotifyVisitToRabbitMq.php b/module/Core/src/EventDispatcher/NotifyVisitToRabbitMq.php new file mode 100644 index 00000000..f05ecf64 --- /dev/null +++ b/module/Core/src/EventDispatcher/NotifyVisitToRabbitMq.php @@ -0,0 +1,102 @@ +isEnabled) { + return; + } + + $visitId = $shortUrlLocated->visitId(); + $visit = $this->em->find(Visit::class, $visitId); + + if ($visit === null) { + $this->logger->warning('Tried to notify RabbitMQ for visit with id "{visitId}", but it does not exist.', [ + 'visitId' => $visitId, + ]); + return; + } + + if (! $this->connection->isConnected()) { + $this->connection->reconnect(); + } + + $queues = $this->determineQueuesToPublishTo($visit); + $message = $this->visitToMessage($visit); + + try { + $channel = $this->connection->channel(); + + foreach ($queues as $queue) { + // Declare an exchange and a queue that will persist server restarts + $exchange = $queue; // We use the same name for the exchange and the queue + $channel->exchange_declare($exchange, AMQPExchangeType::DIRECT, false, true, false); + $channel->queue_declare($queue, false, true, false, false); + + // Bind the exchange and the queue together, and publish the message + $channel->queue_bind($queue, $exchange); + $channel->basic_publish($message, $exchange); + } + + $channel->close(); + } catch (Throwable $e) { + $this->logger->debug('Error while trying to notify RabbitMQ with new visit. {e}', ['e' => $e]); + } finally { + $this->connection->close(); + } + } + + /** + * @return string[] + */ + private function determineQueuesToPublishTo(Visit $visit): array + { + if ($visit->isOrphan()) { + return [self::NEW_ORPHAN_VISIT_QUEUE]; + } + + return [ + self::NEW_VISIT_QUEUE, + sprintf('%s/%s', self::NEW_VISIT_QUEUE, $visit->getShortUrl()?->getShortCode()), + ]; + } + + private function visitToMessage(Visit $visit): AMQPMessage + { + $messageBody = json_encode(! $visit->isOrphan() ? $visit : $this->orphanVisitTransformer->transform($visit)); + return new AMQPMessage($messageBody, [ + 'content_type' => 'application/json', + 'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT, + ]); + } +} diff --git a/module/Core/src/Exception/DeleteShortUrlException.php b/module/Core/src/Exception/DeleteShortUrlException.php index 600fca57..98919b35 100644 --- a/module/Core/src/Exception/DeleteShortUrlException.php +++ b/module/Core/src/Exception/DeleteShortUrlException.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Exception; use Fig\Http\Message\StatusCodeInterface; use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; +use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use function sprintf; @@ -17,11 +18,15 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE private const TITLE = 'Cannot delete short URL'; private const TYPE = 'INVALID_SHORTCODE_DELETION'; // FIXME Deprecated: Should be INVALID_SHORT_URL_DELETION - public static function fromVisitsThreshold(int $threshold, string $shortCode): self + public static function fromVisitsThreshold(int $threshold, ShortUrlIdentifier $identifier): self { + $shortCode = $identifier->shortCode(); + $domain = $identifier->domain(); + $suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain); $e = new self(sprintf( - 'Impossible to delete short URL with short code "%s" since it has more than "%s" visits.', + 'Impossible to delete short URL with short code "%s"%s, since it has more than "%s" visits.', $shortCode, + $suffix, $threshold, )); @@ -34,6 +39,10 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE 'threshold' => $threshold, ]; + if ($domain !== null) { + $e->additional['domain'] = $domain; + } + return $e; } diff --git a/module/Core/src/Exception/InvalidDomainException.php b/module/Core/src/Exception/InvalidDomainException.php deleted file mode 100644 index d41e71ac..00000000 --- a/module/Core/src/Exception/InvalidDomainException.php +++ /dev/null @@ -1,33 +0,0 @@ -detail = $e->getMessage(); - $e->title = self::TITLE; - $e->type = self::TYPE; - $e->status = StatusCodeInterface::STATUS_FORBIDDEN; - - return $e; - } -} diff --git a/module/Core/src/Mercure/MercureUpdatesGenerator.php b/module/Core/src/Mercure/MercureUpdatesGenerator.php index cc0f785a..74b85388 100644 --- a/module/Core/src/Mercure/MercureUpdatesGenerator.php +++ b/module/Core/src/Mercure/MercureUpdatesGenerator.php @@ -8,11 +8,9 @@ use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Entity\Visit; use Symfony\Component\Mercure\Update; -use function json_encode; +use function Shlinkio\Shlink\Common\json_encode; use function sprintf; -use const JSON_THROW_ON_ERROR; - final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface { private const NEW_VISIT_TOPIC = 'https://shlink.io/new-visit'; @@ -26,7 +24,7 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface public function newVisitUpdate(Visit $visit): Update { - return new Update(self::NEW_VISIT_TOPIC, $this->serialize([ + return new Update(self::NEW_VISIT_TOPIC, json_encode([ 'shortUrl' => $this->shortUrlTransformer->transform($visit->getShortUrl()), 'visit' => $visit, ])); @@ -34,7 +32,7 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface public function newOrphanVisitUpdate(Visit $visit): Update { - return new Update(self::NEW_ORPHAN_VISIT_TOPIC, $this->serialize([ + return new Update(self::NEW_ORPHAN_VISIT_TOPIC, json_encode([ 'visit' => $this->orphanVisitTransformer->transform($visit), ])); } @@ -44,14 +42,9 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface $shortUrl = $visit->getShortUrl(); $topic = sprintf('%s/%s', self::NEW_VISIT_TOPIC, $shortUrl?->getShortCode()); - return new Update($topic, $this->serialize([ + return new Update($topic, json_encode([ 'shortUrl' => $this->shortUrlTransformer->transform($shortUrl), 'visit' => $visit, ])); } - - private function serialize(array $data): string - { - return json_encode($data, JSON_THROW_ON_ERROR); - } } diff --git a/module/Core/src/Model/VisitsParams.php b/module/Core/src/Model/VisitsParams.php index ed98d4d2..dd5a656d 100644 --- a/module/Core/src/Model/VisitsParams.php +++ b/module/Core/src/Model/VisitsParams.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Model; +use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Util\DateRange; use function Shlinkio\Shlink\Core\parseDateRangeFromQuery; @@ -11,7 +12,6 @@ use function Shlinkio\Shlink\Core\parseDateRangeFromQuery; final class VisitsParams { private const FIRST_PAGE = 1; - private const ALL_ITEMS = -1; private DateRange $dateRange; private int $page; @@ -36,10 +36,10 @@ final class VisitsParams private function determineItemsPerPage(?int $itemsPerPage): int { if ($itemsPerPage !== null && $itemsPerPage < 0) { - return self::ALL_ITEMS; + return Paginator::ALL_ITEMS; } - return $itemsPerPage ?? self::ALL_ITEMS; + return $itemsPerPage ?? Paginator::ALL_ITEMS; } public static function fromRawData(array $query): self diff --git a/module/Core/src/Options/QrCodeOptions.php b/module/Core/src/Options/QrCodeOptions.php index 80d6e456..3dfc9a53 100644 --- a/module/Core/src/Options/QrCodeOptions.php +++ b/module/Core/src/Options/QrCodeOptions.php @@ -9,6 +9,7 @@ use Laminas\Stdlib\AbstractOptions; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION; use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT; use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_ROUND_BLOCK_SIZE; use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE; class QrCodeOptions extends AbstractOptions @@ -17,6 +18,7 @@ class QrCodeOptions extends AbstractOptions private int $margin = DEFAULT_QR_CODE_MARGIN; private string $format = DEFAULT_QR_CODE_FORMAT; private string $errorCorrection = DEFAULT_QR_CODE_ERROR_CORRECTION; + private bool $roundBlockSize = DEFAULT_QR_CODE_ROUND_BLOCK_SIZE; public function size(): int { @@ -57,4 +59,14 @@ class QrCodeOptions extends AbstractOptions { $this->errorCorrection = $errorCorrection; } + + public function roundBlockSize(): bool + { + return $this->roundBlockSize; + } + + protected function setRoundBlockSize(bool $roundBlockSize): void + { + $this->roundBlockSize = $roundBlockSize; + } } diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 0fe539af..5c39c21e 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -226,8 +226,6 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo 'id' => 'visit_location_id', ]); - $query = $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm); - - return $query->getResult(); + return $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm)->getResult(); } } diff --git a/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php b/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php index 0732b737..e6f2e82d 100644 --- a/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php +++ b/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php @@ -33,7 +33,7 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) { throw Exception\DeleteShortUrlException::fromVisitsThreshold( $this->deleteShortUrlsOptions->getVisitsThreshold(), - $shortUrl->getShortCode(), + $identifier, ); } diff --git a/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php b/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php index 6b103058..809d19b7 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php @@ -17,6 +17,6 @@ class BelongsToApiKeyInlined implements Filter public function getFilter(QueryBuilder $qb, string $dqlAlias): string { // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later - return (string) $qb->expr()->eq('s.authorApiKey', '\'' . $this->apiKey->getId() . '\''); + return $qb->expr()->eq('s.authorApiKey', '\'' . $this->apiKey->getId() . '\'')->__toString(); } } diff --git a/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php index 4ce130b7..46fba689 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php @@ -16,6 +16,6 @@ class BelongsToDomainInlined implements Filter public function getFilter(QueryBuilder $qb, string $context): string { // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later - return (string) $qb->expr()->eq('s.domain', '\'' . $this->domainId . '\''); + return $qb->expr()->eq('s.domain', '\'' . $this->domainId . '\'')->__toString(); } } diff --git a/module/Core/src/Tag/Model/TagRenaming.php b/module/Core/src/Tag/Model/TagRenaming.php index 1f677376..3bdae21c 100644 --- a/module/Core/src/Tag/Model/TagRenaming.php +++ b/module/Core/src/Tag/Model/TagRenaming.php @@ -10,20 +10,13 @@ use function sprintf; final class TagRenaming { - private string $oldName; - private string $newName; - - private function __construct() + private function __construct(private string $oldName, private string $newName) { } public static function fromNames(string $oldName, string $newName): self { - $o = new self(); - $o->oldName = $oldName; - $o->newName = $newName; - - return $o; + return new self($oldName, $newName); } public static function fromArray(array $payload): self diff --git a/module/Core/src/Validation/ShortUrlsParamsInputFilter.php b/module/Core/src/Validation/ShortUrlsParamsInputFilter.php index 871995dd..c62845d4 100644 --- a/module/Core/src/Validation/ShortUrlsParamsInputFilter.php +++ b/module/Core/src/Validation/ShortUrlsParamsInputFilter.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Validation; use Laminas\InputFilter\InputFilter; +use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Validation; class ShortUrlsParamsInputFilter extends InputFilter @@ -32,7 +33,7 @@ class ShortUrlsParamsInputFilter extends InputFilter $this->add($this->createInput(self::SEARCH_TERM, false)); $this->add($this->createNumericInput(self::PAGE, false)); - $this->add($this->createNumericInput(self::ITEMS_PER_PAGE, false, -1)); + $this->add($this->createNumericInput(self::ITEMS_PER_PAGE, false, Paginator::ALL_ITEMS)); $this->add($this->createTagsInput(self::TAGS, false)); } diff --git a/module/Core/src/Visit/RequestTracker.php b/module/Core/src/Visit/RequestTracker.php index eee75ea4..7cefa8a2 100644 --- a/module/Core/src/Visit/RequestTracker.php +++ b/module/Core/src/Visit/RequestTracker.php @@ -5,9 +5,10 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Visit; use Fig\Http\Message\RequestMethodInterface; -use InvalidArgumentException; +use IPLib\Address\IPv4; +use IPLib\Factory; +use IPLib\Range\RangeInterface; use Mezzio\Router\Middleware\ImplicitHeadMiddleware; -use PhpIP\IP; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -73,9 +74,8 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface return false; } - try { - $ip = IP::create($remoteAddr); - } catch (InvalidArgumentException) { + $ip = IPv4::parseString($remoteAddr); + if ($ip === null) { return false; } @@ -83,24 +83,23 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface $disableTrackingFrom = $this->trackingOptions->disableTrackingFrom(); return some($disableTrackingFrom, function (string $value) use ($ip, $remoteAddrParts): bool { - try { - return match (true) { - str_contains($value, '*') => $ip->matches($this->parseValueWithWildcards($value, $remoteAddrParts)), - str_contains($value, '/') => $ip->isIn($value), - default => $ip->matches($value), - }; - } catch (InvalidArgumentException) { - return false; - } + $range = match (true) { + str_contains($value, '*') => $this->parseValueWithWildcards($value, $remoteAddrParts), + default => Factory::parseRangeString($value), + }; + + return $range !== null && $ip->matches($range); }); } - private function parseValueWithWildcards(string $value, array $remoteAddrParts): string + private function parseValueWithWildcards(string $value, array $remoteAddrParts): ?RangeInterface { // Replace wildcard parts with the corresponding ones from the remote address - return implode('.', map( - explode('.', $value), - fn (string $part, int $index) => $part === '*' ? $remoteAddrParts[$index] : $part, - )); + return Factory::parseRangeString( + implode('.', map( + explode('.', $value), + fn (string $part, int $index) => $part === '*' ? $remoteAddrParts[$index] : $part, + )), + ); } } diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index 1eaf6ea9..382e58dd 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -50,27 +50,7 @@ class DomainRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - self::assertEquals( - [$barDomain, $bazDomain, $detachedWithRedirects, $fooDomain], - $this->repo->findDomainsWithout(null), - ); - self::assertEquals( - [$barDomain, $bazDomain, $detachedWithRedirects], - $this->repo->findDomainsWithout('foo.com'), - ); - self::assertEquals( - [$bazDomain, $detachedWithRedirects, $fooDomain], - $this->repo->findDomainsWithout('bar.com'), - ); - self::assertEquals( - [$barDomain, $detachedWithRedirects, $fooDomain], - $this->repo->findDomainsWithout('baz.com'), - ); - self::assertEquals( - [$barDomain, $bazDomain, $fooDomain], - $this->repo->findDomainsWithout('detached-with-redirects.com'), - ); - + self::assertEquals([$barDomain, $bazDomain, $detachedWithRedirects, $fooDomain], $this->repo->findDomains()); self::assertEquals($barDomain, $this->repo->findOneByAuthority('bar.com')); self::assertEquals($detachedWithRedirects, $this->repo->findOneByAuthority('detached-with-redirects.com')); self::assertNull($this->repo->findOneByAuthority('does-not-exist.com')); @@ -121,14 +101,11 @@ class DomainRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - self::assertEquals([$fooDomain], $this->repo->findDomainsWithout(null, $fooDomainApiKey)); - self::assertEquals([$barDomain], $this->repo->findDomainsWithout(null, $barDomainApiKey)); - self::assertEquals( - [$detachedWithRedirects], - $this->repo->findDomainsWithout(null, $detachedWithRedirectsApiKey), - ); - self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout(null, $authorApiKey)); - self::assertEquals([], $this->repo->findDomainsWithout(null, $authorAndDomainApiKey)); + self::assertEquals([$fooDomain], $this->repo->findDomains($fooDomainApiKey)); + self::assertEquals([$barDomain], $this->repo->findDomains($barDomainApiKey)); + self::assertEquals([$detachedWithRedirects], $this->repo->findDomains($detachedWithRedirectsApiKey)); + self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomains($authorApiKey)); + self::assertEquals([], $this->repo->findDomains($authorAndDomainApiKey)); self::assertEquals($fooDomain, $this->repo->findOneByAuthority('foo.com', $authorApiKey)); self::assertNull($this->repo->findOneByAuthority('bar.com', $authorApiKey)); diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 1fdc35ef..664a51a2 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -25,11 +25,16 @@ use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use function getimagesizefromstring; +use function imagecolorat; +use function imagecreatefromstring; class QrCodeActionTest extends TestCase { use ProphecyTrait; + private const WHITE = 0xFFFFFF; + private const BLACK = 0x0; + private QrCodeAction $action; private ObjectProphecy $urlResolver; private QrCodeOptions $options; @@ -135,7 +140,7 @@ class QrCodeActionTest extends TestCase $delegate = $this->prophesize(RequestHandlerInterface::class); $resp = $this->action->process($req->withAttribute('shortCode', $code), $delegate->reveal()); - [$size] = getimagesizefromstring((string) $resp->getBody()); + [$size] = getimagesizefromstring($resp->getBody()->__toString()); self::assertEquals($expectedSize, $size); } @@ -199,4 +204,41 @@ class QrCodeActionTest extends TestCase 538, ]; } + + /** + * @test + * @dataProvider provideRoundBlockSize + */ + public function imageCanRemoveExtraMarginWhenBlockRoundIsDisabled( + array $defaults, + ?string $roundBlockSize, + int $expectedColor, + ): void { + $this->options->setFromArray($defaults); + $code = 'abc123'; + $req = ServerRequestFactory::fromGlobals() + ->withQueryParams(['size' => 250, 'roundBlockSize' => $roundBlockSize]) + ->withAttribute('shortCode', $code); + + $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn( + ShortUrl::withLongUrl('https://shlink.io'), + ); + $delegate = $this->prophesize(RequestHandlerInterface::class); + + $resp = $this->action->process($req, $delegate->reveal()); + $image = imagecreatefromstring($resp->getBody()->__toString()); + $color = imagecolorat($image, 1, 1); + + self::assertEquals($color, $expectedColor); + } + + public function provideRoundBlockSize(): iterable + { + yield 'no round block param' => [[], null, self::WHITE]; + yield 'no round block param, but disabled by default' => [['round_block_size' => false], null, self::BLACK]; + yield 'round block: "true"' => [[], 'true', self::WHITE]; + yield 'round block: "true", but disabled by default' => [['round_block_size' => false], 'true', self::WHITE]; + yield 'round block: "false"' => [[], 'false', self::BLACK]; + yield 'round block: "false", but enabled by default' => [['round_block_size' => true], 'false', self::BLACK]; + } } diff --git a/module/Core/test/Config/EmptyNotFoundRedirectConfigTest.php b/module/Core/test/Config/EmptyNotFoundRedirectConfigTest.php new file mode 100644 index 00000000..d1c47e10 --- /dev/null +++ b/module/Core/test/Config/EmptyNotFoundRedirectConfigTest.php @@ -0,0 +1,29 @@ +redirectsConfig = new EmptyNotFoundRedirectConfig(); + } + + /** @test */ + public function allMethodsReturnHardcodedValues(): void + { + self::assertNull($this->redirectsConfig->invalidShortUrlRedirect()); + self::assertFalse($this->redirectsConfig->hasInvalidShortUrlRedirect()); + self::assertNull($this->redirectsConfig->regular404Redirect()); + self::assertFalse($this->redirectsConfig->hasRegular404Redirect()); + self::assertNull($this->redirectsConfig->baseUrlRedirect()); + self::assertFalse($this->redirectsConfig->hasBaseUrlRedirect()); + } +} diff --git a/module/Core/test/Config/SimplifiedConfigParserTest.php b/module/Core/test/Config/SimplifiedConfigParserTest.php index f4e5c8f0..48d41c00 100644 --- a/module/Core/test/Config/SimplifiedConfigParserTest.php +++ b/module/Core/test/Config/SimplifiedConfigParserTest.php @@ -29,7 +29,7 @@ class SimplifiedConfigParserTest extends TestCase 'entity_manager' => [ 'connection' => [ 'driver' => 'mysql', - 'host' => 'shlink_db', + 'host' => 'shlink_db_mysql', 'port' => '3306', ], ], @@ -78,7 +78,7 @@ class SimplifiedConfigParserTest extends TestCase 'entity_manager' => [ 'connection' => [ 'driver' => 'mysql', - 'host' => 'shlink_db', + 'host' => 'shlink_db_mysql', 'dbname' => 'shlink', 'user' => 'foo', 'password' => 'bar', diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index 159fb6ca..ea3cfe02 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -9,14 +9,13 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Shlinkio\Shlink\Core\Config\EmptyNotFoundRedirectConfig; use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; -use Shlinkio\Shlink\Core\Exception\InvalidDomainException; -use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -31,7 +30,7 @@ class DomainServiceTest extends TestCase public function setUp(): void { $this->em = $this->prophesize(EntityManagerInterface::class); - $this->domainService = new DomainService($this->em->reveal(), 'default.com', new NotFoundRedirectOptions()); + $this->domainService = new DomainService($this->em->reveal(), 'default.com'); } /** @@ -42,7 +41,7 @@ class DomainServiceTest extends TestCase { $repo = $this->prophesize(DomainRepositoryInterface::class); $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); - $findDomains = $repo->findDomainsWithout('default.com', $apiKey)->willReturn($domains); + $findDomains = $repo->findDomains($apiKey)->willReturn($domains); $result = $this->domainService->listDomains($apiKey); @@ -53,7 +52,7 @@ class DomainServiceTest extends TestCase public function provideExcludedDomains(): iterable { - $default = DomainItem::forDefaultDomain('default.com', new NotFoundRedirectOptions()); + $default = DomainItem::forDefaultDomain('default.com', new EmptyNotFoundRedirectConfig()); $adminApiKey = ApiKey::create(); $domainSpecificApiKey = ApiKey::fromMeta( ApiKeyMeta::withRoles(RoleDefinition::forDomain(Domain::withAuthority('')->setId('123'))), @@ -62,15 +61,15 @@ class DomainServiceTest extends TestCase yield 'empty list without API key' => [[], [$default], null]; yield 'one item without API key' => [ [Domain::withAuthority('bar.com')], - [$default, DomainItem::forExistingDomain(Domain::withAuthority('bar.com'))], + [$default, DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com'))], null, ]; yield 'multiple items without API key' => [ [Domain::withAuthority('foo.com'), Domain::withAuthority('bar.com')], [ $default, - DomainItem::forExistingDomain(Domain::withAuthority('foo.com')), - DomainItem::forExistingDomain(Domain::withAuthority('bar.com')), + DomainItem::forNonDefaultDomain(Domain::withAuthority('foo.com')), + DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com')), ], null, ]; @@ -78,15 +77,15 @@ class DomainServiceTest extends TestCase yield 'empty list with admin API key' => [[], [$default], $adminApiKey]; yield 'one item with admin API key' => [ [Domain::withAuthority('bar.com')], - [$default, DomainItem::forExistingDomain(Domain::withAuthority('bar.com'))], + [$default, DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com'))], $adminApiKey, ]; yield 'multiple items with admin API key' => [ [Domain::withAuthority('foo.com'), Domain::withAuthority('bar.com')], [ $default, - DomainItem::forExistingDomain(Domain::withAuthority('foo.com')), - DomainItem::forExistingDomain(Domain::withAuthority('bar.com')), + DomainItem::forNonDefaultDomain(Domain::withAuthority('foo.com')), + DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com')), ], $adminApiKey, ]; @@ -94,14 +93,14 @@ class DomainServiceTest extends TestCase yield 'empty list with domain-specific API key' => [[], [], $domainSpecificApiKey]; yield 'one item with domain-specific API key' => [ [Domain::withAuthority('bar.com')], - [DomainItem::forExistingDomain(Domain::withAuthority('bar.com'))], + [DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com'))], $domainSpecificApiKey, ]; yield 'multiple items with domain-specific API key' => [ [Domain::withAuthority('foo.com'), Domain::withAuthority('bar.com')], [ - DomainItem::forExistingDomain(Domain::withAuthority('foo.com')), - DomainItem::forExistingDomain(Domain::withAuthority('bar.com')), + DomainItem::forNonDefaultDomain(Domain::withAuthority('foo.com')), + DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com')), ], $domainSpecificApiKey, ]; @@ -214,15 +213,4 @@ class DomainServiceTest extends TestCase yield 'domain not found and author API key' => [null, $authorApiKey]; yield 'domain found and author API key' => [$domain, $authorApiKey]; } - - /** @test */ - public function anExceptionIsThrowsWhenTryingToEditRedirectsForDefaultDomain(): void - { - $this->expectException(InvalidDomainException::class); - $this->expectExceptionMessage( - 'You cannot configure default domain\'s redirects this way. Use the configuration or env vars.', - ); - - $this->domainService->configureNotFoundRedirects('default.com', NotFoundRedirects::withoutRedirects()); - } } diff --git a/module/Core/test/EventDispatcher/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/NotifyVisitToRabbitMqTest.php new file mode 100644 index 00000000..778da889 --- /dev/null +++ b/module/Core/test/EventDispatcher/NotifyVisitToRabbitMqTest.php @@ -0,0 +1,178 @@ +channel = $this->prophesize(AMQPChannel::class); + + $this->connection = $this->prophesize(AMQPStreamConnection::class); + $this->connection->isConnected()->willReturn(false); + $this->connection->channel()->willReturn($this->channel->reveal()); + + $this->em = $this->prophesize(EntityManagerInterface::class); + $this->logger = $this->prophesize(LoggerInterface::class); + + $this->listener = new NotifyVisitToRabbitMq( + $this->connection->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + new OrphanVisitDataTransformer(), + true, + ); + } + + /** @test */ + public function doesNothingWhenTheFeatureIsNotEnabled(): void + { + $listener = new NotifyVisitToRabbitMq( + $this->connection->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + new OrphanVisitDataTransformer(), + false, + ); + + $listener(new VisitLocated('123')); + + $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->connection->isConnected()->shouldNotHaveBeenCalled(); + $this->connection->close()->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function notificationsAreNotSentWhenVisitCannotBeFound(): void + { + $visitId = '123'; + $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(null); + $logWarning = $this->logger->warning( + 'Tried to notify RabbitMQ for visit with id "{visitId}", but it does not exist.', + ['visitId' => $visitId], + ); + + ($this->listener)(new VisitLocated($visitId)); + + $findVisit->shouldHaveBeenCalledOnce(); + $logWarning->shouldHaveBeenCalledOnce(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->connection->isConnected()->shouldNotHaveBeenCalled(); + $this->connection->close()->shouldNotHaveBeenCalled(); + } + + /** + * @test + * @dataProvider provideVisits + */ + public function expectedChannelsAreNotifiedBasedOnTheVisitType(Visit $visit, array $expectedChannels): void + { + $visitId = '123'; + $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); + $argumentWithExpectedChannel = Argument::that(fn (string $channel) => contains($expectedChannels, $channel)); + + ($this->listener)(new VisitLocated($visitId)); + + $findVisit->shouldHaveBeenCalledOnce(); + $this->channel->exchange_declare($argumentWithExpectedChannel, Argument::cetera())->shouldHaveBeenCalledTimes( + count($expectedChannels), + ); + $this->channel->queue_declare($argumentWithExpectedChannel, Argument::cetera())->shouldHaveBeenCalledTimes( + count($expectedChannels), + ); + $this->channel->queue_bind( + $argumentWithExpectedChannel, + $argumentWithExpectedChannel, + )->shouldHaveBeenCalledTimes(count($expectedChannels)); + $this->channel->basic_publish(Argument::any(), $argumentWithExpectedChannel)->shouldHaveBeenCalledTimes( + count($expectedChannels), + ); + $this->channel->close()->shouldHaveBeenCalledOnce(); + $this->connection->reconnect()->shouldHaveBeenCalledOnce(); + $this->connection->close()->shouldHaveBeenCalledOnce(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + public function provideVisits(): iterable + { + $visitor = Visitor::emptyInstance(); + + yield 'orphan visit' => [Visit::forBasePath($visitor), ['https://shlink.io/new-orphan-visit']]; + yield 'non-orphan visit' => [ + Visit::forValidShortUrl( + ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'longUrl' => 'foo', + 'customSlug' => 'bar', + ])), + $visitor, + ), + ['https://shlink.io/new-visit', 'https://shlink.io/new-visit/bar'], + ]; + } + + /** + * @test + * @dataProvider provideExceptions + */ + public function printsDebugMessageInCaseOfError(Throwable $e): void + { + $visitId = '123'; + $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(Visit::forBasePath(Visitor::emptyInstance())); + $channel = $this->connection->channel()->willThrow($e); + + ($this->listener)(new VisitLocated($visitId)); + + $this->logger->debug( + 'Error while trying to notify RabbitMQ with new visit. {e}', + ['e' => $e], + )->shouldHaveBeenCalledOnce(); + $this->connection->close()->shouldHaveBeenCalledOnce(); + $this->connection->reconnect()->shouldHaveBeenCalledOnce(); + $findVisit->shouldHaveBeenCalledOnce(); + $channel->shouldHaveBeenCalledOnce(); + $this->channel->close()->shouldNotHaveBeenCalled(); + } + + public function provideExceptions(): iterable + { + yield [new RuntimeException('RuntimeException Error')]; + yield [new Exception('Exception Error')]; + yield [new DomainException('DomainException Error')]; + } +} diff --git a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php index 43dcc2e5..8c616ce1 100644 --- a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php +++ b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Exception; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException; +use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use function Functional\map; use function range; @@ -23,7 +24,10 @@ class DeleteShortUrlExceptionTest extends TestCase string $shortCode, string $expectedMessage, ): void { - $e = DeleteShortUrlException::fromVisitsThreshold($threshold, $shortCode); + $e = DeleteShortUrlException::fromVisitsThreshold( + $threshold, + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + ); self::assertEquals($threshold, $e->getVisitsThreshold()); self::assertEquals($expectedMessage, $e->getMessage()); @@ -41,10 +45,29 @@ class DeleteShortUrlExceptionTest extends TestCase { return map(range(5, 50, 5), function (int $number) { return [$number, $shortCode = generateRandomShortCode(6), sprintf( - 'Impossible to delete short URL with short code "%s" since it has more than "%s" visits.', + 'Impossible to delete short URL with short code "%s", since it has more than "%s" visits.', $shortCode, $number, )]; }); } + + /** @test */ + public function domainIsPartOfAdditionalWhenProvidedInIdentifier(): void + { + $e = DeleteShortUrlException::fromVisitsThreshold( + 10, + ShortUrlIdentifier::fromShortCodeAndDomain('abc123', 'doma.in'), + ); + $expectedMessage = 'Impossible to delete short URL with short code "abc123" for domain "doma.in", since it ' + . 'has more than "10" visits.'; + + self::assertEquals([ + 'shortCode' => 'abc123', + 'domain' => 'doma.in', + 'threshold' => 10, + ], $e->getAdditionalData()); + self::assertEquals($expectedMessage, $e->getMessage()); + self::assertEquals($expectedMessage, $e->getDetail()); + } } diff --git a/module/Core/test/Exception/InvalidDomainExceptionTest.php b/module/Core/test/Exception/InvalidDomainExceptionTest.php deleted file mode 100644 index 06b78ff2..00000000 --- a/module/Core/test/Exception/InvalidDomainExceptionTest.php +++ /dev/null @@ -1,24 +0,0 @@ -getMessage()); - self::assertEquals($expected, $e->getDetail()); - self::assertEquals('Invalid domain', $e->getTitle()); - self::assertEquals('INVALID_DOMAIN', $e->getType()); - self::assertEquals(403, $e->getStatus()); - } -} diff --git a/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php b/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php index 4c066848..6c03d7b5 100644 --- a/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php @@ -51,7 +51,7 @@ class DeleteShortUrlServiceTest extends TestCase $this->expectException(DeleteShortUrlException::class); $this->expectExceptionMessage(sprintf( - 'Impossible to delete short URL with short code "%s" since it has more than "5" visits.', + 'Impossible to delete short URL with short code "%s", since it has more than "5" visits.', $this->shortCode, )); diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 5e0267d6..98b385b0 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -9,7 +9,7 @@ use Laminas\ServiceManager\Factory\InvokableFactory; use Mezzio\Router\Middleware\ImplicitOptionsMiddleware; use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider; use Shlinkio\Shlink\Core\Domain\DomainService; -use Shlinkio\Shlink\Core\Options\AppOptions; +use Shlinkio\Shlink\Core\Options; use Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\Tag\TagService; @@ -55,7 +55,7 @@ return [ ConfigAbstractFactory::class => [ ApiKeyService::class => ['em'], - Action\HealthAction::class => ['em', AppOptions::class], + Action\HealthAction::class => ['em', Options\AppOptions::class], Action\MercureInfoAction::class => [LcobucciJwtProvider::class, 'config.mercure'], Action\ShortUrl\CreateShortUrlAction::class => [Service\UrlShortener::class, ShortUrlDataTransformer::class], Action\ShortUrl\SingleStepCreateShortUrlAction::class => [ @@ -81,7 +81,7 @@ return [ Action\Tag\DeleteTagsAction::class => [TagService::class], Action\Tag\CreateTagsAction::class => [TagService::class], Action\Tag\UpdateTagAction::class => [TagService::class], - Action\Domain\ListDomainsAction::class => [DomainService::class], + Action\Domain\ListDomainsAction::class => [DomainService::class, Options\NotFoundRedirectOptions::class], Action\Domain\DomainRedirectsAction::class => [DomainService::class], Middleware\CrossDomainMiddleware::class => ['config.cors'], diff --git a/module/Rest/src/Action/Domain/ListDomainsAction.php b/module/Rest/src/Action/Domain/ListDomainsAction.php index c8f9a475..e50ada16 100644 --- a/module/Rest/src/Action/Domain/ListDomainsAction.php +++ b/module/Rest/src/Action/Domain/ListDomainsAction.php @@ -7,7 +7,9 @@ namespace Shlinkio\Shlink\Rest\Action\Domain; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; +use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; @@ -16,7 +18,7 @@ class ListDomainsAction extends AbstractRestAction protected const ROUTE_PATH = '/domains'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - public function __construct(private DomainServiceInterface $domainService) + public function __construct(private DomainServiceInterface $domainService, private NotFoundRedirectOptions $options) { } @@ -28,6 +30,7 @@ class ListDomainsAction extends AbstractRestAction return new JsonResponse([ 'domains' => [ 'data' => $domainItems, + 'defaultRedirects' => NotFoundRedirects::fromConfig($this->options), ], ]); } diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index 89371b71..3d34bd19 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -38,7 +38,7 @@ class ListTagsAction extends AbstractRestAction } $tagsInfo = $this->tagService->tagsInfo($apiKey); - $data = map($tagsInfo, fn (TagInfo $info) => (string) $info->tag()); + $data = map($tagsInfo, static fn (TagInfo $info) => $info->tag()->__toString()); return new JsonResponse([ 'tags' => [ diff --git a/module/Rest/src/Middleware/BodyParserMiddleware.php b/module/Rest/src/Middleware/BodyParserMiddleware.php index c7e99121..2711d900 100644 --- a/module/Rest/src/Middleware/BodyParserMiddleware.php +++ b/module/Rest/src/Middleware/BodyParserMiddleware.php @@ -54,7 +54,7 @@ class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterfac private function parseFromJson(Request $request): Request { - $rawBody = (string) $request->getBody(); + $rawBody = $request->getBody()->__toString(); if (empty($rawBody)) { return $request; } @@ -68,7 +68,7 @@ class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterfac */ private function parseFromUrlEncoded(Request $request): Request { - $rawBody = (string) $request->getBody(); + $rawBody = $request->getBody()->__toString(); if (empty($rawBody)) { return $request; } diff --git a/module/Rest/test-api/Action/DeleteShortUrlTest.php b/module/Rest/test-api/Action/DeleteShortUrlTest.php index 479527c1..bb512832 100644 --- a/module/Rest/test-api/Action/DeleteShortUrlTest.php +++ b/module/Rest/test-api/Action/DeleteShortUrlTest.php @@ -40,7 +40,8 @@ class DeleteShortUrlTest extends ApiTestCase for ($i = 0; $i < 20; $i++) { self::assertEquals(self::STATUS_FOUND, $this->callShortUrl('abc123')->getStatusCode()); } - $expectedDetail = 'Impossible to delete short URL with short code "abc123" since it has more than "15" visits.'; + $expectedDetail = 'Impossible to delete short URL with short code "abc123", since it has more than "15" ' + . 'visits.'; $resp = $this->callApiWithKey(self::METHOD_DELETE, '/short-urls/abc123'); $payload = $this->getJsonResponsePayload($resp); diff --git a/module/Rest/test-api/Action/DomainRedirectsTest.php b/module/Rest/test-api/Action/DomainRedirectsTest.php index 987c09d6..fdeec3b3 100644 --- a/module/Rest/test-api/Action/DomainRedirectsTest.php +++ b/module/Rest/test-api/Action/DomainRedirectsTest.php @@ -9,24 +9,6 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class DomainRedirectsTest extends ApiTestCase { - /** @test */ - public function anErrorIsReturnedWhenTryingToEditDefaultDomain(): void - { - $resp = $this->callApiWithKey(self::METHOD_PATCH, '/domains/redirects', [ - RequestOptions::JSON => ['domain' => 'doma.in'], - ]); - $payload = $this->getJsonResponsePayload($resp); - - self::assertEquals(self::STATUS_FORBIDDEN, $resp->getStatusCode()); - self::assertEquals(self::STATUS_FORBIDDEN, $payload['status']); - self::assertEquals('INVALID_DOMAIN', $payload['type']); - self::assertEquals( - 'You cannot configure default domain\'s redirects this way. Use the configuration or env vars.', - $payload['detail'], - ); - self::assertEquals('Invalid domain', $payload['title']); - } - /** * @test * @dataProvider provideInvalidDomains @@ -78,6 +60,14 @@ class DomainRedirectsTest extends ApiTestCase 'regular404Redirect' => 'foo.com', 'invalidShortUrlRedirect' => null, ]]; + yield 'default domain' => [[ + 'domain' => 'doma.in', + 'regular404Redirect' => 'foo-for-default.com', + ], [ + 'baseUrlRedirect' => null, + 'regular404Redirect' => 'foo-for-default.com', + 'invalidShortUrlRedirect' => null, + ]]; yield 'existing domain with redirects' => [[ 'domain' => 'detached-with-redirects.com', 'baseUrlRedirect' => null, diff --git a/module/Rest/test-api/Action/ListDomainsTest.php b/module/Rest/test-api/Action/ListDomainsTest.php index 5f33c20b..54039c41 100644 --- a/module/Rest/test-api/Action/ListDomainsTest.php +++ b/module/Rest/test-api/Action/ListDomainsTest.php @@ -21,6 +21,11 @@ class ListDomainsTest extends ApiTestCase self::assertEquals([ 'domains' => [ 'data' => $expectedDomains, + 'defaultRedirects' => [ + 'baseUrlRedirect' => null, + 'regular404Redirect' => null, + 'invalidShortUrlRedirect' => null, + ], ], ], $respPayload); } diff --git a/module/Rest/test-api/Action/OrphanVisitsTest.php b/module/Rest/test-api/Action/OrphanVisitsTest.php index 21f4cae1..a37193da 100644 --- a/module/Rest/test-api/Action/OrphanVisitsTest.php +++ b/module/Rest/test-api/Action/OrphanVisitsTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Action; use GuzzleHttp\RequestOptions; +use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class OrphanVisitsTest extends ApiTestCase @@ -51,7 +52,7 @@ class OrphanVisitsTest extends ApiTestCase $payload = $this->getJsonResponsePayload($resp); $visits = $payload['visits']['data'] ?? []; - self::assertEquals($totalItems, $payload['visits']['pagination']['totalItems'] ?? -1); + self::assertEquals($totalItems, $payload['visits']['pagination']['totalItems'] ?? Paginator::ALL_ITEMS); self::assertCount($expectedAmount, $visits); self::assertEquals($expectedVisits, $visits); } diff --git a/module/Rest/test-api/Action/ShortUrlVisitsTest.php b/module/Rest/test-api/Action/ShortUrlVisitsTest.php index 327c7c05..a9e571da 100644 --- a/module/Rest/test-api/Action/ShortUrlVisitsTest.php +++ b/module/Rest/test-api/Action/ShortUrlVisitsTest.php @@ -6,6 +6,7 @@ namespace ShlinkioApiTest\Shlink\Rest\Action; use GuzzleHttp\Psr7\Query; use Laminas\Diactoros\Uri; +use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait; @@ -58,7 +59,10 @@ class ShortUrlVisitsTest extends ApiTestCase $resp = $this->callApiWithKey(self::METHOD_GET, (string) $url); $payload = $this->getJsonResponsePayload($resp); - self::assertEquals($expectedAmountOfVisits, $payload['visits']['pagination']['totalItems'] ?? -1); + self::assertEquals( + $expectedAmountOfVisits, + $payload['visits']['pagination']['totalItems'] ?? Paginator::ALL_ITEMS, + ); self::assertCount($expectedAmountOfVisits, $payload['visits']['data'] ?? []); } @@ -84,7 +88,10 @@ class ShortUrlVisitsTest extends ApiTestCase $resp = $this->callApiWithKey(self::METHOD_GET, (string) $url); $payload = $this->getJsonResponsePayload($resp); - self::assertEquals($expectedAmountOfVisits, $payload['visits']['pagination']['totalItems'] ?? -1); + self::assertEquals( + $expectedAmountOfVisits, + $payload['visits']['pagination']['totalItems'] ?? Paginator::ALL_ITEMS, + ); self::assertCount($expectedAmountOfVisits, $payload['visits']['data'] ?? []); } diff --git a/module/Rest/test/Action/Domain/ListDomainsActionTest.php b/module/Rest/test/Action/Domain/ListDomainsActionTest.php index 45575cc6..bc852b34 100644 --- a/module/Rest/test/Action/Domain/ListDomainsActionTest.php +++ b/module/Rest/test/Action/Domain/ListDomainsActionTest.php @@ -9,6 +9,7 @@ use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Entity\Domain; @@ -22,11 +23,13 @@ class ListDomainsActionTest extends TestCase private ListDomainsAction $action; private ObjectProphecy $domainService; + private NotFoundRedirectOptions $options; public function setUp(): void { $this->domainService = $this->prophesize(DomainServiceInterface::class); - $this->action = new ListDomainsAction($this->domainService->reveal()); + $this->options = new NotFoundRedirectOptions(); + $this->action = new ListDomainsAction($this->domainService->reveal(), $this->options); } /** @test */ @@ -35,7 +38,7 @@ class ListDomainsActionTest extends TestCase $apiKey = ApiKey::create(); $domains = [ DomainItem::forDefaultDomain('bar.com', new NotFoundRedirectOptions()), - DomainItem::forExistingDomain(Domain::withAuthority('baz.com')), + DomainItem::forNonDefaultDomain(Domain::withAuthority('baz.com')), ]; $listDomains = $this->domainService->listDomains($apiKey)->willReturn($domains); @@ -46,6 +49,7 @@ class ListDomainsActionTest extends TestCase self::assertEquals([ 'domains' => [ 'data' => $domains, + 'defaultRedirects' => NotFoundRedirects::fromConfig($this->options), ], ], $payload); $listDomains->shouldHaveBeenCalledOnce(); diff --git a/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php b/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php new file mode 100644 index 00000000..55828368 --- /dev/null +++ b/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php @@ -0,0 +1,73 @@ +expectException(ValidationException::class); + DomainRedirectsRequest::fromRawData($data); + } + + public function provideInvalidData(): iterable + { + yield 'missing domain' => [[]]; + yield 'invalid domain' => [['domain' => 'foo:bar:baz']]; + } + + /** + * @test + * @dataProvider provideValidData + */ + public function isProperlyCastToNotFoundRedirects( + array $data, + ?NotFoundRedirectConfigInterface $defaults, + string $expectedAuthority, + ?string $expectedBaseUrlRedirect, + ?string $expectedRegular404Redirect, + ?string $expectedInvalidShortUrlRedirect, + ): void { + $request = DomainRedirectsRequest::fromRawData($data); + $notFound = $request->toNotFoundRedirects($defaults); + + self::assertEquals($expectedAuthority, $request->authority()); + self::assertEquals($expectedBaseUrlRedirect, $notFound->baseUrlRedirect()); + self::assertEquals($expectedRegular404Redirect, $notFound->regular404Redirect()); + self::assertEquals($expectedInvalidShortUrlRedirect, $notFound->invalidShortUrlRedirect()); + } + + public function provideValidData(): iterable + { + yield 'no values' => [['domain' => 'foo'], null, 'foo', null, null, null]; + yield 'some values' => [['domain' => 'foo', 'regular404Redirect' => 'bar'], null, 'foo', null, 'bar', null]; + yield 'fallbacks' => [ + ['domain' => 'domain', 'baseUrlRedirect' => 'bar'], + new NotFoundRedirectOptions(['regular404' => 'fallback', 'invalidShortUrl' => 'fallback2']), + 'domain', + 'bar', + 'fallback', + 'fallback2', + ]; + yield 'fallback ignored' => [ + ['domain' => 'domain', 'regular404Redirect' => 'bar', 'invalidShortUrlRedirect' => null], + new NotFoundRedirectOptions(['regular404' => 'fallback', 'invalidShortUrl' => 'fallback2']), + 'domain', + null, + 'bar', + null, + ]; + } +} diff --git a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php index 9fec7e1f..43209e51 100644 --- a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php @@ -45,13 +45,16 @@ class OrphanVisitsActionTest extends TestCase $orphanVisits = $this->visitsHelper->orphanVisits(Argument::type(VisitsParams::class))->willReturn( new Paginator(new ArrayAdapter($visits)), ); + $visitsAmount = count($visits); $transform = $this->orphanVisitTransformer->transform(Argument::type(Visit::class))->willReturn([]); + /** @var JsonResponse $response */ $response = $this->action->handle(ServerRequestFactory::fromGlobals()); + $payload = $response->getPayload(); - self::assertInstanceOf(JsonResponse::class, $response); + self::assertCount($visitsAmount, $payload['visits']['data']); self::assertEquals(200, $response->getStatusCode()); $orphanVisits->shouldHaveBeenCalledOnce(); - $transform->shouldHaveBeenCalledTimes(count($visits)); + $transform->shouldHaveBeenCalledTimes($visitsAmount); } } diff --git a/module/Rest/test/ApiKey/Model/RoleDefinitionTest.php b/module/Rest/test/ApiKey/Model/RoleDefinitionTest.php new file mode 100644 index 00000000..8e6a58ad --- /dev/null +++ b/module/Rest/test/ApiKey/Model/RoleDefinitionTest.php @@ -0,0 +1,32 @@ +roleName()); + self::assertEquals([], $definition->meta()); + } + + /** @test */ + public function forDomainCreatesRoleDefinitionAsExpected(): void + { + $domain = Domain::withAuthority('foo.com')->setId('123'); + $definition = RoleDefinition::forDomain($domain); + + self::assertEquals(Role::DOMAIN_SPECIFIC, $definition->roleName()); + self::assertEquals(['domain_id' => '123', 'authority' => 'foo.com'], $definition->meta()); + } +} diff --git a/phpstan.neon b/phpstan.neon index bf3afc8e..aa5dab08 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,11 +1,14 @@ includes: - - vendor/phpstan/phpstan-doctrine/extension.neon - - vendor/phpstan/phpstan-symfony/extension.neon + - vendor/phpstan/phpstan-doctrine/extension.neon + - vendor/phpstan/phpstan-symfony/extension.neon parameters: - checkMissingIterableValueType: false - checkGenericClassInNonGenericObjectType: false - symfony: - console_application_loader: 'config/cli-app.php' - doctrine: - repositoryClass: Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository - objectManagerLoader: 'config/entity-manager.php' + checkMissingIterableValueType: false + checkGenericClassInNonGenericObjectType: false + symfony: + console_application_loader: 'config/cli-app.php' + doctrine: + repositoryClass: Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository + objectManagerLoader: 'config/entity-manager.php' + ignoreErrors: + - '#should return int<0, max> but returns int#' + - '#expects -1|int<1, max>, int given#' diff --git a/phpunit-api.xml b/phpunit-api.xml index 38a53ca4..6dd527de 100644 --- a/phpunit-api.xml +++ b/phpunit-api.xml @@ -13,7 +13,8 @@ - ./module/*/src + ./module/Core/src + ./module/Rest/src