diff --git a/.dockerignore b/.dockerignore index 9a48c84c..f9102acb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,8 @@ indocker docker-* phpstan.neon php*xml* -infection.json +infection* **/test* build* **/.* +bin/helper diff --git a/.gitattributes b/.gitattributes index 53b0a935..4d66fe58 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,7 +10,6 @@ .gitattributes export-ignore .gitignore export-ignore .phpstorm.meta.php export-ignore -.scrutinizer.yml export-ignore .travis.yml export-ignore build.sh export-ignore CHANGELOG.md export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c426f4a3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,319 @@ +name: Continuous integration + +on: + pull_request: null + push: + branches: + - main + - develop + +jobs: + lint: + runs-on: ubuntu-20.04 + strategy: + matrix: + php-version: ['7.4'] + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Use PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + tools: composer + extensions: swoole-4.5.9 + coverage: none + - run: composer install --no-interaction --prefer-dist + - run: composer cs + + static-analysis: + runs-on: ubuntu-20.04 + strategy: + matrix: + php-version: ['7.4'] + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Use PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + tools: composer + extensions: swoole-4.5.9 + coverage: none + - run: composer install --no-interaction --prefer-dist + - run: composer stan + + unit-tests: + runs-on: ubuntu-20.04 + continue-on-error: ${{ matrix.php-version == '8.0' }} + strategy: + matrix: + php-version: ['7.4', '8.0'] + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Use PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + tools: composer + extensions: swoole-4.5.9 + coverage: pcov + ini-values: pcov.directory=module + - if: ${{ matrix.php-version == '8.0' }} + run: composer install --no-interaction --prefer-dist --ignore-platform-req=php + - if: ${{ matrix.php-version != '8.0' }} + run: composer install --no-interaction --prefer-dist + - run: composer test:unit:ci + - uses: actions/upload-artifact@v2 + if: ${{ matrix.php-version == '7.4' }} + with: + name: coverage-unit + path: | + build/coverage-unit + build/coverage-unit.cov + + db-tests-sqlite: + runs-on: ubuntu-20.04 + strategy: + matrix: + php-version: ['7.4', '8.0'] + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Use PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + tools: composer + extensions: swoole-4.5.9 + coverage: pcov + ini-values: pcov.directory=module + - if: ${{ matrix.php-version == '8.0' }} + run: composer install --no-interaction --prefer-dist --ignore-platform-req=php + - if: ${{ matrix.php-version != '8.0' }} + run: composer install --no-interaction --prefer-dist + - run: composer test:db:sqlite:ci + - uses: actions/upload-artifact@v2 + if: ${{ matrix.php-version == '7.4' }} + with: + name: coverage-db + path: | + build/coverage-db + build/coverage-db.cov + + db-tests-mysql: + runs-on: ubuntu-20.04 + strategy: + matrix: + php-version: ['7.4', '8.0'] + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Start database server + run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db + - name: Use PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + tools: composer + extensions: swoole-4.5.9 + coverage: none + - if: ${{ matrix.php-version == '8.0' }} + run: composer install --no-interaction --prefer-dist --ignore-platform-req=php + - if: ${{ matrix.php-version != '8.0' }} + run: composer install --no-interaction --prefer-dist + - run: composer test:db:mysql + + db-tests-maria: + runs-on: ubuntu-20.04 + strategy: + matrix: + php-version: ['7.4', '8.0'] + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Start database server + run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_maria + - name: Use PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + tools: composer + extensions: swoole-4.5.9 + coverage: none + - if: ${{ matrix.php-version == '8.0' }} + run: composer install --no-interaction --prefer-dist --ignore-platform-req=php + - if: ${{ matrix.php-version != '8.0' }} + run: composer install --no-interaction --prefer-dist + - run: composer test:db:maria + + db-tests-postgres: + runs-on: ubuntu-20.04 + strategy: + matrix: + php-version: ['7.4', '8.0'] + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Start database server + run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres + - name: Use PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + tools: composer + extensions: swoole-4.5.9 + coverage: none + - if: ${{ matrix.php-version == '8.0' }} + run: composer install --no-interaction --prefer-dist --ignore-platform-req=php + - if: ${{ matrix.php-version != '8.0' }} + run: composer install --no-interaction --prefer-dist + - run: composer test:db:postgres + + db-tests-ms: + runs-on: ubuntu-20.04 + strategy: + matrix: + php-version: ['7.4', '8.0'] + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Install MSSQL ODBC + run: sudo ./data/infra/ci/install-ms-odbc.sh + - name: Start database server + run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms + - name: Use PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + tools: composer + extensions: swoole-4.5.9, pdo_sqlsrv-5.9.0beta2 + coverage: none + - if: ${{ matrix.php-version == '8.0' }} + run: composer install --no-interaction --prefer-dist --ignore-platform-req=php + - if: ${{ matrix.php-version != '8.0' }} + run: composer install --no-interaction --prefer-dist + - name: Create test database + run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;" + - run: composer test:db:ms + + api-tests: + runs-on: ubuntu-20.04 + continue-on-error: ${{ matrix.php-version == '8.0' }} + strategy: + matrix: + php-version: ['7.4', '8.0'] + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Start database server + run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db + - name: Use PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + tools: composer + extensions: swoole-4.5.9 + coverage: pcov + ini-values: pcov.directory=module + - if: ${{ matrix.php-version == '8.0' }} + run: composer install --no-interaction --prefer-dist --ignore-platform-req=php + - if: ${{ matrix.php-version != '8.0' }} + run: composer install --no-interaction --prefer-dist + - run: bin/test/run-api-tests.sh + - uses: actions/upload-artifact@v2 + if: ${{ matrix.php-version == '7.4' }} + with: + name: coverage-api + path: | + build/coverage-api + build/coverage-api.cov + + mutation-tests: + needs: + - unit-tests + - db-tests-sqlite + - api-tests + runs-on: ubuntu-20.04 + strategy: + matrix: + php-version: ['7.4', '8.0'] + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Use PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + tools: composer + extensions: swoole-4.5.9 + coverage: pcov + ini-values: pcov.directory=module + - if: ${{ matrix.php-version == '8.0' }} + run: composer install --no-interaction --prefer-dist --ignore-platform-req=php + - if: ${{ matrix.php-version != '8.0' }} + run: composer install --no-interaction --prefer-dist + - uses: actions/download-artifact@v2 + with: + path: build + - run: composer infect:ci + + upload-coverage: + needs: + - unit-tests + - db-tests-sqlite + - api-tests + runs-on: ubuntu-20.04 + strategy: + matrix: + php-version: ['7.4'] + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Use PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: pcov + ini-values: pcov.directory=module + - uses: actions/download-artifact@v2 + with: + path: build + - run: mv build/coverage-unit/coverage-unit.cov build/coverage-unit.cov + - run: mv build/coverage-db/coverage-db.cov build/coverage-db.cov + - run: mv build/coverage-api/coverage-api.cov build/coverage-api.cov + - run: wget https://phar.phpunit.de/phpcov-8.2.0.phar + - run: php phpcov-8.2.0.phar merge build --clover build/clover.xml + - name: Publish coverage + uses: codecov/codecov-action@v1 + with: + file: ./build/clover.xml + + delete-artifacts: + needs: + - mutation-tests + - upload-coverage + runs-on: ubuntu-20.04 + steps: + - uses: geekyeggo/delete-artifact@v1 + with: + name: | + coverage-unit + coverage-db + coverage-api + + build-docker-image: + runs-on: ubuntu-20.04 + steps: + - name: Checkout code + uses: actions/checkout@v2 + - uses: marceloprado/has-changed-path@v1 + id: changed-dockerfile + with: + paths: ./Dockerfile + - if: ${{ steps.changed-dockerfile.outputs.changed == 'true' }} + run: docker build -t shlink-docker-image:temp . + - if: ${{ steps.changed-dockerfile.outputs.changed != 'true' }} + run: echo "Dockerfile didn't change. Skipped" diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 78f981ab..c1009f1c 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -16,7 +16,7 @@ jobs: with: php-version: '7.4' # Publish release with lowest supported PHP version tools: composer - extensions: swoole-4.5.5 + extensions: swoole-4.5.9 - name: Generate release assets run: ./build.sh ${GITHUB_REF#refs/tags/v} - name: Publish release with assets diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index ed831706..00000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,16 +0,0 @@ -tools: - external_code_coverage: - timeout: 600 -checks: - php: - code_rating: true - duplication: true -build: - dependencies: - override: - - composer install --no-interaction --no-scripts --ignore-platform-reqs - nodes: - analysis: - tests: - override: - - php-scrutinizer-run diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 3bf55b55..00000000 --- a/.travis.yml +++ /dev/null @@ -1,56 +0,0 @@ -dist: bionic - -language: php - -branches: - only: - - /.*/ - -services: - - docker - -cache: - directories: - - $HOME/.composer/cache/files - -jobs: - fast_finish: true - allow_failures: - - php: 'nightly' - include: - - name: "CI - 8.0" - php: 'nightly' - env: - - COMPOSER_FLAGS='--ignore-platform-reqs' - - name: "CI - 7.4" - php: '7.4' - env: - - COMPOSER_FLAGS='' - -before_install: - - echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini - - phpenv config-rm xdebug.ini || return 0 - - sudo ./data/infra/ci/install-ms-odbc.sh - - docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms shlink_db shlink_db_postgres shlink_db_maria - - yes | pecl install pdo_sqlsrv-5.9.0preview1 swoole-4.5.5 pcov - -install: - - composer self-update - - composer install --no-interaction --prefer-dist $COMPOSER_FLAGS - -before_script: - - docker-compose exec shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;" - - mkdir build - - export DOCKERFILE_CHANGED=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/main} --name-only | grep Dockerfile) - -script: - - composer ci - - bin/test/run-api-tests.sh - - if [[ ! -z "${DOCKERFILE_CHANGED}" && "${TRAVIS_PHP_VERSION}" == "7.4" ]]; then docker build -t shlink-docker-image:temp . ; fi - -after_success: - - rm -f build/clover.xml - - wget https://phar.phpunit.de/phpcov-7.0.2.phar - - php phpcov-7.0.2.phar merge build --clover build/clover.xml - - wget https://scrutinizer-ci.com/ocular.phar - - php ocular.phar code-coverage:upload --format=php-clover build/clover.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ec8f4df..8f034cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ 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.5.0] - 2021-01-17 +### Added +* [#795](https://github.com/shlinkio/shlink/issues/795) and [#882](https://github.com/shlinkio/shlink/issues/882) Added new roles system to API keys. + + API keys can have any combinations of these two roles now, allowing to limit their interactions: + + * Can interact only with short URLs created with that API key. + * Can interact only with short URLs for a specific domain. + +* [#833](https://github.com/shlinkio/shlink/issues/833) Added support to connect through unix socket when using an external MySQL, MariaDB or Postgres database. + + It can be provided during the installation, or as the `DB_UNIX_SOCKET` env var for the docker image. + +* [#869](https://github.com/shlinkio/shlink/issues/869) Added support for Mercure Hub 0.10. +* [#896](https://github.com/shlinkio/shlink/issues/896) Added support for unicode characters in custom slugs. +* [#930](https://github.com/shlinkio/shlink/issues/930) Added new `bin/set-option` script that allows changing individual configuration options on existing shlink instances. +* [#877](https://github.com/shlinkio/shlink/issues/877) Improved API tests on CORS, and "refined" middleware handling it. + +### Changed +* [#912](https://github.com/shlinkio/shlink/issues/912) Changed error templates to be plain html files, removing the dependency on `league/plates` package. +* [#875](https://github.com/shlinkio/shlink/issues/875) Updated to `mezzio/mezzio-swoole` v3.1. +* [#952](https://github.com/shlinkio/shlink/issues/952) Simplified in-project docs, by keeping only the basics and linking to the websites docs for anything else. + +### Deprecated +* [#917](https://github.com/shlinkio/shlink/issues/917) Deprecated `/{shortCode}/qr-code/{size}` URL, in favor of providing the size in the query instead, `/{shortCode}/qr-code?size={size}`. +* [#924](https://github.com/shlinkio/shlink/issues/924) Deprecated mechanism to provide config options to the docker image through volumes. Use the env vars instead as a direct replacement. + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + ## [2.4.2] - 2020-11-22 ### Added * *Nothing* diff --git a/Dockerfile b/Dockerfile index cc7c403d..9d7e0bef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM php:7.4.11-alpine3.12 as base ARG SHLINK_VERSION=2.4.0 ENV SHLINK_VERSION ${SHLINK_VERSION} -ENV SWOOLE_VERSION 4.5.5 +ENV SWOOLE_VERSION 4.5.9 ENV LC_ALL "C" WORKDIR /etc/shlink diff --git a/README.md b/README.md index 1b03c048..3a7373b2 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,39 @@ ![Shlink](https://raw.githubusercontent.com/shlinkio/shlink.io/main/public/images/shlink-hero.png) -[![Build Status](https://img.shields.io/travis/com/shlinkio/shlink.svg?style=flat-square)](https://travis-ci.com/shlinkio/shlink) -[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/) -[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/) +[![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) [![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?style=flat-square)](https://hub.docker.com/r/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) [![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate) A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own custom domain. -> This document references Shlink 2.x. If you are using an older version and want to upgrade, follow the [UPGRADE](UPGRADE.md) doc. - -> If you are trying to find out how to run the project in development mode or how to provide contributions, read the [CONTRIBUTING](CONTRIBUTING.md) doc. - ## Table of Contents -- [Installation](#installation) +- [Full documentation](#full-documentation) +- [Docker image](#docker-image) +- [Self hosted](#self-hosted) - [Download](#download) - [Configure](#configure) - - [Serve](#serve) - - [Bonus](#bonus) -- [Update to new version](#update-to-new-version) -- [Using a docker image](#using-a-docker-image) - [Using shlink](#using-shlink) - - [Shlink CLI Help](#shlink-cli-help) -- [Multiple domains](#multiple-domains) - - [Management](#management) - - [Visits](#visits) - - [Special redirects](#special-redirects) +- [Contributing](#contributing) -## Installation +## Full documentation -> These are the steps needed to install Shlink if you plan to manually host it. -> -> Alternatively, you can use the official docker image. If that's your intention, jump directly to [Using a docker image](#using-a-docker-image) +This document contains the very basics to get started with Shlink. If you want to learn everything you can do with it, visit the [full searchable documentation](https://shlink.io/documentation/). + +## Docker image + +Starting with version 1.15.0, an official docker image is provided. You can learn how to use it by reading [the docs](https://shlink.io/documentation/install-docker-image/). + +The idea is that you can just generate a container using the image and provide the custom config via env vars. + +## Self hosted First, make sure the host where you are going to run shlink fulfills these requirements: -* PHP 7.4 or greater with JSON, curl, PDO, intl and gd extensions enabled. +* PHP 7.4 with JSON, curl, PDO, intl and gd extensions enabled (PHP 8.0 support is coming). * MySQL, MariaDB, PostgreSQL, Microsoft SQL Server or SQLite. * The web server of your choice with PHP integration (Apache or Nginx recommended). @@ -64,7 +59,7 @@ In order to run Shlink, you will need a built version of the project. There are After that, you will have a `shlink_x.x.x_dist.zip` dist file inside the `build` directory, that you need to decompress in the location fo your choice. - > This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by [travis](https://travis-ci.com/shlinkio/shlink), attaching the generated dist file to it. + > This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by a [GitHub workflow](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Publish+release%22), attaching the generated dist file to it. ### Configure @@ -75,162 +70,6 @@ Despite how you built the project, you now need to configure it, by following th * Setup the application by running the `bin/install` script. It is a command line tool that will guide you through the installation process. **Take into account that this tool has to be run directly on the server where you plan to host Shlink. Do not run it before uploading/moving it there.** * Generate your first API key by running `bin/cli api-key:generate`. You will need the key in order to interact with shlink's API. -### Serve - -Once Shlink is configured, you need to expose it to the web, either by using a traditional web server + fast CGI approach, or by using a [swoole](https://www.swoole.co.uk/) non-blocking server. - -* **Using a web server:** - - For example, assuming your domain is doma.in and shlink is in the `/path/to/shlink` folder, these would be the basic configurations for Nginx and Apache. - - *Nginx:* - - ```nginx - server { - server_name doma.in; - listen 80; - root /path/to/shlink/public; - index index.php; - charset utf-8; - - location / { - try_files $uri $uri/ /index.php$is_args$args; - } - - location ~ \.php$ { - fastcgi_split_path_info ^(.+\.php)(/.+)$; - fastcgi_pass unix:/var/run/php/php7.4-fpm.sock; - fastcgi_index index.php; - include fastcgi.conf; - } - - location ~ /\.ht { - deny all; - } - } - ``` - - *Apache:* - - ```apache - - ServerName doma.in - DocumentRoot "/path/to/shlink/public" - - - Options FollowSymLinks Includes ExecCGI - AllowOverride all - Order allow,deny - Allow from all - - - ``` - -* **Using swoole:** - - First you need to install the swoole PHP extension with [pecl](https://pecl.php.net/package/swoole), `pecl install swoole`. - - Once installed, it's actually pretty easy to get shlink up and running with swoole. Run `./vendor/bin/mezzio-swoole start -d` and you will get shlink running on port 8080. - - However, by doing it this way, you are loosing all the access logs, and the service won't be automatically run if the server has to be restarted. - - For that reason, you should create a daemon script, in `/etc/init.d/shlink_swoole`, like this one, replacing `/path/to/shlink` by the path to your shlink installation: - - ```bash - #!/bin/bash - ### BEGIN INIT INFO - # Provides: shlink_swoole - # Required-Start: $local_fs $network $named $time $syslog - # Required-Stop: $local_fs $network $named $time $syslog - # Default-Start: 2 3 4 5 - # Default-Stop: 0 1 6 - # Description: Shlink non-blocking server with swoole - ### END INIT INFO - - SCRIPT=/path/to/shlink/vendor/bin/mezzio-swoole\ start - RUNAS=root - - PIDFILE=/var/run/shlink_swoole.pid - LOGDIR=/var/log/shlink - LOGFILE=${LOGDIR}/shlink_swoole.log - - start() { - if [[ -f "$PIDFILE" ]] && kill -0 $(cat "$PIDFILE"); then - echo 'Shlink with swoole already running' >&2 - return 1 - fi - echo 'Starting shlink with swoole' >&2 - mkdir -p "$LOGDIR" - touch "$LOGFILE" - local CMD="$SCRIPT &> \"$LOGFILE\" & echo \$!" - su -c "$CMD" $RUNAS > "$PIDFILE" - echo 'Shlink started' >&2 - } - - stop() { - if [[ ! -f "$PIDFILE" ]] || ! kill -0 $(cat "$PIDFILE"); then - echo 'Shlink with swoole not running' >&2 - return 1 - fi - echo 'Stopping shlink with swoole' >&2 - kill -15 $(cat "$PIDFILE") && rm -f "$PIDFILE" - echo 'Shlink stopped' >&2 - } - - case "$1" in - start) - start - ;; - stop) - stop - ;; - restart) - stop - start - ;; - *) - echo "Usage: $0 {start|stop|restart}" - esac - ``` - - Then run these commands to enable the service and start it: - - * `sudo chmod +x /etc/init.d/shlink_swoole` - * `sudo update-rc.d shlink_swoole defaults` - * `sudo update-rc.d shlink_swoole enable` - * `/etc/init.d/shlink_swoole start` - - Now again, you can access shlink on port 8080, but this time the service will be automatically run at system start-up, and all access logs will be written in `/var/log/shlink/shlink_swoole.log` (you will probably want to [rotate those logs](https://www.digitalocean.com/community/tutorials/how-to-manage-logfiles-with-logrotate-on-ubuntu-16-04). You can find an example logrotate config file [here](data/infra/examples/shlink-daemon-logrotate.conf)). - -Finally access to [https://app.shlink.io](https://app.shlink.io) and configure your server to start creating short URLs. - -### Bonus - -Geo-locating visits to your short links is a time-consuming task. When serving Shlink with swoole, the geo-location task is automatically run asynchronously just after a visit to a short URL happens. - -However, if you are not serving Shlink with swoole, you will have to schedule the geo-location task to be run regularly in the background (for example, using cron jobs): - -The command you need to run is `/path/to/shlink/bin/cli visit:locate`, and you can optionally provide the `-q` flag to remove any output and avoid your cron logs to be polluted. - -## Update to new version - -When a new Shlink version is available, you don't need to repeat the entire process. Instead, follow these steps: - -1. Rename your existing Shlink directory to something else (ie. `shlink` ---> `shlink-old`). -2. Download and extract the new version of Shlink, and set the directory name to that of the old version (ie. `shlink`). -3. Run the `bin/update` script in the new version's directory to migrate your configuration over. You will be asked to provide the path to the old instance (ie. `shlink-old`). -4. If you are using shlink with swoole, restart the service by running `/etc/init.d/shlink_swoole restart`. - -The `bin/update` will use the location from previous shlink version to import the configuration. It will then update the database and generate some assets shlink needs to work. - -**Important!** It is recommended that you don't skip any version when using this process. The update tool gets better on every version, but older versions might make assumptions. - -## Using a docker image - -Starting with version 1.15.0, an official docker image is provided. You can learn how to use it by reading [the docs](docker/README.md). - -The idea is that you can just generate a container using the image and provide custom config via env vars. - ## Using shlink Once shlink is installed, there are two main ways to interact with it: @@ -243,109 +82,13 @@ Once shlink is installed, there are two main ways to interact with it: * **The REST API**. The complete docs on how to use the API can be found [here](https://shlink.io/documentation/api-docs), and a sandbox which also documents every endpoint can be found in the [API Spec](https://api-spec.shlink.io/) portal. - However, you probably don't want to consume the raw API yourself. That's why a nice [web client](https://github.com/shlinkio/shlink-web-client) is provided that can be directly used from [https://app.shlink.io](https://app.shlink.io), or you can host it yourself too. + However, you probably don't want to consume the raw API yourself. That's why a nice [web client](https://github.com/shlinkio/shlink-web-client) is provided that can be directly used from [https://app.shlink.io](https://app.shlink.io), or hosted by yourself. Both the API and CLI allow you to do the same operations, except for API key management, which can be done from the command line interface only. -### Shlink CLI Help +## Contributing -``` -Usage: - command [options] [arguments] - -Options: - -h, --help Display this help message - -q, --quiet Do not output any message - -V, --version Display this application version - --ansi Force ANSI output - --no-ansi Disable ANSI output - -n, --no-interaction Do not ask any interactive question - -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - -Available commands: - help Displays help for a command - list Lists commands - api-key - api-key:disable Disables an API key. - api-key:generate Generates a new valid API key. - api-key:list Lists all the available API keys. - db - db:create Creates the database needed for shlink to work. It will do nothing if the database already exists - db:migrate Runs database migrations, which will ensure the shlink database is up to date. - short-url - short-url:delete Deletes a short URL - short-url:generate Generates a short URL for provided long URL and returns it - short-url:list List all short URLs - short-url:parse Returns the long URL behind a short code - short-url:visits Returns the detailed visits information for provided short code - tag - tag:create Creates one or more tags. - tag:delete Deletes one or more tags. - tag:list Lists existing tags. - tag:rename Renames one existing tag. - visit - visit:locate Resolves visits origin locations. -``` - -## Multiple domains - -While in many cases you will just have one short domain and you'll want all your short URLs to be served from it, there are some cases in which you might want to have multiple short domains served from the same Shlink instance. - -If that's the case, you need to understand how Shlink will behave when managing your short URLs or any of them is visited. - -### Management - -When you create a short URL it is possible to optionally pass a `domain` param. If you don't pass it, the short URL will be created for the default domain (the one provided during Shlink's installation or in the `SHORT_DOMAIN_HOST` env var when using the docker image). - -However, if you pass it, the short URL will be "linked" to that domain. - -> Note that, if the default domain is passed, Shlink will ignore it and will behave as if no `domain` param was provided. - -The main benefit of being able to pass the domain is that Shlink will allow the same custom slug to be used in multiple short URLs, as long as the domain is different (like `example.com/my-compaign`, `another.com/my-compaign` and `foo.com/my-compaign`). - -Then, each short URL will be tracked separately and you will be able to define specific tags and metadata for each one of them. - -However, this has a side effect. When you try to interact with an existing short URL (editing tags, editing meta, resolving it or deleting it), either from the REST API or the CLI tool, you will have to provide the domain appropriately. - -Let's imagine this situation. Shlink's default domain is `example.com`, and you have the next short URLs: - -* `https://example.com/abc123` -> a regular short URL where no domain was provided. -* `https://example.com/my-campaign` -> a regular short URL where no domain was provided, but it has a custom slug. -* `https://another.com/my-campaign` -> a short URL where the `another.com` domain was provided, and it has a custom slug. -* `https://another.com/def456` -> a short URL where the `another.com` domain was provided. - -These are some of the results you will get when trying to interact with them, depending on the params you provide: - -* Providing just the `abc123` short code -> the first URL will be matched. -* Providing just the `my-campaign` short code -> the second URL will be matched, since you did not specify a domain, therefor, Shlink looks for the one with the short code/slug `my-campaign` which is also linked to default domain (or not linked to any domain, to be more accurate). -* Providing the `my-campaign` short code and the `another.com` domain -> The third one will be matched. -* Providing just the `def456` short code -> Shlink will fail/not find any short URL, since there's none with the short code `def456` linked to default domain. -* Providing the `def456` short code and the `another.com` domain -> The fourth short URL will be matched. -* Providing any short code and the `foo.com` domain -> Again, no short URL will be found, as there's none linked to `foo.com` domain. - -### Visits - -Before adding support for multiple domains, you could point as many domains as you wanted to Shlink, and they would have always worked for existing short codes/slugs. - -In order to keep backwards compatibility, Shlink's behavior when a short URL is visited is slightly different, getting to fallback in some cases. - -Let's continue with previous example, and also consider we have three domains that will resolve to our Shlink instance, which are `example.com`, `another.com` and `foo.com`. - -With that in mind, this is how Shlink will behave when the next short URLs are visited: - -* `https://another.com/abc123` -> There was no short URL specifically defined for domain `another.com` and short code `abc123`, but it exists for default domain (`example.com`), so it will fall back to it and redirect to where `example.com/abc123` is configured to redirect. -* `https://example.com/def456` -> The fall-back does not happen from default domain to specific ones, only the other way around (like in previous case). Because of that, this one will result in a not-found URL, even though the `def456` short code exists for `another.com` domain. -* `https://foo.com/abc123` -> This will also fall-back to `example.com/abc123`, like in the first case. -* `https://another.com/non-existing` -> The combination of `another.com` domain with the `non-existing` slug does not exist, so Shlink will try to fall-back to the same but for default domain (`example.com`). However, since that combination does not exist either, it will result in a not-found URL. -* Any other short URL visited exactly as it was configured will, of course, resolve as expected. - -### Special redirects - -It is currently possible to configure some special redirects when the base domain is visited, a URL does not match, or an invalid/disabled short URL is visited. - -Those are configured during Shlink's installation or via env vars when using the docker image. - -Currently those are all shared for all domains serving the same Shlink instance, but the plan is to update that and allow specific ones for every existing domain. +If you are trying to find out how to run the project in development mode or how to provide contributions, read the [CONTRIBUTING](CONTRIBUTING.md) doc. --- diff --git a/bin/helper/mezzio-swoole b/bin/helper/mezzio-swoole new file mode 100755 index 00000000..2c341326 --- /dev/null +++ b/bin/helper/mezzio-swoole @@ -0,0 +1,51 @@ +#!/usr/bin/env php +get('config')['laminas-cli']['commands'] ?? [], + fn ($c, string $command) => str_starts_with($command, $commandsPrefix), +); +$registeredCommands = []; + +foreach ($commands as $newName => $commandServiceName) { + [, $oldName] = explode($commandsPrefix, $newName); + $registeredCommands[$oldName] = $commandServiceName; + + $container->addDelegator($commandServiceName, static function ($c, $n, callable $factory) use ($oldName) { + /** @var Command $command */ + $command = $factory(); + $command->setAliases([$oldName]); + + return $command; + }); +} + +$commandLine = new CommandLine('Mezzio web server', $version); +$commandLine->setAutoExit(true); +$commandLine->setCommandLoader(new ContainerCommandLoader($container, $registeredCommands)); +$commandLine->run(); diff --git a/bin/set-option b/bin/set-option new file mode 100755 index 00000000..ff727f30 --- /dev/null +++ b/bin/set-option @@ -0,0 +1,14 @@ +#!/usr/bin/env php +Alias for \"cs\", \"stan\", \"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", "stan": "Inspects code with phpstan", @@ -160,14 +156,17 @@ "test:unit:ci": "Runs unit test suites, generating all needed reports and logs for CI envs", "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", "test:db:mysql": "Runs database test suites on a MySQL database", "test:db:maria": "Runs database test suites on a MariaDB database", "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", - "infect": "Checks unit tests quality applying mutation testing", - "infect:ci": "Checks unit tests quality applying mutation testing with existing reports and logs", - "infect:test": "Checks unit tests quality applying mutation testing", + "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", "clean:dev": "Deletes artifacts which are gitignored and could affect dev env" }, "config": { diff --git a/config/autoload/cors.global.php b/config/autoload/cors.global.php new file mode 100644 index 00000000..58ad9428 --- /dev/null +++ b/config/autoload/cors.global.php @@ -0,0 +1,11 @@ + [ + 'max_age' => 3600, + ], + +]; diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index c08f66f2..639df7ec 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -4,12 +4,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Common; +use Happyr\DoctrineSpecification\EntitySpecificationRepository; + return [ 'entity_manager' => [ 'orm' => [ 'proxies_dir' => 'data/proxies', 'load_mappings_using_functional_style' => true, + 'default_repository_classname' => EntitySpecificationRepository::class, ], 'connection' => [ 'user' => '', diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index ba0b8332..a04d874b 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -14,6 +14,7 @@ return [ Option\Database\DatabasePortConfigOption::class, Option\Database\DatabaseUserConfigOption::class, Option\Database\DatabasePasswordConfigOption::class, + Option\Database\DatabaseUnixSocketConfigOption::class, Option\Database\DatabaseSqlitePathConfigOption::class, Option\Database\DatabaseMySqlOptionsConfigOption::class, Option\UrlShortener\ShortDomainHostConfigOption::class, diff --git a/config/autoload/templates.global.php b/config/autoload/templates.global.php deleted file mode 100644 index e1b457fa..00000000 --- a/config/autoload/templates.global.php +++ /dev/null @@ -1,17 +0,0 @@ - [ - 'extension' => 'phtml', - ], - - 'plates' => [ - 'extensions' => [ - // extension service names or instances - ], - ], - -]; diff --git a/config/config.php b/config/config.php index ba0657fc..cf9eb86b 100644 --- a/config/config.php +++ b/config/config.php @@ -15,7 +15,6 @@ return (new ConfigAggregator\ConfigAggregator([ Mezzio\ConfigProvider::class, Mezzio\Router\ConfigProvider::class, Mezzio\Router\FastRouteRouter\ConfigProvider::class, - Mezzio\Plates\ConfigProvider::class, Mezzio\Swoole\ConfigProvider::class, ProblemDetails\ConfigProvider::class, Diactoros\ConfigProvider::class, diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index 6b3c6612..3608257e 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -36,7 +36,7 @@ if ($isApiTest) { $buildDbConnection = function (): array { $driver = env('DB_DRIVER', 'sqlite'); - $isCi = env('TRAVIS', false); + $isCi = env('CI', false); $getMysqlHost = fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria'); $getCiMysqlPort = fn (string $driver) => $driver === 'mysql' ? '3307' : '3308'; diff --git a/data/infra/ci/install-ms-odbc.sh b/data/infra/ci/install-ms-odbc.sh index 8cd60580..1efdf8a3 100755 --- a/data/infra/ci/install-ms-odbc.sh +++ b/data/infra/ci/install-ms-odbc.sh @@ -3,7 +3,7 @@ set -ex curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - -curl https://packages.microsoft.com/config/ubuntu/16.04/prod.list > /etc/apt/sources.list.d/mssql-release.list +curl https://packages.microsoft.com/config/ubuntu/20.04/prod.list > /etc/apt/sources.list.d/mssql-release.list apt-get update ACCEPT_EULA=Y apt-get install msodbcsql17 apt-get install unixodbc-dev diff --git a/data/infra/examples/shlink-daemon.sh b/data/infra/examples/shlink-daemon.sh index a18ca65a..ce905721 100644 --- a/data/infra/examples/shlink-daemon.sh +++ b/data/infra/examples/shlink-daemon.sh @@ -8,7 +8,7 @@ # Description: Shlink non-blocking server with swoole ### END INIT INFO -SCRIPT=/path/to/shlink/vendor/bin/mezzio-swoole\ start +SCRIPT=/path/to/shlink/vendor/bin/laminas\ mezzio:swoole:start RUNAS=root PIDFILE=/var/run/shlink_swoole.pid diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index 00d197ba..bb1f084c 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -4,7 +4,7 @@ MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.18 ENV APCU_BC_VERSION 1.0.5 ENV INOTIFY_VERSION 2.0.0 -ENV SWOOLE_VERSION 4.5.5 +ENV SWOOLE_VERSION 4.5.9 RUN apk update @@ -95,4 +95,4 @@ CMD \ if [[ ! -d "./vendor" ]]; then /usr/local/bin/composer install ; fi && \ # When restarting the container, swoole 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/mezzio-swoole start; do sleep 1 ; done + until php ./vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done diff --git a/data/migrations/Version20180913205455.php b/data/migrations/Version20180913205455.php index 8afa316b..c2bc2070 100644 --- a/data/migrations/Version20180913205455.php +++ b/data/migrations/Version20180913205455.php @@ -58,7 +58,7 @@ final class Version20180913205455 extends AbstractMigration } try { - return (string) IpAddress::fromString($addr)->getObfuscatedCopy(); + return (string) IpAddress::fromString($addr)->getAnonymizedCopy(); } catch (InvalidArgumentException $e) { return null; } diff --git a/data/migrations/Version20210102174433.php b/data/migrations/Version20210102174433.php new file mode 100644 index 00000000..95ee62fe --- /dev/null +++ b/data/migrations/Version20210102174433.php @@ -0,0 +1,52 @@ +skipIf($schema->hasTable(self::TABLE_NAME)); + + $table = $schema->createTable(self::TABLE_NAME); + $table->addColumn('id', Types::BIGINT, [ + 'unsigned' => true, + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->setPrimaryKey(['id']); + + $table->addColumn('role_name', Types::STRING, [ + 'length' => 256, + 'notnull' => true, + ]); + $table->addColumn('meta', Types::JSON, [ + 'notnull' => true, + ]); + + $table->addColumn('api_key_id', Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + $table->addForeignKeyConstraint('api_keys', ['api_key_id'], ['id'], [ + 'onDelete' => 'CASCADE', + 'onUpdate' => 'RESTRICT', + ]); + $table->addUniqueIndex(['role_name', 'api_key_id'], 'UQ_role_plus_api_key'); + } + + public function down(Schema $schema): void + { + $this->skipIf(! $schema->hasTable(self::TABLE_NAME)); + $schema->getTable(self::TABLE_NAME)->dropIndex('UQ_role_plus_api_key'); + $schema->dropTable(self::TABLE_NAME); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index d700f3b3..ba4558e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -131,7 +131,7 @@ services: shlink_mercure: container_name: shlink_mercure - image: dunglas/mercure:v0.9 + image: dunglas/mercure:v0.10 ports: - "3080:80" environment: diff --git a/docker/README.md b/docker/README.md index 2cc0b5b9..5269ebb6 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,76 +1,21 @@ # Shlink Docker image -[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/) +[![Docker build status](https://img.shields.io/github/workflow/status/shlinkio/shlink/Build%20docker%20image?logo=docker&style=flat-square)](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Build+docker+image%22) +[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/) 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 persists data in a local [sqlite](https://www.sqlite.org/index.html) database. +It exposes a shlink instance served with [swoole](https://www.swoole.co.uk/), which can be linked to external databases to persist data. ## Usage -Shlink docker image exposes port `8080` in order to interact with its HTTP interface. - -It also expects these two env vars to be provided, in order to properly generate short URLs at runtime. +The most basic way to run Shlink's docker image is by providing these mandatory env vars. * `SHORT_DOMAIN_HOST`: The custom short domain used for this shlink instance. For example **doma.in**. * `SHORT_DOMAIN_SCHEMA`: Either **http** or **https**. +* `GEOLITE_LICENSE_KEY`: Your GeoLite2 license key. [Learn more](https://shlink.io/documentation/geolite-license-key/) about this. -So based on this, to run shlink on a local docker service, you should run a command like this: - -```bash -docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https -e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 shlinkio/shlink:stable -``` - -### Interact with shlink's CLI on a running container. - -Once the shlink container is running, you can interact with the CLI tool by running `shlink` with any of the supported commands. - -For example, if the container is called `shlink_container`, you can generate a new API key with: - -```bash -docker exec -it shlink_container shlink api-key:generate -``` - -Or you can list all tags with: - -```bash -docker exec -it shlink_container shlink tag:list -``` - -Or locate remaining visits with: - -```bash -docker exec -it shlink_container shlink visit:locate -``` - -All shlink commands will work the same way. - -You can also list all available commands just by running this: - -```bash -docker exec -it shlink_container shlink -``` - -## Use an external DB - -The image comes with a working sqlite database, but in production you will probably want to usa a distributed database. - -It is possible to use a set of env vars to make this shlink instance interact with an external MySQL, MariaDB, PostgreSQL or Microsoft SQL Server database. - -* `DB_DRIVER`: **[Mandatory]**. Use the value **mysql**, **maria**, **postgres** or **mssql** to prevent the sqlite database to be used. -* `DB_NAME`: [Optional]. The database name to be used. Defaults to **shlink**. -* `DB_USER`: **[Mandatory]**. The username credential for the database server. -* `DB_PASSWORD`: **[Mandatory]**. The password credential for the database server. -* `DB_HOST`: **[Mandatory]**. The host name of the server running the database engine. -* `DB_PORT`: [Optional]. The port in which the database service is running. - * Default value is based on the value provided for `DB_DRIVER`: - * **mysql** or **maria** -> `3306` - * **postgres** -> `5432` - * **mssql** -> `1433` - -> PostgreSQL is supported since v1.16.1 and Microsoft SQL server since v2.1.0. Do not try to use them with previous versions. - -Taking this into account, you could run shlink on a local docker service like this: +To run shlink on top of a local docker service, and using an internal SQLite database, do the following: ```bash docker run \ @@ -78,222 +23,12 @@ docker run \ -p 8080:8080 \ -e SHORT_DOMAIN_HOST=doma.in \ -e SHORT_DOMAIN_SCHEMA=https \ - -e DB_DRIVER=mysql \ - -e DB_USER=root \ - -e DB_PASSWORD=123abc \ - -e DB_HOST=something.rds.amazonaws.com \ - shlinkio/shlink:stable -``` - -You could even link to a local database running on a different container: - -```bash -docker run \ - --name shlink \ - -p 8080:8080 \ - [...] \ - -e DB_HOST=some_mysql_container \ - --link some_mysql_container \ - shlinkio/shlink:stable -``` - -> If you have considered using SQLite but sharing the database file with a volume, read [this issue](https://github.com/shlinkio/shlink-docker-image/issues/40) first. - -## Other integrations - -### Use an external redis server - -If you plan to run more than one Shlink instance, there are some resources that should be shared ([Multi instance considerations](#multi-instance-considerations)). - -One of those resources are the locks Shlink generates to prevent some operations to be run more than once in parallel (in the future, these redis servers could be used for other caching operations). - -In order to share those locks, you should use an external redis server (or a cluster of redis servers), by providing the `REDIS_SERVERS` env var. - -It can be either one server name or a comma-separated list of servers. - -> If more than one redis server is provided, Shlink will expect them to be configured as a [redis cluster](https://redis.io/topics/cluster-tutorial). - -### Integrate with a mercure hub server - -One way to get real time updates when certain events happen in Shlink is by integrating it with a [mercure hub](https://mercure.rocks/) server. - -If you do that, Shlink will publish updates and other clients can subscribe to those. - -There are three env vars you need to provide if you want to enable this: - -* `MERCURE_PUBLIC_HUB_URL`: **[Mandatory]**. The public URL of a mercure hub server to which Shlink will sent updates. This URL will also be served to consumers that want to subscribe to those updates. -* `MERCURE_INTERNAL_HUB_URL`: **[Optional]**. An internal URL for a mercure hub. Will be used only when publishing updates to mercure, and does not need to be public. If this is not provided, the `MERCURE_PUBLIC_HUB_URL` one will be used to publish updates. -* `MERCURE_JWT_SECRET`: **[Mandatory]**. The secret key that was provided to the mercure hub server, in order to be able to generate valid JWTs for publishing/subscribing to that server. - -So in order to run shlink with mercure integration, you would do it like this: - -```bash -docker run \ - --name shlink \ - -p 8080:8080 \ - -e SHORT_DOMAIN_HOST=doma.in \ - -e SHORT_DOMAIN_SCHEMA=https \ - -e "MERCURE_PUBLIC_HUB_URL=https://example.com" - -e "MERCURE_INTERNAL_HUB_URL=http://my-mercure-hub.prod.svc.cluster.local" - -e MERCURE_JWT_SECRET=super_secret_key - shlinkio/shlink:stable -``` - -## All supported env vars - -A few env vars have been already used in previous examples, but this image supports others that can be used to customize its behavior. - -This is the complete list of supported env vars: - -* `SHORT_DOMAIN_HOST`: The custom short domain used for this shlink instance. For example **doma.in**. -* `SHORT_DOMAIN_SCHEMA`: Either **http** or **https**. -* `DB_DRIVER`: **sqlite** (which is the default value), **mysql**, **maria**, **postgres** or **mssql**. -* `DB_NAME`: The database name to be used when using an external database driver. Defaults to **shlink**. -* `DB_USER`: The username credential to be used when using an external database driver. -* `DB_PASSWORD`: The password credential to be used when using an external database driver. -* `DB_HOST`: The host name of the database server when using an external database driver. -* `DB_PORT`: The port in which the database service is running when using an external database driver. - * Default value is based on the value provided for `DB_DRIVER`: - * **mysql** or **maria** -> `3306` - * **postgres** -> `5432` - * **mssql** -> `1433` -* `DISABLE_TRACK_PARAM`: The name of a query param that can be used to visit short URLs avoiding the visit to be tracked. This feature won't be available if not value is provided. -* `DELETE_SHORT_URL_THRESHOLD`: The amount of visits on short URLs which will not allow them to be deleted. Defaults to `15`. -* `VALIDATE_URLS`: Boolean which tells if shlink should validate a status 20x is returned (after following redirects) when trying to shorten a URL. Defaults to `false`. -* `INVALID_SHORT_URL_REDIRECT_TO`: If a URL is provided here, when a user tries to access an invalid short URL, he/she will be redirected to this value. If this env var is not provided, the user will see a generic `404 - not found` page. -* `REGULAR_404_REDIRECT_TO`: If a URL is provided here, when a user tries to access a URL not matching any one supported by the router, he/she will be redirected to this value. If this env var is not provided, the user will see a generic `404 - not found` page. -* `BASE_URL_REDIRECT_TO`: If a URL is provided here, when a user tries to access Shlink's base URL, he/she will be redirected to this value. If this env var is not provided, the user will see a generic `404 - not found` page. -* `BASE_PATH`: The base path from which you plan to serve shlink, in case you don't want to serve it from the root of the domain. Defaults to `''`. -* `WEB_WORKER_NUM`: The amount of concurrent http requests this shlink instance will be able to server. Defaults to 16. -* `TASK_WORKER_NUM`: The amount of concurrent background tasks this shlink instance will be able to execute. Defaults to 16. -* `VISITS_WEBHOOKS`: A comma-separated list of URLs that will receive a `POST` request when a short URL receives a visit. -* `DEFAULT_SHORT_CODES_LENGTH`: The length you want generated short codes to have. It defaults to 5 and has to be at least 4, so any value smaller than that will fall back to 4. -* `GEOLITE_LICENSE_KEY`: The license key used to download new GeoLite2 database files. This is not mandatory, as a default license key is provided, but it is **strongly recommended** that you provide your own. Go to [https://shlink.io/documentation/geolite-license-key](https://shlink.io/documentation/geolite-license-key) to know how to generate it. -* `REDIS_SERVERS`: A comma-separated list of redis servers where Shlink locks are stored (locks are used to prevent some operations to be run more than once in parallel). -* `MERCURE_PUBLIC_HUB_URL`: The public URL of a mercure hub server to which Shlink will sent updates. This URL will also be served to consumers that want to subscribe to those updates. -* `MERCURE_INTERNAL_HUB_URL`: An internal URL for a mercure hub. Will be used only when publishing updates to mercure, and does not need to be public. If this is not provided but `MERCURE_PUBLIC_HUB_URL` was, the former one will be used to publish updates. -* `MERCURE_JWT_SECRET`: The secret key that was provided to the mercure hub server, in order to be able to generate valid JWTs for publishing/subscribing to that server. -* `ANONYMIZE_REMOTE_ADDR`: Tells if IP addresses from visitors should be obfuscated before storing them in the database. Default value is `true`. **Careful!** Setting this to `false` will make your Shlink instance no longer be in compliance with the GDPR and other similar data protection regulations. -* `REDIRECT_STATUS_CODE`: Either **301** or **302**. Used to determine if redirects from short to long URLs should be done with a 301 or 302 status. Defaults to 302. -* `REDIRECT_CACHE_LIFETIME`: Allows to set the amount of seconds that redirects should be cached when redirect status is 301. Default values is 30. -* `PORT`: Can be used to set the port in which shlink listens. Defaults to 8080 (Some cloud providers, like Google cloud or Heroku, expect to be able to customize exposed port by providing this env var). - -An example using all env vars could look like this: - -```bash -docker run \ - --name shlink \ - -p 8080:8888 \ - -e SHORT_DOMAIN_HOST=doma.in \ - -e SHORT_DOMAIN_SCHEMA=https \ - -e PORT=8888 \ - -e DB_DRIVER=mysql \ - -e DB_NAME=shlink \ - -e DB_USER=root \ - -e DB_PASSWORD=123abc \ - -e DB_HOST=something.rds.amazonaws.com \ - -e DB_PORT=3306 \ - -e DISABLE_TRACK_PARAM="no-track" \ - -e DELETE_SHORT_URL_THRESHOLD=30 \ - -e VALIDATE_URLS=true \ - -e "INVALID_SHORT_URL_REDIRECT_TO=https://my-landing-page.com" \ - -e "REGULAR_404_REDIRECT_TO=https://my-landing-page.com" \ - -e "BASE_URL_REDIRECT_TO=https://my-landing-page.com" \ - -e "REDIS_SERVERS=tcp://172.20.0.1:6379,tcp://172.20.0.2:6379" \ - -e "BASE_PATH=/my-campaign" \ - -e WEB_WORKER_NUM=64 \ - -e TASK_WORKER_NUM=32 \ - -e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \ - -e DEFAULT_SHORT_CODES_LENGTH=6 \ -e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 \ - -e "MERCURE_PUBLIC_HUB_URL=https://example.com" \ - -e "MERCURE_INTERNAL_HUB_URL=http://my-mercure-hub.prod.svc.cluster.local" \ - -e MERCURE_JWT_SECRET=super_secret_key \ - -e ANONYMIZE_REMOTE_ADDR=false \ - -e REDIRECT_STATUS_CODE=301 \ - -e REDIRECT_CACHE_LIFETIME=90 \ shlinkio/shlink:stable ``` -## Provide config via volumes +## Full documentation -Rather than providing custom configuration via env vars, it is also possible ot provide config files in json format. +All the features supported by Shlink are also supported by the docker image. -Mounting a volume at `config/params` you will make shlink load all the files on it with the `.config.json` suffix. - -The whole configuration should have this format, but it can be split into multiple files that will be merged: - -```json -{ - "disable_track_param": "my_param", - "delete_short_url_threshold": 30, - "short_domain_schema": "https", - "short_domain_host": "doma.in", - "validate_url": true, - "invalid_short_url_redirect_to": "https://my-landing-page.com", - "regular_404_redirect_to": "https://my-landing-page.com", - "base_url_redirect_to": "https://my-landing-page.com", - "base_path": "/my-campaign", - "web_worker_num": 64, - "task_worker_num": 32, - "default_short_codes_length": 6, - "redis_servers": [ - "tcp://172.20.0.1:6379", - "tcp://172.20.0.2:6379" - ], - "visits_webhooks": [ - "http://my-api.com/api/v2.3/notify", - "https://third-party.io/foo" - ], - "db_config": { - "driver": "pdo_mysql", - "dbname": "shlink", - "user": "root", - "password": "123abc", - "host": "something.rds.amazonaws.com", - "port": "3306" - }, - "geolite_license_key": "kjh23ljkbndskj345", - "mercure_public_hub_url": "https://example.com", - "mercure_internal_hub_url": "http://my-mercure-hub.prod.svc.cluster.local", - "mercure_jwt_secret": "super_secret_key", - "anonymize_remote_addr": false, - "redirect_status_code": 301, - "redirect_cache_lifetime": 90, - "port": 8888 -} -``` - -> This is internally parsed to how shlink expects the config. If you are using a version previous to 1.17.0, this parser is not present and you need to provide a config structure like the one [documented previously](https://github.com/shlinkio/shlink-docker-image/tree/v1.16.3#provide-config-via-volumes). - -Once created just run shlink with the volume: - -```bash -docker run --name shlink -p 8080:8080 -v ${PWD}/my/config/dir:/etc/shlink/config/params shlinkio/shlink:stable -``` - -## Multi-architecture - -Starting on v2.3.0, Shlink's docker image is built for multiple architectures. - -The only limitation is that images for architectures other than `amd64` will not have support for Microsoft SQL databases, since there are no official binaries. - -## Multi-instance considerations - -These are some considerations to take into account when running multiple instances of shlink. - -* Some operations performed by Shlink should never be run more than once at the same time (like creating the database for the first time, or downloading the GeoLite2 database). For this reason, Shlink uses a locking system. - - However, these locks are locally scoped to each Shlink instance by default. - - You can (and should) make the locks to be shared by all Shlink instances by using a redis server/cluster. Just define the `REDIS_SERVERS` env var with the list of servers. - -## Versions - -Versioning on this docker image works as follows: - -* `X.X.X`: when providing a specific version number, the image version will match the shlink version it contains. For example, installing `shlinkio/shlink:1.15.0`, you will get an image containing shlink v1.15.0. -* `stable`: always holds the latest stable tag. For example, if latest shlink version is 2.0.0, installing `shlinkio/shlink:stable`, you will get an image containing shlink v2.0.0 -* `latest`: always holds the latest contents, and it's considered unstable and not suitable for production. - -> **Important**: The docker image was introduced with shlink v1.15.0, so there are no official images previous to that versions. +If you want to learn more, visit the [full documentation](https://shlink.io/documentation/install-docker-image/). diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index c4502b7c..c6d7f69e 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -34,6 +34,7 @@ $helper = new class { public function getDbConfig(): array { $driver = env('DB_DRIVER'); + $isMysql = contains(['maria', 'mysql'], $driver); if ($driver === null || $driver === 'sqlite') { return [ 'driver' => 'pdo_sqlite', @@ -41,7 +42,7 @@ $helper = new class { ]; } - $driverOptions = ! contains(['maria', 'mysql'], $driver) ? [] : [ + $driverOptions = ! $isMysql ? [] : [ // 1002 -> PDO::MYSQL_ATTR_INIT_COMMAND 1002 => 'SET NAMES utf8', // 1000 -> PDO::MYSQL_ATTR_USE_BUFFERED_QUERY @@ -52,9 +53,10 @@ $helper = new class { 'dbname' => env('DB_NAME', 'shlink'), 'user' => env('DB_USER'), 'password' => env('DB_PASSWORD'), - 'host' => env('DB_HOST'), + 'host' => env('DB_HOST', $driver === 'postgres' ? env('DB_UNIX_SOCKET') : null), 'port' => env('DB_PORT', self::DB_PORTS_MAP[$driver]), 'driverOptions' => $driverOptions, + 'unix_socket' => $isMysql ? env('DB_UNIX_SOCKET') : null, ]; } diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 055e315f..df480d2f 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -17,4 +17,4 @@ php vendor/doctrine/orm/bin/doctrine.php orm:clear-cache:metadata -n -q # When restarting the container, swoole 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/mezzio/mezzio-swoole/bin/mezzio-swoole start; do sleep 1 ; done +until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index a89dd187..a81853d8 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -191,7 +191,7 @@ "Short URLs" ], "summary": "Create short URL", - "description": "Creates a new short URL.

**Param findIfExists:**: Starting with v1.16, this new param allows to force shlink to return existing short URLs when found based on provided params, instead of creating a new one. However, it might add complexity and have unexpected outputs.\n\nThese are the use cases:\n* Only the long URL is provided: It will return the newest match or create a new short URL if none is found.\n* Long url and custom slug are provided: It will return the short URL when both params match, return an error when the slug is in use for another long URL, or create a new short URL otherwise.\n* Any of the above but including other params (tags, validSince, validUntil, maxVisits): It will behave the same as the previous two cases, but it will try to exactly match existing results using all the params. If any of them does not match, it will try to create a new short URL.", + "description": "Creates a new short URL.

**Param findIfExists**: This new param allows to force shlink to return existing short URLs when found based on provided params, instead of creating a new one. However, it might add complexity and have unexpected outputs.\n\nThese are the use cases:\n* Only the long URL is provided: It will return the newest match or create a new short URL if none is found.\n* Long url and custom slug are provided: It will return the short URL when both params match, return an error when the slug is in use for another long URL, or create a new short URL otherwise.\n* Any of the above but including other params (tags, validSince, validUntil, maxVisits): It will behave the same as the previous two cases, but it will try to exactly match existing results using all the params. If any of them does not match, it will try to create a new short URL.", "security": [ { "ApiKey": [] diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json index cb6a6bb3..8c3ada73 100644 --- a/docs/swagger/paths/v1_tags.json +++ b/docs/swagger/paths/v1_tags.json @@ -232,6 +232,16 @@ } } }, + "403": { + "description": "The API key you used does not have permissions to rename tags.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + }, "404": { "description": "There's no tag found with the name provided in oldName param.", "content": { @@ -298,6 +308,16 @@ "204": { "description": "Tags properly deleted" }, + "403": { + "description": "The API key you used does not have permissions to delete tags.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + }, "500": { "description": "Unexpected error.", "content": { diff --git a/docs/swagger/paths/{shortCode}_qr-code.json b/docs/swagger/paths/{shortCode}_qr-code.json index a3fdaffb..3714f802 100644 --- a/docs/swagger/paths/{shortCode}_qr-code.json +++ b/docs/swagger/paths/{shortCode}_qr-code.json @@ -18,7 +18,7 @@ }, { "name": "size", - "in": "path", + "in": "query", "description": "The size of the image to be returned.", "required": false, "schema": { diff --git a/docs/swagger/paths/{shortCode}_qr-code_{size}.json b/docs/swagger/paths/{shortCode}_qr-code_{size}.json new file mode 100644 index 00000000..fb5dd33e --- /dev/null +++ b/docs/swagger/paths/{shortCode}_qr-code_{size}.json @@ -0,0 +1,66 @@ +{ + "get": { + "operationId": "shortUrlQrCodeSize", + "deprecated": true, + "tags": [ + "URL Shortener" + ], + "summary": "Short URL QR code", + "description": "Generates a QR code image pointing to a short URL", + "parameters": [ + { + "name": "shortCode", + "in": "path", + "description": "The short code to resolve.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "size", + "in": "path", + "description": "The size of the image to be returned.", + "required": false, + "schema": { + "type": "integer", + "minimum": 50, + "maximum": 1000, + "default": 300 + } + }, + { + "name": "format", + "in": "query", + "description": "The format for the QR code image, being valid values png and svg. Not providing the param or providing any other value will fall back to png.", + "required": false, + "schema": { + "type": "string", + "enum": [ + "png", + "svg" + ] + } + } + ], + "responses": { + "200": { + "description": "QR code in PNG format", + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/svg+xml": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } +} diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 8dc04412..dc834905 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -116,6 +116,9 @@ }, "/{shortCode}/qr-code": { "$ref": "paths/{shortCode}_qr-code.json" + }, + "/{shortCode}/qr-code/{size}": { + "$ref": "paths/{shortCode}_qr-code_{size}.json" } } } diff --git a/infection-db.json b/infection-db.json new file mode 100644 index 00000000..a429c995 --- /dev/null +++ b/infection-db.json @@ -0,0 +1,23 @@ +{ + "source": { + "directories": [ + "module/*/src" + ] + }, + "timeout": 5, + "logs": { + "text": "build/infection-db/infection-log.txt", + "summary": "build/infection-db/summary-log.txt", + "debug": "build/infection-db/debug-log.txt" + }, + "tmpDir": "build/infection-db/temp", + "phpUnit": { + "configDir": "." + }, + "testFrameworkOptions": "--configuration=phpunit-db.xml", + "mutators": { + "@default": true, + "IdenticalEqual": false, + "NotIdenticalNotEqual": false + } +} diff --git a/infection.json b/infection.json index 44fdf228..b182bddf 100644 --- a/infection.json +++ b/infection.json @@ -6,11 +6,11 @@ }, "timeout": 5, "logs": { - "text": "build/infection/infection-log.txt", - "summary": "build/infection/summary-log.txt", - "debug": "build/infection/debug-log.txt" + "text": "build/infection-unit/infection-log.txt", + "summary": "build/infection-unit/summary-log.txt", + "debug": "build/infection-unit/debug-log.txt" }, - "tmpDir": "build/infection/temp", + "tmpDir": "build/infection-unit/temp", "phpUnit": { "configDir": "." }, diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 199d29ef..3c9d74ce 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -8,7 +8,6 @@ use Doctrine\DBAL\Connection; use GeoIp2\Database\Reader; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\Factory\InvokableFactory; -use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory; use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Service; @@ -32,7 +31,8 @@ return [ SymfonyCli\Helper\ProcessHelper::class => ProcessHelperFactory::class, PhpExecutableFinder::class => InvokableFactory::class, - GeolocationDbUpdater::class => ConfigAbstractFactory::class, + Util\GeolocationDbUpdater::class => ConfigAbstractFactory::class, + ApiKey\RoleResolver::class => ConfigAbstractFactory::class, Command\ShortUrl\GenerateShortUrlCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class, @@ -59,7 +59,8 @@ return [ ], ConfigAbstractFactory::class => [ - GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY], + Util\GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY], + ApiKey\RoleResolver::class => [DomainService::class], Command\ShortUrl\GenerateShortUrlCommand::class => [ Service\UrlShortener::class, @@ -75,10 +76,10 @@ return [ Visit\VisitLocator::class, IpLocationResolverInterface::class, LockFactory::class, - GeolocationDbUpdater::class, + Util\GeolocationDbUpdater::class, ], - Command\Api\GenerateKeyCommand::class => [ApiKeyService::class], + Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class], Command\Api\DisableKeyCommand::class => [ApiKeyService::class], Command\Api\ListKeysCommand::class => [ApiKeyService::class], @@ -87,7 +88,7 @@ return [ Command\Tag\RenameTagCommand::class => [TagService::class], Command\Tag\DeleteTagsCommand::class => [TagService::class], - Command\Domain\ListDomainsCommand::class => [DomainService::class, 'config.url_shortener.domain.hostname'], + Command\Domain\ListDomainsCommand::class => [DomainService::class], Command\Db\CreateDatabaseCommand::class => [ LockFactory::class, diff --git a/module/CLI/src/ApiKey/RoleResolver.php b/module/CLI/src/ApiKey/RoleResolver.php new file mode 100644 index 00000000..67747983 --- /dev/null +++ b/module/CLI/src/ApiKey/RoleResolver.php @@ -0,0 +1,36 @@ +domainService = $domainService; + } + + public function determineRoles(InputInterface $input): array + { + $domainAuthority = $input->getOption('domain-only'); + $author = $input->getOption('author-only'); + + $roleDefinitions = []; + if ($author) { + $roleDefinitions[] = RoleDefinition::forAuthoredShortUrls(); + } + if ($domainAuthority !== null) { + $domain = $this->domainService->getOrCreate($domainAuthority); + $roleDefinitions[] = RoleDefinition::forDomain($domain); + } + + return $roleDefinitions; + } +} diff --git a/module/CLI/src/ApiKey/RoleResolverInterface.php b/module/CLI/src/ApiKey/RoleResolverInterface.php new file mode 100644 index 00000000..98d50483 --- /dev/null +++ b/module/CLI/src/ApiKey/RoleResolverInterface.php @@ -0,0 +1,19 @@ +apiKeyService = $apiKeyService; parent::__construct(); + $this->apiKeyService = $apiKeyService; + $this->roleResolver = $roleResolver; } protected function configure(): void { + $authorOnly = RoleResolverInterface::AUTHOR_ONLY_PARAM; + $domainOnly = RoleResolverInterface::DOMAIN_ONLY_PARAM; + $help = <<%command.name% generates a new valid API key. + + %command.full_name% + + You can optionally set its expiration date with --expirationDate or -e: + + %command.full_name% --expirationDate 2020-01-01 + + You can also set roles to the API key: + + * Can interact with short URLs created with this API key: %command.full_name% --{$authorOnly} + * Can interact with short URLs for one domain: %command.full_name% --{$domainOnly}=example.com + * Both: %command.full_name% --{$authorOnly} --{$domainOnly}=example.com + HELP; + $this ->setName(self::NAME) ->setDescription('Generates a new valid API key.') @@ -37,15 +61,42 @@ class GenerateKeyCommand extends Command 'e', InputOption::VALUE_REQUIRED, 'The date in which the API key should expire. Use any valid PHP format.', - ); + ) + ->addOption( + $authorOnly, + 'a', + InputOption::VALUE_NONE, + sprintf('Adds the "%s" role to the new API key.', Role::AUTHORED_SHORT_URLS), + ) + ->addOption( + $domainOnly, + 'd', + InputOption::VALUE_REQUIRED, + sprintf('Adds the "%s" role to the new API key, with the domain provided.', Role::DOMAIN_SPECIFIC), + ) + ->setHelp($help); } protected function execute(InputInterface $input, OutputInterface $output): ?int { $expirationDate = $input->getOption('expirationDate'); - $apiKey = $this->apiKeyService->create(isset($expirationDate) ? Chronos::parse($expirationDate) : null); + $apiKey = $this->apiKeyService->create( + isset($expirationDate) ? Chronos::parse($expirationDate) : null, + ...$this->roleResolver->determineRoles($input), + ); + + $io = new SymfonyStyle($input, $output); + $io->success(sprintf('Generated API key: "%s"', $apiKey->toString())); + + if (! $apiKey->isAdmin()) { + ShlinkTable::fromOutput($io)->render( + ['Role name', 'Role metadata'], + $apiKey->mapRoles(fn (string $name, array $meta) => [$name, arrayToString($meta, 0)]), + null, + 'Roles', + ); + } - (new SymfonyStyle($input, $output))->success(sprintf('Generated API key: "%s"', $apiKey)); return ExitCodes::EXIT_SUCCESS; } } diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index f54ad8dd..cf09e614 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Api; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; +use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Symfony\Component\Console\Command\Command; @@ -14,7 +15,8 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use function array_filter; -use function array_map; +use function Functional\map; +use function implode; use function sprintf; class ListKeysCommand extends Command @@ -50,7 +52,7 @@ class ListKeysCommand extends Command { $enabledOnly = $input->getOption('enabledOnly'); - $rows = array_map(function (ApiKey $apiKey) use ($enabledOnly) { + $rows = map($this->apiKeyService->listKeys($enabledOnly), function (ApiKey $apiKey) use ($enabledOnly) { $expiration = $apiKey->getExpirationDate(); $messagePattern = $this->determineMessagePattern($apiKey); @@ -60,13 +62,21 @@ class ListKeysCommand extends Command $rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey)); } $rowData[] = $expiration !== null ? $expiration->toAtomString() : '-'; + $rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles( + fn (string $roleName, array $meta) => + empty($meta) + ? Role::toFriendlyName($roleName) + : sprintf('%s: %s', Role::toFriendlyName($roleName), Role::domainAuthorityFromMeta($meta)), + )); + return $rowData; - }, $this->apiKeyService->listKeys($enabledOnly)); + }); ShlinkTable::fromOutput($output)->render(array_filter([ 'Key', ! $enabledOnly ? 'Is enabled' : null, 'Expiration date', + 'Roles', ]), $rows); return ExitCodes::EXIT_SUCCESS; } @@ -80,8 +90,6 @@ class ListKeysCommand extends Command return $apiKey->isExpired() ? self::WARNING_STRING_PATTERN : self::SUCCESS_STRING_PATTERN; } - /** - */ private function getEnabledSymbol(ApiKey $apiKey): string { return ! $apiKey->isEnabled() || $apiKey->isExpired() ? '---' : '+++'; diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php index 0368f1dd..ddcfa1bd 100644 --- a/module/CLI/src/Command/Domain/ListDomainsCommand.php +++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Domain; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; -use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -19,13 +19,11 @@ class ListDomainsCommand extends Command public const NAME = 'domain:list'; private DomainServiceInterface $domainService; - private string $defaultDomain; - public function __construct(DomainServiceInterface $domainService, string $defaultDomain) + public function __construct(DomainServiceInterface $domainService) { parent::__construct(); $this->domainService = $domainService; - $this->defaultDomain = $defaultDomain; } protected function configure(): void @@ -37,12 +35,12 @@ class ListDomainsCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): ?int { - $regularDomains = $this->domainService->listDomainsWithout($this->defaultDomain); + $domains = $this->domainService->listDomains(); - ShlinkTable::fromOutput($output)->render(['Domain', 'Is default'], [ - [$this->defaultDomain, 'Yes'], - ...map($regularDomains, fn (Domain $domain) => [$domain->getAuthority(), 'No']), - ]); + ShlinkTable::fromOutput($output)->render( + ['Domain', 'Is default'], + map($domains, fn (DomainItem $domain) => [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No']), + ); return ExitCodes::EXIT_SUCCESS; } diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index fe42a832..8bfb0242 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; +use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -42,7 +43,7 @@ class RenameTagCommand extends Command $newName = $input->getArgument('newName'); try { - $this->tagService->renameTag($oldName, $newName); + $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName)); $io->success('Tag properly renamed.'); return ExitCodes::EXIT_SUCCESS; } catch (TagNotFoundException | TagConflictException $e) { diff --git a/module/CLI/test/ApiKey/RoleResolverTest.php b/module/CLI/test/ApiKey/RoleResolverTest.php new file mode 100644 index 00000000..a50c2b12 --- /dev/null +++ b/module/CLI/test/ApiKey/RoleResolverTest.php @@ -0,0 +1,82 @@ +domainService = $this->prophesize(DomainServiceInterface::class); + $this->resolver = new RoleResolver($this->domainService->reveal()); + } + + /** + * @test + * @dataProvider provideRoles + */ + public function properRolesAreResolvedBasedOnInput( + InputInterface $input, + array $expectedRoles, + int $expectedDomainCalls + ): void { + $getDomain = $this->domainService->getOrCreate('example.com')->willReturn( + (new Domain('example.com'))->setId('1'), + ); + + $result = $this->resolver->determineRoles($input); + + self::assertEquals($expectedRoles, $result); + $getDomain->shouldHaveBeenCalledTimes($expectedDomainCalls); + } + + public function provideRoles(): iterable + { + $domain = (new Domain('example.com'))->setId('1'); + $buildInput = function (array $definition): InputInterface { + $input = $this->prophesize(InputInterface::class); + + foreach ($definition as $name => $value) { + $input->getOption($name)->willReturn($value); + } + + return $input->reveal(); + }; + + yield 'no roles' => [ + $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => false]), + [], + 0, + ]; + yield 'domain role only' => [ + $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => false]), + [RoleDefinition::forDomain($domain)], + 1, + ]; + yield 'author role only' => [ + $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => true]), + [RoleDefinition::forAuthoredShortUrls()], + 0, + ]; + yield 'both roles' => [ + $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => true]), + [RoleDefinition::forAuthoredShortUrls(), RoleDefinition::forDomain($domain)], + 1, + ]; + } +} diff --git a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php index 7ff87a3f..744fb482 100644 --- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php +++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php @@ -9,10 +9,12 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface; use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Symfony\Component\Console\Application; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Tester\CommandTester; class GenerateKeyCommandTest extends TestCase @@ -21,11 +23,15 @@ class GenerateKeyCommandTest extends TestCase private CommandTester $commandTester; private ObjectProphecy $apiKeyService; + private ObjectProphecy $roleResolver; public function setUp(): void { $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); - $command = new GenerateKeyCommand($this->apiKeyService->reveal()); + $this->roleResolver = $this->prophesize(RoleResolverInterface::class); + $this->roleResolver->determineRoles(Argument::type(InputInterface::class))->willReturn([]); + + $command = new GenerateKeyCommand($this->apiKeyService->reveal(), $this->roleResolver->reveal()); $app = new Application(); $app->add($command); $this->commandTester = new CommandTester($command); diff --git a/module/CLI/test/Command/Api/ListKeysCommandTest.php b/module/CLI/test/Command/Api/ListKeysCommandTest.php index ccf3b0ee..116f979d 100644 --- a/module/CLI/test/Command/Api/ListKeysCommandTest.php +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -8,6 +8,8 @@ use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand; +use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Symfony\Component\Console\Application; @@ -29,42 +31,87 @@ class ListKeysCommandTest extends TestCase $this->commandTester = new CommandTester($command); } - /** @test */ - public function everythingIsListedIfEnabledOnlyIsNotProvided(): void + /** + * @test + * @dataProvider provideKeysAndOutputs + */ + public function returnsExpectedOutput(array $keys, bool $enabledOnly, string $expected): void { - $this->apiKeyService->listKeys(false)->willReturn([ - new ApiKey(), - new ApiKey(), - new ApiKey(), - ])->shouldBeCalledOnce(); + $listKeys = $this->apiKeyService->listKeys($enabledOnly)->willReturn($keys); - $this->commandTester->execute([]); + $this->commandTester->execute(['--enabledOnly' => $enabledOnly]); $output = $this->commandTester->getDisplay(); - self::assertStringContainsString('Key', $output); - self::assertStringContainsString('Is enabled', $output); - self::assertStringContainsString(' +++ ', $output); - self::assertStringNotContainsString(' --- ', $output); - self::assertStringContainsString('Expiration date', $output); + self::assertEquals($expected, $output); + $listKeys->shouldHaveBeenCalledOnce(); } - /** @test */ - public function onlyEnabledKeysAreListedIfEnabledOnlyIsProvided(): void + public function provideKeysAndOutputs(): iterable { - $this->apiKeyService->listKeys(true)->willReturn([ - (new ApiKey())->disable(), - new ApiKey(), - ])->shouldBeCalledOnce(); + yield 'all keys' => [ + [ApiKey::withKey('foo'), ApiKey::withKey('bar'), ApiKey::withKey('baz')], + false, + <<commandTester->execute([ - '--enabledOnly' => true, - ]); - $output = $this->commandTester->getDisplay(); + OUTPUT, + ]; + yield 'enabled keys' => [ + [ApiKey::withKey('foo')->disable(), ApiKey::withKey('bar')], + true, + << [ + [ + ApiKey::withKey('foo'), + $this->apiKeyWithRoles('bar', [RoleDefinition::forAuthoredShortUrls()]), + $this->apiKeyWithRoles('baz', [RoleDefinition::forDomain((new Domain('example.com'))->setId('1'))]), + ApiKey::withKey('foo2'), + $this->apiKeyWithRoles('baz2', [ + RoleDefinition::forAuthoredShortUrls(), + RoleDefinition::forDomain((new Domain('example.com'))->setId('1')), + ]), + ApiKey::withKey('foo3'), + ], + true, + <<registerRole($role); + } + + return $apiKey; } } diff --git a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php index 500fed7f..a0f79448 100644 --- a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php +++ b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php @@ -10,7 +10,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; -use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; @@ -25,7 +25,7 @@ class ListDomainsCommandTest extends TestCase { $this->domainService = $this->prophesize(DomainServiceInterface::class); - $command = new ListDomainsCommand($this->domainService->reveal(), 'foo.com'); + $command = new ListDomainsCommand($this->domainService->reveal()); $app = new Application(); $app->add($command); @@ -45,9 +45,10 @@ class ListDomainsCommandTest extends TestCase +---------+------------+ OUTPUT; - $listDomains = $this->domainService->listDomainsWithout('foo.com')->willReturn([ - new Domain('bar.com'), - new Domain('baz.com'), + $listDomains = $this->domainService->listDomains()->willReturn([ + new DomainItem('foo.com', true), + new DomainItem('bar.com', false), + new DomainItem('baz.com', false), ]); $this->commandTester->execute([]); diff --git a/module/CLI/test/Command/Tag/RenameTagCommandTest.php b/module/CLI/test/Command/Tag/RenameTagCommandTest.php index 9764a111..d457c25d 100644 --- a/module/CLI/test/Command/Tag/RenameTagCommandTest.php +++ b/module/CLI/test/Command/Tag/RenameTagCommandTest.php @@ -10,6 +10,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; +use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; @@ -37,7 +38,9 @@ class RenameTagCommandTest extends TestCase { $oldName = 'foo'; $newName = 'bar'; - $renameTag = $this->tagService->renameTag($oldName, $newName)->willThrow(TagNotFoundException::fromTag('foo')); + $renameTag = $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName))->willThrow( + TagNotFoundException::fromTag('foo'), + ); $this->commandTester->execute([ 'oldName' => $oldName, @@ -54,7 +57,9 @@ class RenameTagCommandTest extends TestCase { $oldName = 'foo'; $newName = 'bar'; - $renameTag = $this->tagService->renameTag($oldName, $newName)->willReturn(new Tag($newName)); + $renameTag = $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName))->willReturn( + new Tag($newName), + ); $this->commandTester->execute([ 'oldName' => $oldName, diff --git a/module/CLI/test/ConfigProviderTest.php b/module/CLI/test/ConfigProviderTest.php index 42a8f504..863b8a1f 100644 --- a/module/CLI/test/ConfigProviderTest.php +++ b/module/CLI/test/ConfigProviderTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI; +use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\ConfigProvider; @@ -21,7 +22,9 @@ class ConfigProviderTest extends TestCase { $config = ($this->configProvider)(); + self::assertCount(3, $config); self::assertArrayHasKey('cli', $config); self::assertArrayHasKey('dependencies', $config); + self::assertArrayHasKey(ConfigAbstractFactory::class, $config); } } diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index b6beb1ac..a843a0a2 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; -use Mezzio\Template\TemplateRendererInterface; +use Laminas\ServiceManager\Factory\InvokableFactory; use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Core\ErrorHandler; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; @@ -16,7 +16,7 @@ return [ 'dependencies' => [ 'factories' => [ ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class, - ErrorHandler\NotFoundTemplateHandler::class => ConfigAbstractFactory::class, + ErrorHandler\NotFoundTemplateHandler::class => InvokableFactory::class, Options\AppOptions::class => ConfigAbstractFactory::class, Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class, @@ -60,7 +60,6 @@ return [ Util\RedirectResponseHelper::class, 'config.router.base_path', ], - ErrorHandler\NotFoundTemplateHandler::class => [TemplateRendererInterface::class], Options\AppOptions::class => ['config.app_options'], Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'], @@ -89,7 +88,7 @@ return [ ], Service\ShortUrl\ShortUrlResolver::class => ['em'], Service\ShortUrl\ShortCodeHelper::class => ['em'], - Domain\DomainService::class => ['em'], + Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'], Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class], Util\DoctrineBatchHelper::class => ['em'], diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index c72e2d7a..83390fdd 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -14,13 +14,13 @@ return [ 'events' => [ 'regular' => [ - EventDispatcher\VisitLocated::class => [ + EventDispatcher\Event\VisitLocated::class => [ EventDispatcher\NotifyVisitToMercure::class, EventDispatcher\NotifyVisitToWebHooks::class, ], ], 'async' => [ - EventDispatcher\ShortUrlVisited::class => [ + EventDispatcher\Event\ShortUrlVisited::class => [ EventDispatcher\LocateShortUrlVisit::class, ], ], diff --git a/module/Core/config/mezzio.config.php b/module/Core/config/mezzio.config.php deleted file mode 100644 index 5e4acb22..00000000 --- a/module/Core/config/mezzio.config.php +++ /dev/null @@ -1,14 +0,0 @@ - [ - 'error_handler' => [ - 'template_404' => 'ShlinkCore::error/404', - 'template_error' => 'ShlinkCore::error/error', - ], - ], - -]; diff --git a/module/Core/config/routes.config.php b/module/Core/config/routes.config.php index 82abef30..a95e8e96 100644 --- a/module/Core/config/routes.config.php +++ b/module/Core/config/routes.config.php @@ -29,7 +29,17 @@ return [ ], [ 'name' => Action\QrCodeAction::class, - 'path' => '/{shortCode}/qr-code[/{size:[0-9]+}]', + 'path' => '/{shortCode}/qr-code', + 'middleware' => [ + Action\QrCodeAction::class, + ], + 'allowed_methods' => [RequestMethod::METHOD_GET], + ], + + // Deprecated + [ + 'name' => 'old_' . Action\QrCodeAction::class, + 'path' => '/{shortCode}/qr-code/{size:[0-9]+}', 'middleware' => [ Action\QrCodeAction::class, ], diff --git a/module/Core/config/templates.config.php b/module/Core/config/templates.config.php deleted file mode 100644 index 784f731e..00000000 --- a/module/Core/config/templates.config.php +++ /dev/null @@ -1,13 +0,0 @@ - [ - 'paths' => [ - 'ShlinkCore' => __DIR__ . '/../templates', - ], - ], - -]; diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 2f7f86e9..076de6a0 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -10,7 +10,11 @@ use Fig\Http\Message\StatusCodeInterface; use Laminas\InputFilter\InputFilter; use PUGX\Shortid\Factory as ShortIdFactory; +use function Functional\reduce_left; +use function is_array; +use function print_r; use function sprintf; +use function str_repeat; const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15; const DEFAULT_SHORT_CODES_LENGTH = 5; @@ -18,7 +22,7 @@ const MIN_SHORT_CODES_LENGTH = 4; const DEFAULT_REDIRECT_STATUS_CODE = StatusCodeInterface::STATUS_FOUND; const DEFAULT_REDIRECT_CACHE_LIFETIME = 30; const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory'; -const CUSTOM_SLUGS_REGEXP = '/[^A-Za-z0-9._~]+/'; +const CUSTOM_SLUGS_REGEXP = '/[^\pL\pN._~]/u'; // Any unicode letter or number, plus ".", "_" and "~" chars function generateRandomShortCode(int $length): string { @@ -75,3 +79,21 @@ function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldN $value = $inputFilter->getValue($fieldName); return $value !== null ? (bool) $value : null; } + +function arrayToString(array $array, int $indentSize = 4): string +{ + $indent = str_repeat(' ', $indentSize); + $index = 0; + + return reduce_left($array, static function ($messages, string $name, $_, string $acc) use (&$index, $indent) { + $index++; + + return $acc . sprintf( + "%s%s'%s' => %s", + $index === 1 ? '' : "\n", + $indent, + $name, + is_array($messages) ? print_r($messages, true) : $messages, + ); + }, ''); +} diff --git a/module/Core/src/Action/AbstractTrackingAction.php b/module/Core/src/Action/AbstractTrackingAction.php index b121ae3a..86eb197b 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -41,7 +41,7 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet $this->urlResolver = $urlResolver; $this->visitTracker = $visitTracker; $this->appOptions = $appOptions; - $this->logger = $logger ?: new NullLogger(); + $this->logger = $logger ?? new NullLogger(); } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index 4a8b7db5..919682d5 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -34,7 +34,7 @@ class QrCodeAction implements MiddlewareInterface ) { $this->urlResolver = $urlResolver; $this->domainConfig = $domainConfig; - $this->logger = $logger ?: new NullLogger(); + $this->logger = $logger ?? new NullLogger(); } public function process(Request $request, RequestHandlerInterface $handler): Response @@ -48,11 +48,15 @@ class QrCodeAction implements MiddlewareInterface return $handler->handle($request); } + $query = $request->getQueryParams(); + // Size attribute is deprecated + $size = $this->normalizeSize((int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE)); + $qrCode = new QrCode($shortUrl->toString($this->domainConfig)); - $qrCode->setSize($this->getSizeParam($request)); + $qrCode->setSize($size); $qrCode->setMargin(0); - $format = $request->getQueryParams()['format'] ?? 'png'; + $format = $query['format'] ?? 'png'; if ($format === 'svg') { $qrCode->setWriter(new SvgWriter()); } @@ -60,9 +64,8 @@ class QrCodeAction implements MiddlewareInterface return new QrCodeResponse($qrCode); } - private function getSizeParam(Request $request): int + private function normalizeSize(int $size): int { - $size = (int) $request->getAttribute('size', self::DEFAULT_SIZE); if ($size < self::MIN_SIZE) { return self::MIN_SIZE; } diff --git a/module/Core/src/Config/SimplifiedConfigParser.php b/module/Core/src/Config/SimplifiedConfigParser.php index aebeb2c3..b578799b 100644 --- a/module/Core/src/Config/SimplifiedConfigParser.php +++ b/module/Core/src/Config/SimplifiedConfigParser.php @@ -15,6 +15,7 @@ use function Functional\contains; use function Functional\reduce_left; use function uksort; +/** @deprecated */ class SimplifiedConfigParser { private const SIMPLIFIED_CONFIG_MAPPING = [ diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index d7575361..5a573799 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -5,25 +5,69 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain; use Doctrine\ORM\EntityManagerInterface; +use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; +use Shlinkio\Shlink\Rest\ApiKey\Role; +use Shlinkio\Shlink\Rest\Entity\ApiKey; + +use function Functional\map; class DomainService implements DomainServiceInterface { private EntityManagerInterface $em; + private string $defaultDomain; - public function __construct(EntityManagerInterface $em) + public function __construct(EntityManagerInterface $em, string $defaultDomain) { $this->em = $em; + $this->defaultDomain = $defaultDomain; } /** - * @return Domain[] + * @return DomainItem[] */ - public function listDomainsWithout(?string $excludeDomain = null): array + public function listDomains(?ApiKey $apiKey = null): array { /** @var DomainRepositoryInterface $repo */ $repo = $this->em->getRepository(Domain::class); - return $repo->findDomainsWithout($excludeDomain); + $domains = $repo->findDomainsWithout($this->defaultDomain, $apiKey); + $mappedDomains = map($domains, fn (Domain $domain) => new DomainItem($domain->getAuthority(), false)); + + if ($apiKey !== null && $apiKey->hasRole(Role::DOMAIN_SPECIFIC)) { + return $mappedDomains; + } + + return [ + new DomainItem($this->defaultDomain, true), + ...$mappedDomains, + ]; + } + + /** + * @throws DomainNotFoundException + */ + public function getDomain(string $domainId): Domain + { + /** @var Domain|null $domain */ + $domain = $this->em->find(Domain::class, $domainId); + if ($domain === null) { + throw DomainNotFoundException::fromId($domainId); + } + + return $domain; + } + + public function getOrCreate(string $authority): Domain + { + $repo = $this->em->getRepository(Domain::class); + /** @var Domain|null $domain */ + $domain = $repo->findOneBy(['authority' => $authority]) ?? new Domain($authority); + + $this->em->persist($domain); + $this->em->flush(); + + return $domain; } } diff --git a/module/Core/src/Domain/DomainServiceInterface.php b/module/Core/src/Domain/DomainServiceInterface.php index 3e56c69c..3588fbc6 100644 --- a/module/Core/src/Domain/DomainServiceInterface.php +++ b/module/Core/src/Domain/DomainServiceInterface.php @@ -4,12 +4,22 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain; +use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; +use Shlinkio\Shlink\Rest\Entity\ApiKey; interface DomainServiceInterface { /** - * @return Domain[] + * @return DomainItem[] */ - public function listDomainsWithout(?string $excludeDomain = null): array; + public function listDomains(?ApiKey $apiKey = null): array; + + /** + * @throws DomainNotFoundException + */ + public function getDomain(string $domainId): Domain; + + public function getOrCreate(string $authority): Domain; } diff --git a/module/Core/src/Domain/Model/DomainItem.php b/module/Core/src/Domain/Model/DomainItem.php new file mode 100644 index 00000000..4006b186 --- /dev/null +++ b/module/Core/src/Domain/Model/DomainItem.php @@ -0,0 +1,37 @@ +domain = $domain; + $this->isDefault = $isDefault; + } + + public function jsonSerialize(): array + { + return [ + 'domain' => $this->domain, + 'isDefault' => $this->isDefault, + ]; + } + + public function toString(): string + { + return $this->domain; + } + + public function isDefault(): bool + { + return $this->isDefault; + } +} diff --git a/module/Core/src/Domain/Repository/DomainRepository.php b/module/Core/src/Domain/Repository/DomainRepository.php index f02dd120..f2152fbe 100644 --- a/module/Core/src/Domain/Repository/DomainRepository.php +++ b/module/Core/src/Domain/Repository/DomainRepository.php @@ -4,17 +4,18 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain\Repository; -use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Query\Expr\Join; +use Happyr\DoctrineSpecification\EntitySpecificationRepository; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -class DomainRepository extends EntityRepository implements DomainRepositoryInterface +class DomainRepository extends EntitySpecificationRepository implements DomainRepositoryInterface { /** * @return Domain[] */ - public function findDomainsWithout(?string $excludedAuthority = null): array + public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array { $qb = $this->createQueryBuilder('d'); $qb->join(ShortUrl::class, 's', Join::WITH, 's.domain = d') @@ -25,6 +26,10 @@ class DomainRepository extends EntityRepository implements DomainRepositoryInter ->setParameter('excludedAuthority', $excludedAuthority); } + if ($apiKey !== null) { + $this->applySpecification($qb, $apiKey->spec(), 's'); + } + return $qb->getQuery()->getResult(); } } diff --git a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php index 56a765ac..13917dc6 100644 --- a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php +++ b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php @@ -5,12 +5,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain\Repository; use Doctrine\Persistence\ObjectRepository; +use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface; use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -interface DomainRepositoryInterface extends ObjectRepository +interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface { /** * @return Domain[] */ - public function findDomainsWithout(?string $excludedAuthority = null): array; + public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array; } diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index 6f7493aa..67d41136 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -59,7 +59,7 @@ class ShortUrl extends AbstractEntity $this->shortCodeLength = $meta->getShortCodeLength(); $this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($this->shortCodeLength); $this->domain = $relationResolver->resolveDomain($meta->getDomain()); - $this->authorApiKey = $relationResolver->resolveApiKey($meta->getApiKey()); + $this->authorApiKey = $meta->getApiKey(); } public static function fromImport( diff --git a/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php b/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php index e5968a68..62b78973 100644 --- a/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php +++ b/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php @@ -4,40 +4,37 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ErrorHandler; +use Closure; use Fig\Http\Message\StatusCodeInterface; -use InvalidArgumentException; use Laminas\Diactoros\Response; use Mezzio\Router\RouteResult; -use Mezzio\Template\TemplateRendererInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use function file_get_contents; +use function sprintf; + class NotFoundTemplateHandler implements RequestHandlerInterface { - public const NOT_FOUND_TEMPLATE = 'ShlinkCore::error/404'; - public const INVALID_SHORT_CODE_TEMPLATE = 'ShlinkCore::invalid-short-code'; + private const TEMPLATES_BASE_DIR = __DIR__ . '/../../templates'; + public const NOT_FOUND_TEMPLATE = '404.html'; + public const INVALID_SHORT_CODE_TEMPLATE = 'invalid-short-code.html'; + private Closure $readFile; - private TemplateRendererInterface $renderer; - - public function __construct(TemplateRendererInterface $renderer) + public function __construct(?callable $readFile = null) { - $this->renderer = $renderer; + $this->readFile = $readFile ? Closure::fromCallable($readFile) : fn (string $file) => file_get_contents($file); } - /** - * Dispatch the next available middleware and return the response. - * - * - * @throws InvalidArgumentException - */ public function handle(ServerRequestInterface $request): ResponseInterface { /** @var RouteResult $routeResult */ - $routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null)); + $routeResult = $request->getAttribute(RouteResult::class) ?? RouteResult::fromRouteFailure(null); $status = StatusCodeInterface::STATUS_NOT_FOUND; $template = $routeResult->isFailure() ? self::NOT_FOUND_TEMPLATE : self::INVALID_SHORT_CODE_TEMPLATE; - return new Response\HtmlResponse($this->renderer->render($template), $status); + $templateContent = ($this->readFile)(sprintf('%s/%s', self::TEMPLATES_BASE_DIR, $template)); + return new Response\HtmlResponse($templateContent, $status); } } diff --git a/module/Core/src/EventDispatcher/VisitLocated.php b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php similarity index 69% rename from module/Core/src/EventDispatcher/VisitLocated.php rename to module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php index 0e1c1176..09869cb2 100644 --- a/module/Core/src/EventDispatcher/VisitLocated.php +++ b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\Core\EventDispatcher; +namespace Shlinkio\Shlink\Core\EventDispatcher\Event; use JsonSerializable; -final class VisitLocated implements JsonSerializable +abstract class AbstractVisitEvent implements JsonSerializable { - private string $visitId; + protected string $visitId; public function __construct(string $visitId) { diff --git a/module/Core/src/EventDispatcher/Event/ShortUrlVisited.php b/module/Core/src/EventDispatcher/Event/ShortUrlVisited.php new file mode 100644 index 00000000..f177721f --- /dev/null +++ b/module/Core/src/EventDispatcher/Event/ShortUrlVisited.php @@ -0,0 +1,21 @@ +originalIpAddress = $originalIpAddress; + } + + public function originalIpAddress(): ?string + { + return $this->originalIpAddress; + } +} diff --git a/module/Core/src/EventDispatcher/Event/VisitLocated.php b/module/Core/src/EventDispatcher/Event/VisitLocated.php new file mode 100644 index 00000000..99b7a05e --- /dev/null +++ b/module/Core/src/EventDispatcher/Event/VisitLocated.php @@ -0,0 +1,9 @@ +visitId = $visitId; - $this->originalIpAddress = $originalIpAddress; - } - - public function visitId(): string - { - return $this->visitId; - } - - public function originalIpAddress(): ?string - { - return $this->originalIpAddress; - } - - public function jsonSerialize(): array - { - return ['visitId' => $this->visitId, 'originalIpAddress' => $this->originalIpAddress]; - } -} diff --git a/module/Core/src/Exception/DomainNotFoundException.php b/module/Core/src/Exception/DomainNotFoundException.php new file mode 100644 index 00000000..b1b97c91 --- /dev/null +++ b/module/Core/src/Exception/DomainNotFoundException.php @@ -0,0 +1,32 @@ +detail = $e->getMessage(); + $e->title = self::TITLE; + $e->type = self::TYPE; + $e->status = StatusCodeInterface::STATUS_NOT_FOUND; + $e->additional = ['id' => $id]; + + return $e; + } +} diff --git a/module/Core/src/Exception/ForbiddenTagOperationException.php b/module/Core/src/Exception/ForbiddenTagOperationException.php new file mode 100644 index 00000000..d4200c92 --- /dev/null +++ b/module/Core/src/Exception/ForbiddenTagOperationException.php @@ -0,0 +1,39 @@ +detail = $message; + $e->title = self::TITLE; + $e->type = self::TYPE; + $e->status = StatusCodeInterface::STATUS_FORBIDDEN; + + return $e; + } +} diff --git a/module/Core/src/Exception/TagConflictException.php b/module/Core/src/Exception/TagConflictException.php index 7362f76b..d551ec19 100644 --- a/module/Core/src/Exception/TagConflictException.php +++ b/module/Core/src/Exception/TagConflictException.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\Tag\Model\TagRenaming; use function sprintf; @@ -17,18 +18,15 @@ class TagConflictException extends RuntimeException implements ProblemDetailsExc private const TITLE = 'Tag conflict'; private const TYPE = 'TAG_CONFLICT'; - public static function fromExistingTag(string $oldName, string $newName): self + public static function forExistingTag(TagRenaming $renaming): self { - $e = new self(sprintf('You cannot rename tag %s to %s, because it already exists', $oldName, $newName)); + $e = new self(sprintf('You cannot rename tag %s, because it already exists', $renaming->toString())); $e->detail = $e->getMessage(); $e->title = self::TITLE; $e->type = self::TYPE; $e->status = StatusCodeInterface::STATUS_CONFLICT; - $e->additional = [ - 'oldName' => $oldName, - 'newName' => $newName, - ]; + $e->additional = $renaming->toArray(); return $e; } diff --git a/module/Core/src/Exception/ValidationException.php b/module/Core/src/Exception/ValidationException.php index fc090279..3a211592 100644 --- a/module/Core/src/Exception/ValidationException.php +++ b/module/Core/src/Exception/ValidationException.php @@ -11,9 +11,7 @@ use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; use Throwable; use function array_keys; -use function Functional\reduce_left; -use function is_array; -use function print_r; +use function Shlinkio\Shlink\Core\arrayToString; use function sprintf; use const PHP_EOL; @@ -55,24 +53,16 @@ class ValidationException extends InvalidArgumentException implements ProblemDet public function __toString(): string { return sprintf( - '%s %s in %s:%s%s%sStack trace:%s%s', + '%s %s in %s:%s%s%s%sStack trace:%s%s', __CLASS__, $this->getMessage(), $this->getFile(), $this->getLine(), - $this->invalidElementsToString(), + PHP_EOL, + arrayToString($this->getInvalidElements()), PHP_EOL, PHP_EOL, $this->getTraceAsString(), ); } - - private function invalidElementsToString(): string - { - return reduce_left($this->getInvalidElements(), fn ($messages, string $name, $_, string $acc) => $acc . sprintf( - "\n '%s' => %s", - $name, - is_array($messages) ? print_r($messages, true) : $messages, - ), ''); - } } diff --git a/module/Core/src/Model/ShortUrlMeta.php b/module/Core/src/Model/ShortUrlMeta.php index fa82919e..0df792be 100644 --- a/module/Core/src/Model/ShortUrlMeta.php +++ b/module/Core/src/Model/ShortUrlMeta.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Model; use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; @@ -24,7 +25,7 @@ final class ShortUrlMeta private ?string $domain = null; private int $shortCodeLength = 5; private ?bool $validateUrl = null; - private ?string $apiKey = null; + private ?ApiKey $apiKey = null; // Enforce named constructors private function __construct() @@ -135,7 +136,7 @@ final class ShortUrlMeta return $this->validateUrl; } - public function getApiKey(): ?string + public function getApiKey(): ?ApiKey { return $this->apiKey; } diff --git a/module/Core/src/Model/ShortUrlsOrdering.php b/module/Core/src/Model/ShortUrlsOrdering.php index 25c7c940..e1708a86 100644 --- a/module/Core/src/Model/ShortUrlsOrdering.php +++ b/module/Core/src/Model/ShortUrlsOrdering.php @@ -35,7 +35,6 @@ final class ShortUrlsOrdering */ private function validateAndInit(array $data): void { - /** @var string|array|null $orderBy */ $orderBy = $data[self::ORDER_BY] ?? null; if ($orderBy === null) { return; @@ -49,6 +48,7 @@ final class ShortUrlsOrdering ]); } + /** @var string|array $orderBy */ if (! $isArray) { $parts = explode('-', $orderBy); $this->orderField = $parts[0]; diff --git a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php index f395412c..93fd88c7 100644 --- a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -4,27 +4,25 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Paginator\Adapter; +use Happyr\DoctrineSpecification\Specification\Specification; use Laminas\Paginator\Adapter\AdapterInterface; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlRepositoryAdapter implements AdapterInterface { private ShortUrlRepositoryInterface $repository; private ShortUrlsParams $params; + private ?ApiKey $apiKey; - public function __construct(ShortUrlRepositoryInterface $repository, ShortUrlsParams $params) + public function __construct(ShortUrlRepositoryInterface $repository, ShortUrlsParams $params, ?ApiKey $apiKey) { $this->repository = $repository; $this->params = $params; + $this->apiKey = $apiKey; } - /** - * Returns a collection of items for a page. - * - * @param int $offset Page offset - * @param int $itemCountPerPage Number of items per page - */ public function getItems($offset, $itemCountPerPage): array // phpcs:ignore { return $this->repository->findList( @@ -34,24 +32,22 @@ class ShortUrlRepositoryAdapter implements AdapterInterface $this->params->tags(), $this->params->orderBy(), $this->params->dateRange(), + $this->resolveSpec(), ); } - /** - * Count elements of an object - * @link http://php.net/manual/en/countable.count.php - * @return int The custom count as an integer. - *

- *

- * The return value is cast to an integer. - * @since 5.1.0 - */ public function count(): int { return $this->repository->countList( $this->params->searchTerm(), $this->params->tags(), $this->params->dateRange(), + $this->resolveSpec(), ); } + + private function resolveSpec(): ?Specification + { + return $this->apiKey !== null ? $this->apiKey->spec() : null; + } } diff --git a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php index e80fbcdd..3b73509a 100644 --- a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php @@ -4,20 +4,28 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Paginator\Adapter; +use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter { private VisitRepositoryInterface $visitRepository; private string $tag; private VisitsParams $params; + private ?ApiKey $apiKey; - public function __construct(VisitRepositoryInterface $visitRepository, string $tag, VisitsParams $params) - { + public function __construct( + VisitRepositoryInterface $visitRepository, + string $tag, + VisitsParams $params, + ?ApiKey $apiKey + ) { $this->visitRepository = $visitRepository; $this->params = $params; $this->tag = $tag; + $this->apiKey = $apiKey; } public function getItems($offset, $itemCountPerPage): array // phpcs:ignore @@ -27,11 +35,21 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte $this->params->getDateRange(), $itemCountPerPage, $offset, + $this->resolveSpec(), ); } protected function doCount(): int { - return $this->visitRepository->countVisitsByTag($this->tag, $this->params->getDateRange()); + return $this->visitRepository->countVisitsByTag( + $this->tag, + $this->params->getDateRange(), + $this->resolveSpec(), + ); + } + + private function resolveSpec(): ?Specification + { + return $this->apiKey !== null ? $this->apiKey->spec(true) : null; } } diff --git a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php index 404ae309..29498a6d 100644 --- a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Paginator\Adapter; +use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; @@ -13,15 +14,18 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter private VisitRepositoryInterface $visitRepository; private ShortUrlIdentifier $identifier; private VisitsParams $params; + private ?Specification $spec; public function __construct( VisitRepositoryInterface $visitRepository, ShortUrlIdentifier $identifier, - VisitsParams $params + VisitsParams $params, + ?Specification $spec ) { $this->visitRepository = $visitRepository; $this->params = $params; $this->identifier = $identifier; + $this->spec = $spec; } public function getItems($offset, $itemCountPerPage): array // phpcs:ignore @@ -32,6 +36,7 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter $this->params->getDateRange(), $itemCountPerPage, $offset, + $this->spec, ); } @@ -41,6 +46,7 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter $this->identifier->shortCode(), $this->identifier->domain(), $this->params->getDateRange(), + $this->spec, ); } } diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index 27dac54b..ddfaa189 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; -use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; +use Happyr\DoctrineSpecification\EntitySpecificationRepository; +use Happyr\DoctrineSpecification\Specification\Specification; +use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; @@ -18,7 +20,7 @@ use function array_key_exists; use function count; use function Functional\contains; -class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface +class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface { /** * @param string[] $tags @@ -30,18 +32,13 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI ?string $searchTerm = null, array $tags = [], ?ShortUrlsOrdering $orderBy = null, - ?DateRange $dateRange = null + ?DateRange $dateRange = null, + ?Specification $spec = null ): array { - $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange); - $qb->select('DISTINCT s'); - - // Set limit and offset - if ($limit !== null) { - $qb->setMaxResults($limit); - } - if ($offset !== null) { - $qb->setFirstResult($offset); - } + $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec); + $qb->select('DISTINCT s') + ->setMaxResults($limit) + ->setFirstResult($offset); // In case the ordering has been specified, the query could be more complex. Process it if ($orderBy !== null && $orderBy->hasOrderField()) { @@ -80,18 +77,23 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI return $qb->getQuery()->getResult(); } - public function countList(?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null): int - { - $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange); + public function countList( + ?string $searchTerm = null, + array $tags = [], + ?DateRange $dateRange = null, + ?Specification $spec = null + ): int { + $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec); $qb->select('COUNT(DISTINCT s)'); return (int) $qb->getQuery()->getSingleScalarResult(); } private function createListQueryBuilder( - ?string $searchTerm = null, - array $tags = [], - ?DateRange $dateRange = null + ?string $searchTerm, + array $tags, + ?DateRange $dateRange, + ?Specification $spec ): QueryBuilder { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(ShortUrl::class, 's') @@ -99,11 +101,11 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI if ($dateRange !== null && $dateRange->getStartDate() !== null) { $qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate')); - $qb->setParameter('startDate', $dateRange->getStartDate()); + $qb->setParameter('startDate', $dateRange->getStartDate(), ChronosDateTimeType::CHRONOS_DATETIME); } if ($dateRange !== null && $dateRange->getEndDate() !== null) { $qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate')); - $qb->setParameter('endDate', $dateRange->getEndDate()); + $qb->setParameter('endDate', $dateRange->getEndDate(), ChronosDateTimeType::CHRONOS_DATETIME); } // Apply search term to every searchable field if not empty @@ -130,6 +132,8 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI ->andWhere($qb->expr()->in('t.name', $tags)); } + $this->applySpecification($qb, $spec, 's'); + return $qb; } @@ -147,7 +151,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI WHERE s.shortCode = :shortCode AND (s.domain IS NULL OR d.authority = :domain) ORDER BY s.domain {$ordering} -DQL; + DQL; $query = $this->getEntityManager()->createQuery($dql); $query->setMaxResults(1) @@ -165,23 +169,23 @@ DQL; return $query->getOneOrNullResult(); } - public function findOne(string $shortCode, ?string $domain = null): ?ShortUrl + public function findOne(string $shortCode, ?string $domain = null, ?Specification $spec = null): ?ShortUrl { - $qb = $this->createFindOneQueryBuilder($shortCode, $domain); + $qb = $this->createFindOneQueryBuilder($shortCode, $domain, $spec); $qb->select('s'); return $qb->getQuery()->getOneOrNullResult(); } - public function shortCodeIsInUse(string $slug, ?string $domain = null): bool + public function shortCodeIsInUse(string $slug, ?string $domain = null, ?Specification $spec = null): bool { - $qb = $this->createFindOneQueryBuilder($slug, $domain); + $qb = $this->createFindOneQueryBuilder($slug, $domain, $spec); $qb->select('COUNT(DISTINCT s.id)'); return ((int) $qb->getQuery()->getSingleScalarResult()) > 0; } - private function createFindOneQueryBuilder(string $slug, ?string $domain = null): QueryBuilder + private function createFindOneQueryBuilder(string $slug, ?string $domain, ?Specification $spec): QueryBuilder { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(ShortUrl::class, 's') @@ -192,6 +196,8 @@ DQL; $this->whereDomainIs($qb, $domain); + $this->applySpecification($qb, $spec, 's'); + return $qb; } @@ -216,19 +222,23 @@ DQL; } if ($meta->hasValidSince()) { $qb->andWhere($qb->expr()->eq('s.validSince', ':validSince')) - ->setParameter('validSince', $meta->getValidSince()); + ->setParameter('validSince', $meta->getValidSince(), ChronosDateTimeType::CHRONOS_DATETIME); } if ($meta->hasValidUntil()) { $qb->andWhere($qb->expr()->eq('s.validUntil', ':validUntil')) - ->setParameter('validUntil', $meta->getValidUntil()); + ->setParameter('validUntil', $meta->getValidUntil(), ChronosDateTimeType::CHRONOS_DATETIME); } - if ($meta->hasDomain()) { $qb->join('s.domain', 'd') ->andWhere($qb->expr()->eq('d.authority', ':domain')) ->setParameter('domain', $meta->getDomain()); } + $apiKey = $meta->getApiKey(); + if ($apiKey !== null) { + $this->applySpecification($qb, $apiKey->spec(), 's'); + } + $tagsAmount = count($tags); if ($tagsAmount === 0) { return $qb->getQuery()->getOneOrNullResult(); diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php index 1d6f38a8..a0131f6f 100644 --- a/module/Core/src/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/Repository/ShortUrlRepositoryInterface.php @@ -5,13 +5,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; use Doctrine\Persistence\ObjectRepository; +use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface; +use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; -interface ShortUrlRepositoryInterface extends ObjectRepository +interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface { public function findList( ?int $limit = null, @@ -19,16 +21,22 @@ interface ShortUrlRepositoryInterface extends ObjectRepository ?string $searchTerm = null, array $tags = [], ?ShortUrlsOrdering $orderBy = null, - ?DateRange $dateRange = null + ?DateRange $dateRange = null, + ?Specification $spec = null ): array; - public function countList(?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null): int; + public function countList( + ?string $searchTerm = null, + array $tags = [], + ?DateRange $dateRange = null, + ?Specification $spec = null + ): int; public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl; - public function findOne(string $shortCode, ?string $domain = null): ?ShortUrl; + public function findOne(string $shortCode, ?string $domain = null, ?Specification $spec = null): ?ShortUrl; - public function shortCodeIsInUse(string $slug, ?string $domain): bool; + public function shortCodeIsInUse(string $slug, ?string $domain, ?Specification $spec = null): bool; public function findOneMatching(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl; diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 05b2481c..dd15c292 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -4,13 +4,18 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; -use Doctrine\ORM\EntityRepository; +use Happyr\DoctrineSpecification\EntitySpecificationRepository; +use Happyr\DoctrineSpecification\Spec; +use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; +use Shlinkio\Shlink\Core\Tag\Spec\CountTagsWithName; +use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use function Functional\map; -class TagRepository extends EntityRepository implements TagRepositoryInterface +class TagRepository extends EntitySpecificationRepository implements TagRepositoryInterface { public function deleteByName(array $names): int { @@ -28,21 +33,32 @@ class TagRepository extends EntityRepository implements TagRepositoryInterface /** * @return TagInfo[] */ - public function findTagsWithInfo(): array + public function findTagsWithInfo(?Specification $spec = null): array { - $dql = <<getEntityManager()->createQuery($dql); + $qb = $this->createQueryBuilder('t'); + $qb->select('t AS tag', 'COUNT(DISTINCT s.id) AS shortUrlsCount', 'COUNT(DISTINCT v.id) AS visitsCount') + ->leftJoin('t.shortUrls', 's') + ->leftJoin('s.visits', 'v') + ->groupBy('t') + ->orderBy('t.name', 'ASC'); + + $this->applySpecification($qb, $spec, 't'); + + $query = $qb->getQuery(); return map( $query->getResult(), fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']), ); } + + public function tagExists(string $tag, ?ApiKey $apiKey = null): bool + { + $result = (int) $this->matchSingleScalarResult(Spec::andX( + new CountTagsWithName($tag), + new WithApiKeySpecsEnsuringJoin($apiKey), + )); + + return $result > 0; + } } diff --git a/module/Core/src/Repository/TagRepositoryInterface.php b/module/Core/src/Repository/TagRepositoryInterface.php index 37179e21..86898ed1 100644 --- a/module/Core/src/Repository/TagRepositoryInterface.php +++ b/module/Core/src/Repository/TagRepositoryInterface.php @@ -5,14 +5,19 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; use Doctrine\Persistence\ObjectRepository; +use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface; +use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -interface TagRepositoryInterface extends ObjectRepository +interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface { public function deleteByName(array $names): int; /** * @return TagInfo[] */ - public function findTagsWithInfo(): array; + public function findTagsWithInfo(?Specification $spec = null): array; + + public function tagExists(string $tag, ?ApiKey $apiKey = null): bool; } diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 458b8ef2..a1df73a5 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -4,17 +4,21 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; -use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\ORM\QueryBuilder; +use Happyr\DoctrineSpecification\EntitySpecificationRepository; +use Happyr\DoctrineSpecification\Spec; +use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; +use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use const PHP_INT_MAX; -class VisitRepository extends EntityRepository implements VisitRepositoryInterface +class VisitRepository extends EntitySpecificationRepository implements VisitRepositoryInterface { /** * @return iterable|Visit[] @@ -84,15 +88,20 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa ?string $domain = null, ?DateRange $dateRange = null, ?int $limit = null, - ?int $offset = null + ?int $offset = null, + ?Specification $spec = null ): array { - $qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange); + $qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange, $spec); return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset); } - public function countVisitsByShortCode(string $shortCode, ?string $domain = null, ?DateRange $dateRange = null): int - { - $qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange); + public function countVisitsByShortCode( + string $shortCode, + ?string $domain = null, + ?DateRange $dateRange = null, + ?Specification $spec = null + ): int { + $qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange, $spec); $qb->select('COUNT(v.id)'); return (int) $qb->getQuery()->getSingleScalarResult(); @@ -101,11 +110,12 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa private function createVisitsByShortCodeQueryBuilder( string $shortCode, ?string $domain, - ?DateRange $dateRange + ?DateRange $dateRange, + ?Specification $spec = null ): QueryBuilder { /** @var ShortUrlRepositoryInterface $shortUrlRepo */ $shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class); - $shortUrl = $shortUrlRepo->findOne($shortCode, $domain); + $shortUrl = $shortUrlRepo->findOne($shortCode, $domain, $spec); $shortUrlId = $shortUrl !== null ? $shortUrl->getId() : -1; // Parameters in this query need to be part of the query itself, as we need to use it a sub-query later @@ -124,32 +134,36 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa string $tag, ?DateRange $dateRange = null, ?int $limit = null, - ?int $offset = null + ?int $offset = null, + ?Specification $spec = null ): array { - $qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange); + $qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange, $spec); return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset); } - public function countVisitsByTag(string $tag, ?DateRange $dateRange = null): int + public function countVisitsByTag(string $tag, ?DateRange $dateRange = null, ?Specification $spec = null): int { - $qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange); + $qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange, $spec); $qb->select('COUNT(v.id)'); return (int) $qb->getQuery()->getSingleScalarResult(); } - private function createVisitsByTagQueryBuilder(string $tag, ?DateRange $dateRange = null): QueryBuilder - { - // Parameters in this query need to be part of the query itself, as we need to use it a sub-query later + private function createVisitsByTagQueryBuilder( + string $tag, + ?DateRange $dateRange, + ?Specification $spec + ): QueryBuilder { + // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later // Since they are not strictly provided by the caller, it's reasonably safe $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(Visit::class, 'v') ->join('v.shortUrl', 's') ->join('s.tags', 't') - ->where($qb->expr()->eq('t.name', '\'' . $tag . '\'')); + ->where($qb->expr()->eq('t.name', '\'' . $tag . '\'')); // This needs to be concatenated, not bound - // Apply date range filtering $this->applyDatesInline($qb, $dateRange); + $this->applySpecification($qb, $spec, 'v'); return $qb; } @@ -194,4 +208,11 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa return $query->getResult(); } + + public function countVisits(?ApiKey $apiKey = null): int + { + return (int) $this->matchSingleScalarResult( + Spec::countOf(new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrl')), + ); + } } diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index 5a540171..526645df 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -5,10 +5,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; use Doctrine\Persistence\ObjectRepository; +use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface; +use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -interface VisitRepositoryInterface extends ObjectRepository +interface VisitRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface { public const DEFAULT_BLOCK_SIZE = 10000; @@ -35,13 +38,15 @@ interface VisitRepositoryInterface extends ObjectRepository ?string $domain = null, ?DateRange $dateRange = null, ?int $limit = null, - ?int $offset = null + ?int $offset = null, + ?Specification $spec = null ): array; public function countVisitsByShortCode( string $shortCode, ?string $domain = null, - ?DateRange $dateRange = null + ?DateRange $dateRange = null, + ?Specification $spec = null ): int; /** @@ -51,8 +56,11 @@ interface VisitRepositoryInterface extends ObjectRepository string $tag, ?DateRange $dateRange = null, ?int $limit = null, - ?int $offset = null + ?int $offset = null, + ?Specification $spec = null ): array; - public function countVisitsByTag(string $tag, ?DateRange $dateRange = null): int; + public function countVisitsByTag(string $tag, ?DateRange $dateRange = null, ?Specification $spec = null): int; + + public function countVisits(?ApiKey $apiKey = null): int; } diff --git a/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php b/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php index 35a540da..07af448d 100644 --- a/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php +++ b/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php @@ -9,6 +9,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class DeleteShortUrlService implements DeleteShortUrlServiceInterface { @@ -30,9 +31,12 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface * @throws Exception\ShortUrlNotFoundException * @throws Exception\DeleteShortUrlException */ - public function deleteByShortCode(ShortUrlIdentifier $identifier, bool $ignoreThreshold = false): void - { - $shortUrl = $this->urlResolver->resolveShortUrl($identifier); + public function deleteByShortCode( + ShortUrlIdentifier $identifier, + bool $ignoreThreshold = false, + ?ApiKey $apiKey = null + ): void { + $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey); if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) { throw Exception\DeleteShortUrlException::fromVisitsThreshold( $this->deleteShortUrlsOptions->getVisitsThreshold(), diff --git a/module/Core/src/Service/ShortUrl/DeleteShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrl/DeleteShortUrlServiceInterface.php index 4759bf24..b1f01839 100644 --- a/module/Core/src/Service/ShortUrl/DeleteShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrl/DeleteShortUrlServiceInterface.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl; use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Rest\Entity\ApiKey; interface DeleteShortUrlServiceInterface { @@ -13,5 +14,9 @@ interface DeleteShortUrlServiceInterface * @throws Exception\ShortUrlNotFoundException * @throws Exception\DeleteShortUrlException */ - public function deleteByShortCode(ShortUrlIdentifier $identifier, bool $ignoreThreshold = false): void; + public function deleteByShortCode( + ShortUrlIdentifier $identifier, + bool $ignoreThreshold = false, + ?ApiKey $apiKey = null + ): void; } diff --git a/module/Core/src/Service/ShortUrl/ShortUrlResolver.php b/module/Core/src/Service/ShortUrl/ShortUrlResolver.php index 414a3446..6e03114c 100644 --- a/module/Core/src/Service/ShortUrl/ShortUrlResolver.php +++ b/module/Core/src/Service/ShortUrl/ShortUrlResolver.php @@ -9,6 +9,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlResolver implements ShortUrlResolverInterface { @@ -22,11 +23,15 @@ class ShortUrlResolver implements ShortUrlResolverInterface /** * @throws ShortUrlNotFoundException */ - public function resolveShortUrl(ShortUrlIdentifier $identifier): ShortUrl + public function resolveShortUrl(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): ShortUrl { /** @var ShortUrlRepository $shortUrlRepo */ $shortUrlRepo = $this->em->getRepository(ShortUrl::class); - $shortUrl = $shortUrlRepo->findOne($identifier->shortCode(), $identifier->domain()); + $shortUrl = $shortUrlRepo->findOne( + $identifier->shortCode(), + $identifier->domain(), + $apiKey !== null ? $apiKey->spec() : null, + ); if ($shortUrl === null) { throw ShortUrlNotFoundException::fromNotFound($identifier); } diff --git a/module/Core/src/Service/ShortUrl/ShortUrlResolverInterface.php b/module/Core/src/Service/ShortUrl/ShortUrlResolverInterface.php index a3a7c115..daa66e43 100644 --- a/module/Core/src/Service/ShortUrl/ShortUrlResolverInterface.php +++ b/module/Core/src/Service/ShortUrl/ShortUrlResolverInterface.php @@ -7,13 +7,14 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Rest\Entity\ApiKey; interface ShortUrlResolverInterface { /** * @throws ShortUrlNotFoundException */ - public function resolveShortUrl(ShortUrlIdentifier $identifier): ShortUrl; + public function resolveShortUrl(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): ShortUrl; /** * @throws ShortUrlNotFoundException diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index 9159ef63..06b39f08 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -17,6 +17,7 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlService implements ShortUrlServiceInterface { @@ -39,11 +40,11 @@ class ShortUrlService implements ShortUrlServiceInterface /** * @return ShortUrl[]|Paginator */ - public function listShortUrls(ShortUrlsParams $params): Paginator + public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator { /** @var ShortUrlRepository $repo */ $repo = $this->em->getRepository(ShortUrl::class); - $paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $params)); + $paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $params, $apiKey)); $paginator->setItemCountPerPage($params->itemsPerPage()) ->setCurrentPageNumber($params->page()); @@ -54,9 +55,9 @@ class ShortUrlService implements ShortUrlServiceInterface * @param string[] $tags * @throws ShortUrlNotFoundException */ - public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags = []): ShortUrl + public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags, ?ApiKey $apiKey = null): ShortUrl { - $shortUrl = $this->urlResolver->resolveShortUrl($identifier); + $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey); $shortUrl->setTags($this->tagNamesToEntities($this->em, $tags)); $this->em->flush(); @@ -68,13 +69,16 @@ class ShortUrlService implements ShortUrlServiceInterface * @throws ShortUrlNotFoundException * @throws InvalidUrlException */ - public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit): ShortUrl - { + public function updateMetadataByShortCode( + ShortUrlIdentifier $identifier, + ShortUrlEdit $shortUrlEdit, + ?ApiKey $apiKey = null + ): ShortUrl { if ($shortUrlEdit->hasLongUrl()) { $this->urlValidator->validateUrl($shortUrlEdit->longUrl(), $shortUrlEdit->doValidateUrl()); } - $shortUrl = $this->urlResolver->resolveShortUrl($identifier); + $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey); $shortUrl->update($shortUrlEdit); $this->em->flush(); diff --git a/module/Core/src/Service/ShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrlServiceInterface.php index 3c09e7e9..5f6b9b30 100644 --- a/module/Core/src/Service/ShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrlServiceInterface.php @@ -11,23 +11,28 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlEdit; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; +use Shlinkio\Shlink\Rest\Entity\ApiKey; interface ShortUrlServiceInterface { /** * @return ShortUrl[]|Paginator */ - public function listShortUrls(ShortUrlsParams $params): Paginator; + public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator; /** * @param string[] $tags * @throws ShortUrlNotFoundException */ - public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags = []): ShortUrl; + public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags, ?ApiKey $apiKey = null): ShortUrl; /** * @throws ShortUrlNotFoundException * @throws InvalidUrlException */ - public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit): ShortUrl; + public function updateMetadataByShortCode( + ShortUrlIdentifier $identifier, + ShortUrlEdit $shortUrlEdit, + ?ApiKey $apiKey = null + ): ShortUrl; } diff --git a/module/Core/src/Service/VisitsTracker.php b/module/Core/src/Service/VisitsTracker.php index e777af76..46d4bd6b 100644 --- a/module/Core/src/Service/VisitsTracker.php +++ b/module/Core/src/Service/VisitsTracker.php @@ -10,7 +10,7 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; -use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited; +use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlVisited; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; @@ -21,6 +21,7 @@ use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsTracker implements VisitsTrackerInterface { @@ -52,17 +53,19 @@ class VisitsTracker implements VisitsTrackerInterface * @return Visit[]|Paginator * @throws ShortUrlNotFoundException */ - public function info(ShortUrlIdentifier $identifier, VisitsParams $params): Paginator + public function info(ShortUrlIdentifier $identifier, VisitsParams $params, ?ApiKey $apiKey = null): Paginator { + $spec = $apiKey !== null ? $apiKey->spec() : null; + /** @var ShortUrlRepositoryInterface $repo */ $repo = $this->em->getRepository(ShortUrl::class); - if (! $repo->shortCodeIsInUse($identifier->shortCode(), $identifier->domain())) { + if (! $repo->shortCodeIsInUse($identifier->shortCode(), $identifier->domain(), $spec)) { throw ShortUrlNotFoundException::fromNotFound($identifier); } /** @var VisitRepositoryInterface $repo */ $repo = $this->em->getRepository(Visit::class); - $paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params)); + $paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params, $spec)); $paginator->setItemCountPerPage($params->getItemsPerPage()) ->setCurrentPageNumber($params->getPage()); @@ -73,18 +76,17 @@ class VisitsTracker implements VisitsTrackerInterface * @return Visit[]|Paginator * @throws TagNotFoundException */ - public function visitsForTag(string $tag, VisitsParams $params): Paginator + public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator { /** @var TagRepository $tagRepo */ $tagRepo = $this->em->getRepository(Tag::class); - $count = $tagRepo->count(['name' => $tag]); - if ($count === 0) { + if (! $tagRepo->tagExists($tag, $apiKey)) { throw TagNotFoundException::fromTag($tag); } /** @var VisitRepositoryInterface $repo */ $repo = $this->em->getRepository(Visit::class); - $paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params)); + $paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params, $apiKey)); $paginator->setItemCountPerPage($params->getItemsPerPage()) ->setCurrentPageNumber($params->getPage()); diff --git a/module/Core/src/Service/VisitsTrackerInterface.php b/module/Core/src/Service/VisitsTrackerInterface.php index 2c2759c2..ecffae23 100644 --- a/module/Core/src/Service/VisitsTrackerInterface.php +++ b/module/Core/src/Service/VisitsTrackerInterface.php @@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\VisitsParams; +use Shlinkio\Shlink\Rest\Entity\ApiKey; interface VisitsTrackerInterface { @@ -21,11 +22,11 @@ interface VisitsTrackerInterface * @return Visit[]|Paginator * @throws ShortUrlNotFoundException */ - public function info(ShortUrlIdentifier $identifier, VisitsParams $params): Paginator; + public function info(ShortUrlIdentifier $identifier, VisitsParams $params, ?ApiKey $apiKey = null): Paginator; /** * @return Visit[]|Paginator * @throws TagNotFoundException */ - public function visitsForTag(string $tag, VisitsParams $params): Paginator; + public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator; } diff --git a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php index d898fb37..0e3afa23 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Resolver; use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Entity\Domain; -use Shlinkio\Shlink\Rest\Entity\ApiKey; class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInterface { @@ -27,15 +26,4 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt $existingDomain = $this->em->getRepository(Domain::class)->findOneBy(['authority' => $domain]); return $existingDomain ?? new Domain($domain); } - - public function resolveApiKey(?string $key): ?ApiKey - { - if ($key === null) { - return null; - } - - /** @var ApiKey|null $existingApiKey */ - $existingApiKey = $this->em->getRepository(ApiKey::class)->findOneBy(['key' => $key]); - return $existingApiKey; - } } diff --git a/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php b/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php index 0a708cf6..bc576dbd 100644 --- a/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php +++ b/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php @@ -5,11 +5,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Resolver; use Shlinkio\Shlink\Core\Entity\Domain; -use Shlinkio\Shlink\Rest\Entity\ApiKey; interface ShortUrlRelationResolverInterface { public function resolveDomain(?string $domain): ?Domain; - - public function resolveApiKey(?string $key): ?ApiKey; } diff --git a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php index 9de156ee..4e4620f5 100644 --- a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Resolver; use Shlinkio\Shlink\Core\Entity\Domain; -use Shlinkio\Shlink\Rest\Entity\ApiKey; class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterface { @@ -13,9 +12,4 @@ class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterfac { return $domain !== null ? new Domain($domain) : null; } - - public function resolveApiKey(?string $key): ?ApiKey - { - return null; - } } diff --git a/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php new file mode 100644 index 00000000..9e094b90 --- /dev/null +++ b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php @@ -0,0 +1,28 @@ +apiKey = $apiKey; + $this->dqlAlias = $dqlAlias ?? 's'; + parent::__construct($this->dqlAlias); + } + + protected function getSpec(): Filter + { + return Spec::eq('authorApiKey', $this->apiKey, $this->dqlAlias); + } +} diff --git a/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php b/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php new file mode 100644 index 00000000..197031f3 --- /dev/null +++ b/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php @@ -0,0 +1,29 @@ +apiKey = $apiKey; + } + + public function getFilter(QueryBuilder $qb, string $dqlAlias): string + { + // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later + return (string) $qb->expr()->eq('s.authorApiKey', '\'' . $this->apiKey->getId() . '\''); + } + + public function modify(QueryBuilder $qb, string $dqlAlias): void + { + } +} diff --git a/module/Core/src/ShortUrl/Spec/BelongsToDomain.php b/module/Core/src/ShortUrl/Spec/BelongsToDomain.php new file mode 100644 index 00000000..81b4388a --- /dev/null +++ b/module/Core/src/ShortUrl/Spec/BelongsToDomain.php @@ -0,0 +1,27 @@ +domainId = $domainId; + $this->dqlAlias = $dqlAlias ?? 's'; + parent::__construct($this->dqlAlias); + } + + protected function getSpec(): Filter + { + return Spec::eq('domain', $this->domainId, $this->dqlAlias); + } +} diff --git a/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php new file mode 100644 index 00000000..a8ef527e --- /dev/null +++ b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php @@ -0,0 +1,28 @@ +domainId = $domainId; + } + + public function getFilter(QueryBuilder $qb, string $dqlAlias): string + { + // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later + return (string) $qb->expr()->eq('s.domain', '\'' . $this->domainId . '\''); + } + + public function modify(QueryBuilder $qb, string $dqlAlias): void + { + } +} diff --git a/module/Core/src/Tag/Model/TagRenaming.php b/module/Core/src/Tag/Model/TagRenaming.php new file mode 100644 index 00000000..1f677376 --- /dev/null +++ b/module/Core/src/Tag/Model/TagRenaming.php @@ -0,0 +1,68 @@ +oldName = $oldName; + $o->newName = $newName; + + return $o; + } + + public static function fromArray(array $payload): self + { + if (! isset($payload['oldName'], $payload['newName'])) { + throw ValidationException::fromArray([ + 'oldName' => 'oldName is required', + 'newName' => 'newName is required', + ]); + } + + return self::fromNames($payload['oldName'], $payload['newName']); + } + + public function oldName(): string + { + return $this->oldName; + } + + public function newName(): string + { + return $this->newName; + } + + public function nameChanged(): bool + { + return $this->oldName !== $this->newName; + } + + public function toString(): string + { + return sprintf('%s to %s', $this->oldName, $this->newName); + } + + public function toArray(): array + { + return [ + 'oldName' => $this->oldName, + 'newName' => $this->newName, + ]; + } +} diff --git a/module/Core/src/Tag/Spec/CountTagsWithName.php b/module/Core/src/Tag/Spec/CountTagsWithName.php new file mode 100644 index 00000000..a3f90a78 --- /dev/null +++ b/module/Core/src/Tag/Spec/CountTagsWithName.php @@ -0,0 +1,30 @@ +tagName = $tagName; + } + + protected function getSpec(): Specification + { + return Spec::countOf( + Spec::andX( + Spec::select('id'), + Spec::eq('name', $this->tagName), + ), + ); + } +} diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index 4e0261a5..ae46a312 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -6,13 +6,18 @@ namespace Shlinkio\Shlink\Core\Tag; use Doctrine\Common\Collections\Collection; use Doctrine\ORM; +use Happyr\DoctrineSpecification\Spec; use Shlinkio\Shlink\Core\Entity\Tag; +use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; +use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Util\TagManagerTrait; +use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class TagService implements TagServiceInterface { @@ -28,28 +33,38 @@ class TagService implements TagServiceInterface /** * @return Tag[] */ - public function listTags(): array + public function listTags(?ApiKey $apiKey = null): array { + /** @var TagRepository $repo */ + $repo = $this->em->getRepository(Tag::class); /** @var Tag[] $tags */ - $tags = $this->em->getRepository(Tag::class)->findBy([], ['name' => 'ASC']); + $tags = $repo->match(Spec::andX( + Spec::orderBy('name'), + new WithApiKeySpecsEnsuringJoin($apiKey), + )); return $tags; } /** * @return TagInfo[] */ - public function tagsInfo(): array + public function tagsInfo(?ApiKey $apiKey = null): array { /** @var TagRepositoryInterface $repo */ $repo = $this->em->getRepository(Tag::class); - return $repo->findTagsWithInfo(); + return $repo->findTagsWithInfo($apiKey !== null ? $apiKey->spec() : null); } /** * @param string[] $tagNames + * @throws ForbiddenTagOperationException */ - public function deleteTags(array $tagNames): void + public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void { + if ($apiKey !== null && ! $apiKey->isAdmin()) { + throw ForbiddenTagOperationException::forDeletion(); + } + /** @var TagRepository $repo */ $repo = $this->em->getRepository(Tag::class); $repo->deleteByName($tagNames); @@ -73,24 +88,29 @@ class TagService implements TagServiceInterface /** * @throws TagNotFoundException * @throws TagConflictException + * @throws ForbiddenTagOperationException */ - public function renameTag(string $oldName, string $newName): Tag + public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag { + if ($apiKey !== null && ! $apiKey->isAdmin()) { + throw ForbiddenTagOperationException::forRenaming(); + } + /** @var TagRepository $repo */ $repo = $this->em->getRepository(Tag::class); /** @var Tag|null $tag */ - $tag = $repo->findOneBy(['name' => $oldName]); + $tag = $repo->findOneBy(['name' => $renaming->oldName()]); if ($tag === null) { - throw TagNotFoundException::fromTag($oldName); + throw TagNotFoundException::fromTag($renaming->oldName()); } - $newNameExists = $newName !== $oldName && $repo->count(['name' => $newName]) > 0; + $newNameExists = $renaming->nameChanged() && $repo->count(['name' => $renaming->newName()]) > 0; if ($newNameExists) { - throw TagConflictException::fromExistingTag($oldName, $newName); + throw TagConflictException::forExistingTag($renaming); } - $tag->rename($newName); + $tag->rename($renaming->newName()); $this->em->flush(); return $tag; diff --git a/module/Core/src/Tag/TagServiceInterface.php b/module/Core/src/Tag/TagServiceInterface.php index 3c8c6e69..34cf1871 100644 --- a/module/Core/src/Tag/TagServiceInterface.php +++ b/module/Core/src/Tag/TagServiceInterface.php @@ -6,26 +6,30 @@ namespace Shlinkio\Shlink\Core\Tag; use Doctrine\Common\Collections\Collection; use Shlinkio\Shlink\Core\Entity\Tag; +use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; +use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; +use Shlinkio\Shlink\Rest\Entity\ApiKey; interface TagServiceInterface { /** * @return Tag[] */ - public function listTags(): array; + public function listTags(?ApiKey $apiKey = null): array; /** * @return TagInfo[] */ - public function tagsInfo(): array; + public function tagsInfo(?ApiKey $apiKey = null): array; /** * @param string[] $tagNames + * @throws ForbiddenTagOperationException */ - public function deleteTags(array $tagNames): void; + public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void; /** * @deprecated @@ -37,6 +41,7 @@ interface TagServiceInterface /** * @throws TagNotFoundException * @throws TagConflictException + * @throws ForbiddenTagOperationException */ - public function renameTag(string $oldName, string $newName): Tag; + public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag; } diff --git a/module/Core/src/Validation/ShortUrlMetaInputFilter.php b/module/Core/src/Validation/ShortUrlMetaInputFilter.php index e3b630e4..ca29ad14 100644 --- a/module/Core/src/Validation/ShortUrlMetaInputFilter.php +++ b/module/Core/src/Validation/ShortUrlMetaInputFilter.php @@ -11,6 +11,7 @@ use Laminas\InputFilter\InputFilter; use Laminas\Validator; use Shlinkio\Shlink\Common\Validation; use Shlinkio\Shlink\Core\Util\CocurSymfonySluggerBridge; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use const Shlinkio\Shlink\Core\CUSTOM_SLUGS_REGEXP; use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH; @@ -54,6 +55,7 @@ class ShortUrlMetaInputFilter extends InputFilter $customSlug->getFilterChain()->attach(new Validation\SluggerFilter(new CocurSymfonySluggerBridge(new Slugify([ 'regexp' => CUSTOM_SLUGS_REGEXP, 'lowercase' => false, // We want to keep it case sensitive + 'rulesets' => ['default'], ])))); $customSlug->getValidatorChain()->attach(new Validator\NotEmpty([ Validator\NotEmpty::STRING, @@ -72,7 +74,11 @@ class ShortUrlMetaInputFilter extends InputFilter $domain->getValidatorChain()->attach(new Validation\HostAndPortValidator()); $this->add($domain); - $this->add($this->createInput(self::API_KEY, false)); + $apiKeyInput = new Input(self::API_KEY); + $apiKeyInput + ->setRequired(false) + ->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class])); + $this->add($apiKeyInput); } private function createPositiveNumberInput(string $name, int $min = 1): Input diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index de3219ff..ab06079a 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -8,6 +8,7 @@ use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsStatsHelper implements VisitsStatsHelperInterface { @@ -18,15 +19,15 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface $this->em = $em; } - public function getVisitsStats(): VisitsStats + public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats { - return new VisitsStats($this->getVisitsCount()); + return new VisitsStats($this->getVisitsCount($apiKey)); } - private function getVisitsCount(): int + private function getVisitsCount(?ApiKey $apiKey): int { /** @var VisitRepository $visitsRepo */ $visitsRepo = $this->em->getRepository(Visit::class); - return $visitsRepo->count([]); + return $visitsRepo->countVisits($apiKey); } } diff --git a/module/Core/src/Visit/VisitsStatsHelperInterface.php b/module/Core/src/Visit/VisitsStatsHelperInterface.php index 81423cb0..ca044d4b 100644 --- a/module/Core/src/Visit/VisitsStatsHelperInterface.php +++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php @@ -5,8 +5,9 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; +use Shlinkio\Shlink\Rest\Entity\ApiKey; interface VisitsStatsHelperInterface { - public function getVisitsStats(): VisitsStats; + public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats; } diff --git a/module/Core/templates/404.html b/module/Core/templates/404.html new file mode 100644 index 00000000..93e6fb64 --- /dev/null +++ b/module/Core/templates/404.html @@ -0,0 +1,27 @@ + + + + Not Found | Shlink + + + + + + + + +

+
+

404

+
+

Page not found.

+

The page you requested could not be found.

+
+
+ + diff --git a/module/Core/templates/error/404.phtml b/module/Core/templates/error/404.phtml deleted file mode 100644 index 20ac4ff8..00000000 --- a/module/Core/templates/error/404.phtml +++ /dev/null @@ -1,19 +0,0 @@ -layout('ShlinkCore::layout/default') ?> - -start('title') ?> - Not Found -end() ?> - -start('stylesheets') ?> - -end() ?> - -start('main') ?> -

404

-
-

Page not found.

-

The page you requested could not be found.

-end() ?> diff --git a/module/Core/templates/error/error.phtml b/module/Core/templates/error/error.phtml deleted file mode 100644 index 77108f26..00000000 --- a/module/Core/templates/error/error.phtml +++ /dev/null @@ -1,25 +0,0 @@ -layout('ShlinkCore::layout/default') ?> - -start('title') ?> - e($status . ' ' . $reason) ?> -end() ?> - -start('stylesheets') ?> - -end() ?> - -start('main') ?> -

Oops!

-
- - -

- -

'This short URL doesn't seem to be valid.

-

'Make sure you included all the characters, with no extra punctuation.

- -end() ?> - diff --git a/module/Core/templates/invalid-short-code.html b/module/Core/templates/invalid-short-code.html new file mode 100644 index 00000000..61c5a804 --- /dev/null +++ b/module/Core/templates/invalid-short-code.html @@ -0,0 +1,27 @@ + + + + Invalid Short URL | Shlink + + + + + + + + +
+
+

Oops!

+
+

This short URL doesn't seem to be valid.

+

Make sure you included all the characters, with no extra punctuation.

+
+
+ + diff --git a/module/Core/templates/invalid-short-code.phtml b/module/Core/templates/invalid-short-code.phtml deleted file mode 100644 index 47be4a16..00000000 --- a/module/Core/templates/invalid-short-code.phtml +++ /dev/null @@ -1,19 +0,0 @@ -layout('ShlinkCore::layout/default') ?> - -start('title') ?> - Invalid Short URL -end() ?> - -start('stylesheets') ?> - -end() ?> - -start('main') ?> -

Oops!

-
-

This short URL doesn't seem to be valid.

-

Make sure you included all the characters, with no extra punctuation.

-end() ?> diff --git a/module/Core/templates/layout/default.phtml b/module/Core/templates/layout/default.phtml deleted file mode 100644 index fbb78b26..00000000 --- a/module/Core/templates/layout/default.phtml +++ /dev/null @@ -1,23 +0,0 @@ - - - - <?= $this->section('title', '') ?> | Shlink - - - - - - - section('stylesheets', '') ?> - - -
-
- section('main', '') ?> -
-
- - diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index 79f9caaf..eae77154 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -9,16 +9,15 @@ use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; class DomainRepositoryTest extends DatabaseTestCase { - protected const ENTITIES_TO_EMPTY = [ShortUrl::class, Domain::class]; - private DomainRepository $repo; - protected function setUp(): void + protected function beforeEach(): void { $this->repo = $this->getEntityManager()->getRepository(Domain::class); } @@ -28,35 +27,70 @@ class DomainRepositoryTest extends DatabaseTestCase { $fooDomain = new Domain('foo.com'); $this->getEntityManager()->persist($fooDomain); - $fooShortUrl = $this->createShortUrl($fooDomain); - $this->getEntityManager()->persist($fooShortUrl); + $this->getEntityManager()->persist($this->createShortUrl($fooDomain)); $barDomain = new Domain('bar.com'); $this->getEntityManager()->persist($barDomain); - $barShortUrl = $this->createShortUrl($barDomain); - $this->getEntityManager()->persist($barShortUrl); + $this->getEntityManager()->persist($this->createShortUrl($barDomain)); $bazDomain = new Domain('baz.com'); $this->getEntityManager()->persist($bazDomain); - $bazShortUrl = $this->createShortUrl($bazDomain); - $this->getEntityManager()->persist($bazShortUrl); + $this->getEntityManager()->persist($this->createShortUrl($bazDomain)); $detachedDomain = new Domain('detached.com'); $this->getEntityManager()->persist($detachedDomain); $this->getEntityManager()->flush(); - self::assertEquals([$barDomain, $bazDomain, $fooDomain], $this->repo->findDomainsWithout()); + self::assertEquals([$barDomain, $bazDomain, $fooDomain], $this->repo->findDomainsWithout(null)); self::assertEquals([$barDomain, $bazDomain], $this->repo->findDomainsWithout('foo.com')); self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout('bar.com')); self::assertEquals([$barDomain, $fooDomain], $this->repo->findDomainsWithout('baz.com')); } - private function createShortUrl(Domain $domain): ShortUrl + /** @test */ + public function findDomainsReturnsJustThoseMatchingProvidedApiKey(): void + { + $authorApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); + $this->getEntityManager()->persist($authorApiKey); + $authorAndDomainApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); + $this->getEntityManager()->persist($authorAndDomainApiKey); + + $fooDomain = new Domain('foo.com'); + $this->getEntityManager()->persist($fooDomain); + $this->getEntityManager()->persist($this->createShortUrl($fooDomain, $authorApiKey)); + + $barDomain = new Domain('bar.com'); + $this->getEntityManager()->persist($barDomain); + $this->getEntityManager()->persist($this->createShortUrl($barDomain, $authorAndDomainApiKey)); + + $bazDomain = new Domain('baz.com'); + $this->getEntityManager()->persist($bazDomain); + $this->getEntityManager()->persist($this->createShortUrl($bazDomain, $authorApiKey)); + + $this->getEntityManager()->flush(); + + $authorAndDomainApiKey->registerRole(RoleDefinition::forDomain($fooDomain)); + + $fooDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($fooDomain)); + $this->getEntityManager()->persist($fooDomainApiKey); + + $barDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($barDomain)); + $this->getEntityManager()->persist($fooDomainApiKey); + + $this->getEntityManager()->flush(); + + self::assertEquals([$fooDomain], $this->repo->findDomainsWithout(null, $fooDomainApiKey)); + self::assertEquals([$barDomain], $this->repo->findDomainsWithout(null, $barDomainApiKey)); + self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout(null, $authorApiKey)); + self::assertEquals([], $this->repo->findDomainsWithout(null, $authorAndDomainApiKey)); + } + + private function createShortUrl(Domain $domain, ?ApiKey $apiKey = null): ShortUrl { return new ShortUrl( 'foo', - ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority()]), + ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority(), 'apiKey' => $apiKey]), new class ($domain) implements ShortUrlRelationResolverInterface { private Domain $domain; @@ -69,11 +103,6 @@ class DomainRepositoryTest extends DatabaseTestCase { return $this->domain; } - - public function resolveApiKey(?string $key): ?ApiKey - { - return null; - } }, ); } diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index 86eb2aa3..c942f61d 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -16,8 +16,11 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; use function count; @@ -26,16 +29,9 @@ class ShortUrlRepositoryTest extends DatabaseTestCase { use TagManagerTrait; - protected const ENTITIES_TO_EMPTY = [ - Tag::class, - Visit::class, - ShortUrl::class, - Domain::class, - ]; - private ShortUrlRepository $repo; - public function setUp(): void + public function beforeEach(): void { $this->repo = $this->getEntityManager()->getRepository(ShortUrl::class); } @@ -308,17 +304,84 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); + $result = $this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta)); + + self::assertSame($shortUrl1, $result); + self::assertNotSame($shortUrl2, $result); + self::assertNotSame($shortUrl3, $result); + } + + /** @test */ + public function findOneMatchingAppliesProvidedApiKeyConditions(): void + { + $start = Chronos::parse('2020-03-05 20:18:30'); + + $wrongDomain = new Domain('wrong.com'); + $this->getEntityManager()->persist($wrongDomain); + $rightDomain = new Domain('right.com'); + $this->getEntityManager()->persist($rightDomain); + + $this->getEntityManager()->flush(); + + $apiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); + $this->getEntityManager()->persist($apiKey); + $otherApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); + $this->getEntityManager()->persist($otherApiKey); + $wrongDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($wrongDomain)); + $this->getEntityManager()->persist($wrongDomainApiKey); + $rightDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($rightDomain)); + $this->getEntityManager()->persist($rightDomainApiKey); + + $shortUrl = new ShortUrl('foo', ShortUrlMeta::fromRawData( + ['validSince' => $start, 'apiKey' => $apiKey, 'domain' => $rightDomain->getAuthority()], + ), new PersistenceShortUrlRelationResolver($this->getEntityManager())); + $shortUrl->setTags($this->tagNamesToEntities($this->getEntityManager(), ['foo', 'bar'])); + $this->getEntityManager()->persist($shortUrl); + + $this->getEntityManager()->flush(); + self::assertSame( - $shortUrl1, - $this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta)), + $shortUrl, + $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData(['validSince' => $start])), ); - self::assertNotSame( - $shortUrl2, - $this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta)), + self::assertSame($shortUrl, $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + 'validSince' => $start, + 'apiKey' => $apiKey, + ]))); + self::assertNull($this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + 'validSince' => $start, + 'apiKey' => $otherApiKey, + ]))); + + self::assertSame( + $shortUrl, + $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + 'validSince' => $start, + 'domain' => $rightDomain->getAuthority(), + ])), ); - self::assertNotSame( - $shortUrl3, - $this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta)), + self::assertSame( + $shortUrl, + $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + 'validSince' => $start, + 'domain' => $rightDomain->getAuthority(), + 'apiKey' => $rightDomainApiKey, + ])), + ); + self::assertSame( + $shortUrl, + $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + 'validSince' => $start, + 'domain' => $rightDomain->getAuthority(), + 'apiKey' => $apiKey, + ])), + ); + self::assertNull( + $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + 'validSince' => $start, + 'domain' => $rightDomain->getAuthority(), + 'apiKey' => $wrongDomainApiKey, + ])), ); } diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index 9f8b9893..59f53b6b 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -5,26 +5,25 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Repository; use Doctrine\Common\Collections\ArrayCollection; +use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\TagRepository; +use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; use function array_chunk; class TagRepositoryTest extends DatabaseTestCase { - protected const ENTITIES_TO_EMPTY = [ - Visit::class, - ShortUrl::class, - Tag::class, - ]; - private TagRepository $repo; - protected function setUp(): void + protected function beforeEach(): void { $this->repo = $this->getEntityManager()->getRepository(Tag::class); } @@ -97,4 +96,59 @@ class TagRepositoryTest extends DatabaseTestCase $result[3]->jsonSerialize(), ); } + + /** @test */ + public function tagExistsReturnsExpectedResultBasedOnApiKey(): void + { + $domain = new Domain('foo.com'); + $this->getEntityManager()->persist($domain); + $this->getEntityManager()->flush(); + + $authorApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); + $this->getEntityManager()->persist($authorApiKey); + $domainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($domain)); + $this->getEntityManager()->persist($domainApiKey); + + $names = ['foo', 'bar', 'baz', 'another']; + $tags = []; + foreach ($names as $name) { + $tag = new Tag($name); + $tags[] = $tag; + $this->getEntityManager()->persist($tag); + } + + [$firstUrlTags, $secondUrlTags] = array_chunk($tags, 3); + + $shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData(['apiKey' => $authorApiKey])); + $shortUrl->setTags(new ArrayCollection($firstUrlTags)); + $this->getEntityManager()->persist($shortUrl); + + $shortUrl2 = new ShortUrl( + '', + ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority()]), + new PersistenceShortUrlRelationResolver($this->getEntityManager()), + ); + $shortUrl2->setTags(new ArrayCollection($secondUrlTags)); + $this->getEntityManager()->persist($shortUrl2); + + $this->getEntityManager()->flush(); + + self::assertTrue($this->repo->tagExists('foo')); + self::assertTrue($this->repo->tagExists('bar')); + self::assertTrue($this->repo->tagExists('baz')); + self::assertTrue($this->repo->tagExists('another')); + self::assertFalse($this->repo->tagExists('invalid')); + + self::assertTrue($this->repo->tagExists('foo', $authorApiKey)); + self::assertTrue($this->repo->tagExists('bar', $authorApiKey)); + self::assertTrue($this->repo->tagExists('baz', $authorApiKey)); + self::assertFalse($this->repo->tagExists('another', $authorApiKey)); + self::assertFalse($this->repo->tagExists('invalid', $authorApiKey)); + + self::assertFalse($this->repo->tagExists('foo', $domainApiKey)); + self::assertFalse($this->repo->tagExists('bar', $domainApiKey)); + self::assertFalse($this->repo->tagExists('baz', $domainApiKey)); + self::assertTrue($this->repo->tagExists('another', $domainApiKey)); + self::assertFalse($this->repo->tagExists('invalid', $domainApiKey)); + } } diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index f6df4b9b..1cc1c895 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -15,7 +15,10 @@ use Shlinkio\Shlink\Core\Entity\VisitLocation; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\VisitRepository; +use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; use Shlinkio\Shlink\IpGeolocation\Model\Location; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; use function Functional\map; @@ -24,17 +27,9 @@ use function sprintf; class VisitRepositoryTest extends DatabaseTestCase { - protected const ENTITIES_TO_EMPTY = [ - VisitLocation::class, - Visit::class, - ShortUrl::class, - Domain::class, - Tag::class, - ]; - private VisitRepository $repo; - protected function setUp(): void + protected function beforeEach(): void { $this->repo = $this->getEntityManager()->getRepository(Visit::class); } @@ -185,6 +180,49 @@ class VisitRepositoryTest extends DatabaseTestCase ))); } + /** @test */ + public function countReturnsExpectedResultBasedOnApiKey(): void + { + $domain = new Domain('foo.com'); + $this->getEntityManager()->persist($domain); + + $this->getEntityManager()->flush(); + + $apiKey1 = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); + $this->getEntityManager()->persist($apiKey1); + $shortUrl = new ShortUrl( + '', + ShortUrlMeta::fromRawData(['apiKey' => $apiKey1, 'domain' => $domain->getAuthority()]), + new PersistenceShortUrlRelationResolver($this->getEntityManager()), + ); + $this->getEntityManager()->persist($shortUrl); + $this->createVisitsForShortUrl($shortUrl, 4); + + $apiKey2 = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); + $this->getEntityManager()->persist($apiKey2); + $shortUrl2 = new ShortUrl('', ShortUrlMeta::fromRawData(['apiKey' => $apiKey2])); + $this->getEntityManager()->persist($shortUrl2); + $this->createVisitsForShortUrl($shortUrl2, 5); + + $shortUrl3 = new ShortUrl( + '', + ShortUrlMeta::fromRawData(['apiKey' => $apiKey2, 'domain' => $domain->getAuthority()]), + new PersistenceShortUrlRelationResolver($this->getEntityManager()), + ); + $this->getEntityManager()->persist($shortUrl3); + $this->createVisitsForShortUrl($shortUrl3, 7); + + $domainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($domain)); + $this->getEntityManager()->persist($domainApiKey); + + $this->getEntityManager()->flush(); + + self::assertEquals(4 + 5 + 7, $this->repo->countVisits()); + self::assertEquals(4, $this->repo->countVisits($apiKey1)); + self::assertEquals(5 + 7, $this->repo->countVisits($apiKey2)); + self::assertEquals(4 + 7, $this->repo->countVisits($domainApiKey)); + } + private function createShortUrlsAndVisits(bool $withDomain = true): array { $shortUrl = new ShortUrl(''); @@ -192,7 +230,24 @@ class VisitRepositoryTest extends DatabaseTestCase $shortCode = $shortUrl->getShortCode(); $this->getEntityManager()->persist($shortUrl); - for ($i = 0; $i < 6; $i++) { + $this->createVisitsForShortUrl($shortUrl); + + if ($withDomain) { + $shortUrlWithDomain = new ShortUrl('', ShortUrlMeta::fromRawData([ + 'customSlug' => $shortCode, + 'domain' => $domain, + ])); + $this->getEntityManager()->persist($shortUrlWithDomain); + $this->createVisitsForShortUrl($shortUrlWithDomain, 3); + $this->getEntityManager()->flush(); + } + + return [$shortCode, $domain, $shortUrl]; + } + + private function createVisitsForShortUrl(ShortUrl $shortUrl, int $amount = 6): void + { + for ($i = 0; $i < $amount; $i++) { $visit = new Visit( $shortUrl, Visitor::emptyInstance(), @@ -201,26 +256,5 @@ class VisitRepositoryTest extends DatabaseTestCase ); $this->getEntityManager()->persist($visit); } - - if ($withDomain) { - $shortUrlWithDomain = new ShortUrl('', ShortUrlMeta::fromRawData([ - 'customSlug' => $shortCode, - 'domain' => $domain, - ])); - $this->getEntityManager()->persist($shortUrlWithDomain); - - for ($i = 0; $i < 3; $i++) { - $visit = new Visit( - $shortUrlWithDomain, - Visitor::emptyInstance(), - true, - Chronos::parse(sprintf('2016-01-0%s', $i + 1)), - ); - $this->getEntityManager()->persist($visit); - } - $this->getEntityManager()->flush(); - } - - return [$shortCode, $domain, $shortUrl]; } } diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 1237585c..76daa406 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -6,11 +6,13 @@ namespace ShlinkioTest\Shlink\Core\Action; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequest; +use Laminas\Diactoros\ServerRequestFactory; use Mezzio\Router\RouterInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Common\Response\QrCodeResponse; use Shlinkio\Shlink\Core\Action\QrCodeAction; @@ -19,6 +21,8 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; +use function getimagesizefromstring; + class QrCodeActionTest extends TestCase { use ProphecyTrait; @@ -51,21 +55,6 @@ class QrCodeActionTest extends TestCase $process->shouldHaveBeenCalledOnce(); } - /** @test */ - public function anInvalidShortCodeWillReturnNotFoundResponse(): void - { - $shortCode = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, '')) - ->willThrow(ShortUrlNotFoundException::class) - ->shouldBeCalledOnce(); - $delegate = $this->prophesize(RequestHandlerInterface::class); - $process = $delegate->handle(Argument::any())->willReturn(new Response()); - - $this->action->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate->reveal()); - - $process->shouldHaveBeenCalledOnce(); - } - /** @test */ public function aCorrectRequestReturnsTheQrCodeResponse(): void { @@ -110,4 +99,31 @@ class QrCodeActionTest extends TestCase yield 'svg format' => [['format' => 'svg'], 'image/svg+xml']; yield 'unsupported format' => [['format' => 'jpg'], 'image/png']; } + + /** + * @test + * @dataProvider provideRequestsWithSize + */ + public function imageIsReturnedWithExpectedSize(ServerRequestInterface $req, int $expectedSize): void + { + $code = 'abc123'; + $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn(new ShortUrl('')); + $delegate = $this->prophesize(RequestHandlerInterface::class); + + $resp = $this->action->process($req->withAttribute('shortCode', $code), $delegate->reveal()); + [$size] = getimagesizefromstring((string) $resp->getBody()); + + self::assertEquals($expectedSize, $size); + } + + public function provideRequestsWithSize(): iterable + { + yield 'no size' => [ServerRequestFactory::fromGlobals(), 300]; + yield 'size in attr' => [ServerRequestFactory::fromGlobals()->withAttribute('size', '400'), 400]; + yield 'size in query' => [ServerRequestFactory::fromGlobals()->withQueryParams(['size' => '123']), 123]; + yield 'size in query and attr' => [ + ServerRequestFactory::fromGlobals()->withAttribute('size', '350')->withQueryParams(['size' => '123']), + 350, + ]; + } } diff --git a/module/Core/test/ConfigProviderTest.php b/module/Core/test/ConfigProviderTest.php index 2660803b..4044446a 100644 --- a/module/Core/test/ConfigProviderTest.php +++ b/module/Core/test/ConfigProviderTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core; +use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\ConfigProvider; @@ -19,11 +20,13 @@ class ConfigProviderTest extends TestCase /** @test */ public function properConfigIsReturned(): void { - $config = $this->configProvider->__invoke(); + $config = ($this->configProvider)(); + self::assertCount(5, $config); self::assertArrayHasKey('routes', $config); self::assertArrayHasKey('dependencies', $config); - self::assertArrayHasKey('templates', $config); - self::assertArrayHasKey('mezzio', $config); + self::assertArrayHasKey('entity_manager', $config); + self::assertArrayHasKey('events', $config); + self::assertArrayHasKey(ConfigAbstractFactory::class, $config); } } diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index 906088ea..46e39c5a 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -6,11 +6,16 @@ namespace ShlinkioTest\Shlink\Core\Domain; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; 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\Rest\ApiKey\Model\RoleDefinition; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class DomainServiceTest extends TestCase { @@ -22,20 +27,20 @@ class DomainServiceTest extends TestCase public function setUp(): void { $this->em = $this->prophesize(EntityManagerInterface::class); - $this->domainService = new DomainService($this->em->reveal()); + $this->domainService = new DomainService($this->em->reveal(), 'default.com'); } /** * @test * @dataProvider provideExcludedDomains */ - public function listDomainsWithoutDelegatesIntoRepository(?string $excludedDomain, array $expectedResult): void + public function listDomainsDelegatesIntoRepository(array $domains, array $expectedResult, ?ApiKey $apiKey): void { $repo = $this->prophesize(DomainRepositoryInterface::class); $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); - $findDomains = $repo->findDomainsWithout($excludedDomain)->willReturn($expectedResult); + $findDomains = $repo->findDomainsWithout('default.com', $apiKey)->willReturn($domains); - $result = $this->domainService->listDomainsWithout($excludedDomain); + $result = $this->domainService->listDomains($apiKey); self::assertEquals($expectedResult, $result); $getRepo->shouldHaveBeenCalledOnce(); @@ -44,9 +49,96 @@ class DomainServiceTest extends TestCase public function provideExcludedDomains(): iterable { - yield 'no excluded domain' => [null, []]; - yield 'foo.com excluded domain' => ['foo.com', []]; - yield 'bar.com excluded domain' => ['bar.com', [new Domain('bar.com')]]; - yield 'baz.com excluded domain' => ['baz.com', [new Domain('foo.com'), new Domain('bar.com')]]; + $default = new DomainItem('default.com', true); + $adminApiKey = new ApiKey(); + $domainSpecificApiKey = ApiKey::withRoles(RoleDefinition::forDomain((new Domain(''))->setId('123'))); + + yield 'empty list without API key' => [[], [$default], null]; + yield 'one item without API key' => [ + [new Domain('bar.com')], + [$default, new DomainItem('bar.com', false)], + null, + ]; + yield 'multiple items without API key' => [ + [new Domain('foo.com'), new Domain('bar.com')], + [$default, new DomainItem('foo.com', false), new DomainItem('bar.com', false)], + null, + ]; + + yield 'empty list with admin API key' => [[], [$default], $adminApiKey]; + yield 'one item with admin API key' => [ + [new Domain('bar.com')], + [$default, new DomainItem('bar.com', false)], + $adminApiKey, + ]; + yield 'multiple items with admin API key' => [ + [new Domain('foo.com'), new Domain('bar.com')], + [$default, new DomainItem('foo.com', false), new DomainItem('bar.com', false)], + $adminApiKey, + ]; + + yield 'empty list with domain-specific API key' => [[], [], $domainSpecificApiKey]; + yield 'one item with domain-specific API key' => [ + [new Domain('bar.com')], + [new DomainItem('bar.com', false)], + $domainSpecificApiKey, + ]; + yield 'multiple items with domain-specific API key' => [ + [new Domain('foo.com'), new Domain('bar.com')], + [new DomainItem('foo.com', false), new DomainItem('bar.com', false)], + $domainSpecificApiKey, + ]; + } + + /** @test */ + public function getDomainThrowsExceptionWhenDomainIsNotFound(): void + { + $find = $this->em->find(Domain::class, '123')->willReturn(null); + + $this->expectException(DomainNotFoundException::class); + $find->shouldBeCalledOnce(); + + $this->domainService->getDomain('123'); + } + + /** @test */ + public function getDomainReturnsEntityWhenFound(): void + { + $domain = new Domain(''); + $find = $this->em->find(Domain::class, '123')->willReturn($domain); + + $result = $this->domainService->getDomain('123'); + + self::assertSame($domain, $result); + $find->shouldHaveBeenCalledOnce(); + } + + /** + * @test + * @dataProvider provideFoundDomains + */ + public function getOrCreateAlwaysPersistsDomain(?Domain $foundDomain): void + { + $authority = 'example.com'; + $repo = $this->prophesize(DomainRepositoryInterface::class); + $repo->findOneBy(['authority' => $authority])->willReturn($foundDomain); + $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + $persist = $this->em->persist($foundDomain !== null ? $foundDomain : Argument::type(Domain::class)); + $flush = $this->em->flush(); + + $result = $this->domainService->getOrCreate($authority); + + if ($foundDomain !== null) { + self::assertSame($result, $foundDomain); + } + $getRepo->shouldHaveBeenCalledOnce(); + $persist->shouldHaveBeenCalledOnce(); + $flush->shouldHaveBeenCalledOnce(); + } + + public function provideFoundDomains(): iterable + { + yield 'domain not found' => [null]; + yield 'domain found' => [new Domain('')]; } } diff --git a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php index e10954ca..6b9f9989 100644 --- a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php @@ -4,29 +4,30 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ErrorHandler; +use Closure; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequestFactory; use Mezzio\Router\Route; use Mezzio\Router\RouteResult; -use Mezzio\Template\TemplateRendererInterface; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Shlinkio\Shlink\Core\ErrorHandler\NotFoundTemplateHandler; class NotFoundTemplateHandlerTest extends TestCase { - use ProphecyTrait; - private NotFoundTemplateHandler $handler; - private ObjectProphecy $renderer; + private Closure $readFile; + private bool $readFileCalled; public function setUp(): void { - $this->renderer = $this->prophesize(TemplateRendererInterface::class); - $this->handler = new NotFoundTemplateHandler($this->renderer->reveal()); + $this->readFileCalled = false; + $this->readFile = function (string $fileName): string { + $this->readFileCalled = true; + return $fileName; + }; + $this->handler = new NotFoundTemplateHandler($this->readFile); } /** @@ -35,13 +36,11 @@ class NotFoundTemplateHandlerTest extends TestCase */ public function properErrorTemplateIsRendered(ServerRequestInterface $request, string $expectedTemplate): void { - $request = $request->withHeader('Accept', 'text/html'); - $render = $this->renderer->render($expectedTemplate)->willReturn(''); - - $resp = $this->handler->handle($request); + $resp = $this->handler->handle($request->withHeader('Accept', 'text/html')); self::assertInstanceOf(Response\HtmlResponse::class, $resp); - $render->shouldHaveBeenCalledOnce(); + self::assertStringContainsString($expectedTemplate, (string) $resp->getBody()); + self::assertTrue($this->readFileCalled); } public function provideTemplates(): iterable diff --git a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php index ab12a349..8c9119a5 100644 --- a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php @@ -17,9 +17,9 @@ use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; +use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlVisited; +use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; use Shlinkio\Shlink\Core\EventDispatcher\LocateShortUrlVisit; -use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited; -use Shlinkio\Shlink\Core\EventDispatcher\VisitLocated; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\Model\Location; diff --git a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php index 90891db3..b8e71297 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php @@ -13,8 +13,8 @@ use Psr\Log\LoggerInterface; use RuntimeException; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToMercure; -use Shlinkio\Shlink\Core\EventDispatcher\VisitLocated; use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGeneratorInterface; use Shlinkio\Shlink\Core\Model\Visitor; use Symfony\Component\Mercure\PublisherInterface; diff --git a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php index 8319f448..e7021e18 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php +++ b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php @@ -19,8 +19,8 @@ use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToWebHooks; -use Shlinkio\Shlink\Core\EventDispatcher\VisitLocated; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Options\AppOptions; diff --git a/module/Core/test/Exception/DomainNotFoundExceptionTest.php b/module/Core/test/Exception/DomainNotFoundExceptionTest.php new file mode 100644 index 00000000..6ac26efd --- /dev/null +++ b/module/Core/test/Exception/DomainNotFoundExceptionTest.php @@ -0,0 +1,28 @@ +getMessage()); + self::assertEquals($expectedMessage, $e->getDetail()); + self::assertEquals('Domain not found', $e->getTitle()); + self::assertEquals('DOMAIN_NOT_FOUND', $e->getType()); + self::assertEquals(['id' => $id], $e->getAdditionalData()); + self::assertEquals(404, $e->getStatus()); + } +} diff --git a/module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php b/module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php new file mode 100644 index 00000000..c42f864a --- /dev/null +++ b/module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php @@ -0,0 +1,37 @@ +assertExceptionShape($e, $expectedMessage); + } + + private function assertExceptionShape(ForbiddenTagOperationException $e, string $expectedMessage): void + { + self::assertEquals($expectedMessage, $e->getMessage()); + self::assertEquals($expectedMessage, $e->getDetail()); + self::assertEquals('Forbidden tag operation', $e->getTitle()); + self::assertEquals('FORBIDDEN_OPERATION', $e->getType()); + self::assertEquals(403, $e->getStatus()); + } + + public function provideExceptions(): iterable + { + yield 'deletion' => [ForbiddenTagOperationException::forDeletion(), 'You are not allowed to delete tags']; + yield 'renaming' => [ForbiddenTagOperationException::forRenaming(), 'You are not allowed to rename tags']; + } +} diff --git a/module/Core/test/Exception/TagConflictExceptionTest.php b/module/Core/test/Exception/TagConflictExceptionTest.php index 156fd500..4427eb40 100644 --- a/module/Core/test/Exception/TagConflictExceptionTest.php +++ b/module/Core/test/Exception/TagConflictExceptionTest.php @@ -2,22 +2,23 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Rest\Exception; +namespace ShlinkioTest\Shlink\Core\Exception; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\TagConflictException; +use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use function sprintf; class TagConflictExceptionTest extends TestCase { /** @test */ - public function properlyCreatesExceptionFromNotFoundTag(): void + public function properlyCreatesExceptionForExistingTag(): void { $oldName = 'foo'; $newName = 'bar'; $expectedMessage = sprintf('You cannot rename tag %s to %s, because it already exists', $oldName, $newName); - $e = TagConflictException::fromExistingTag($oldName, $newName); + $e = TagConflictException::forExistingTag(TagRenaming::fromNames($oldName, $newName)); self::assertEquals($expectedMessage, $e->getMessage()); self::assertEquals($expectedMessage, $e->getDetail()); diff --git a/module/Core/test/Exception/TagNotFoundExceptionTest.php b/module/Core/test/Exception/TagNotFoundExceptionTest.php index c6e8bf1d..ccd63788 100644 --- a/module/Core/test/Exception/TagNotFoundExceptionTest.php +++ b/module/Core/test/Exception/TagNotFoundExceptionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Rest\Exception; +namespace ShlinkioTest\Shlink\Core\Exception; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; diff --git a/module/Core/test/Exception/ValidationExceptionTest.php b/module/Core/test/Exception/ValidationExceptionTest.php index 44a46c1f..a0980738 100644 --- a/module/Core/test/Exception/ValidationExceptionTest.php +++ b/module/Core/test/Exception/ValidationExceptionTest.php @@ -32,14 +32,14 @@ class ValidationExceptionTest extends TestCase ]; $barValue = print_r(['baz', 'foo'], true); $expectedStringRepresentation = << bar - 'something' => {$barValue} -EOT; + 'foo' => bar + 'something' => {$barValue} + EOT; $inputFilter = $this->prophesize(InputFilterInterface::class); $getMessages = $inputFilter->getMessages()->willReturn($invalidData); - $e = ValidationException::fromInputFilter($inputFilter->reveal()); + $e = ValidationException::fromInputFilter($inputFilter->reveal(), $prev); self::assertEquals($invalidData, $e->getInvalidElements()); self::assertEquals(['invalidElements' => array_keys($invalidData)], $e->getAdditionalData()); @@ -52,6 +52,6 @@ EOT; public function provideExceptions(): iterable { - return [[null, new RuntimeException(), new LogicException()]]; + return [[null], [new RuntimeException()], [new LogicException()]]; } } diff --git a/module/Core/test/Model/ShortUrlMetaTest.php b/module/Core/test/Model/ShortUrlMetaTest.php index c8cc3ff6..3c45dad9 100644 --- a/module/Core/test/Model/ShortUrlMetaTest.php +++ b/module/Core/test/Model/ShortUrlMetaTest.php @@ -88,5 +88,10 @@ class ShortUrlMetaTest extends TestCase yield ['wp-admin.php', 'wp-admin.php']; yield ['UPPER_lower', 'UPPER_lower']; yield ['more~url_special.chars', 'more~url_special.chars']; + yield ['äéñ', 'äen']; + yield ['구글', '구글']; + yield ['グーグル', 'グーグル']; + yield ['谷歌', '谷歌']; + yield ['гугл', 'гугл']; } } diff --git a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index 9f541ebe..c3848aa5 100644 --- a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -11,6 +11,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlRepositoryAdapterTest extends TestCase { @@ -41,11 +42,11 @@ class ShortUrlRepositoryAdapterTest extends TestCase 'endDate' => $endDate, 'orderBy' => $orderBy, ]); - $adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params); + $adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params, null); $orderBy = $params->orderBy(); $dateRange = $params->dateRange(); - $this->repo->findList(10, 5, $searchTerm, $tags, $orderBy, $dateRange)->shouldBeCalledOnce(); + $this->repo->findList(10, 5, $searchTerm, $tags, $orderBy, $dateRange, null)->shouldBeCalledOnce(); $adapter->getItems(5, 10); } @@ -65,10 +66,11 @@ class ShortUrlRepositoryAdapterTest extends TestCase 'startDate' => $startDate, 'endDate' => $endDate, ]); - $adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params); + $apiKey = new ApiKey(); + $adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params, $apiKey); $dateRange = $params->dateRange(); - $this->repo->countList($searchTerm, $tags, $dateRange)->shouldBeCalledOnce(); + $this->repo->countList($searchTerm, $tags, $dateRange, $apiKey->spec())->shouldBeCalledOnce(); $adapter->count(); } diff --git a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php index b3a47749..a0bc6405 100644 --- a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php @@ -11,18 +11,17 @@ use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsForTagPaginatorAdapterTest extends TestCase { use ProphecyTrait; - private VisitsForTagPaginatorAdapter $adapter; private ObjectProphecy $repo; protected function setUp(): void { $this->repo = $this->prophesize(VisitRepositoryInterface::class); - $this->adapter = new VisitsForTagPaginatorAdapter($this->repo->reveal(), 'foo', VisitsParams::fromRawData([])); } /** @test */ @@ -31,10 +30,11 @@ class VisitsForTagPaginatorAdapterTest extends TestCase $count = 3; $limit = 1; $offset = 5; - $findVisits = $this->repo->findVisitsByTag('foo', new DateRange(), $limit, $offset)->willReturn([]); + $adapter = $this->createAdapter(null); + $findVisits = $this->repo->findVisitsByTag('foo', new DateRange(), $limit, $offset, null)->willReturn([]); for ($i = 0; $i < $count; $i++) { - $this->adapter->getItems($offset, $limit); + $adapter->getItems($offset, $limit); } $findVisits->shouldHaveBeenCalledTimes($count); @@ -44,12 +44,24 @@ class VisitsForTagPaginatorAdapterTest extends TestCase public function repoIsCalledOnlyOnceForCount(): void { $count = 3; - $countVisits = $this->repo->countVisitsByTag('foo', new DateRange())->willReturn(3); + $apiKey = new ApiKey(); + $adapter = $this->createAdapter($apiKey); + $countVisits = $this->repo->countVisitsByTag('foo', new DateRange(), $apiKey->spec())->willReturn(3); for ($i = 0; $i < $count; $i++) { - $this->adapter->count(); + $adapter->count(); } $countVisits->shouldHaveBeenCalledOnce(); } + + private function createAdapter(?ApiKey $apiKey): VisitsForTagPaginatorAdapter + { + return new VisitsForTagPaginatorAdapter( + $this->repo->reveal(), + 'foo', + VisitsParams::fromRawData([]), + $apiKey, + ); + } } diff --git a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php index 508a0984..76ccc220 100644 --- a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php @@ -12,22 +12,17 @@ use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsPaginatorAdapterTest extends TestCase { use ProphecyTrait; - private VisitsPaginatorAdapter $adapter; private ObjectProphecy $repo; protected function setUp(): void { $this->repo = $this->prophesize(VisitRepositoryInterface::class); - $this->adapter = new VisitsPaginatorAdapter( - $this->repo->reveal(), - new ShortUrlIdentifier(''), - VisitsParams::fromRawData([]), - ); } /** @test */ @@ -36,10 +31,13 @@ class VisitsPaginatorAdapterTest extends TestCase $count = 3; $limit = 1; $offset = 5; - $findVisits = $this->repo->findVisitsByShortCode('', null, new DateRange(), $limit, $offset)->willReturn([]); + $adapter = $this->createAdapter(null); + $findVisits = $this->repo->findVisitsByShortCode('', null, new DateRange(), $limit, $offset, null)->willReturn( + [], + ); for ($i = 0; $i < $count; $i++) { - $this->adapter->getItems($offset, $limit); + $adapter->getItems($offset, $limit); } $findVisits->shouldHaveBeenCalledTimes($count); @@ -49,12 +47,24 @@ class VisitsPaginatorAdapterTest extends TestCase public function repoIsCalledOnlyOnceForCount(): void { $count = 3; - $countVisits = $this->repo->countVisitsByShortCode('', null, new DateRange())->willReturn(3); + $apiKey = new ApiKey(); + $adapter = $this->createAdapter($apiKey); + $countVisits = $this->repo->countVisitsByShortCode('', null, new DateRange(), $apiKey->spec())->willReturn(3); for ($i = 0; $i < $count; $i++) { - $this->adapter->count(); + $adapter->count(); } $countVisits->shouldHaveBeenCalledOnce(); } + + private function createAdapter(?ApiKey $apiKey): VisitsPaginatorAdapter + { + return new VisitsPaginatorAdapter( + $this->repo->reveal(), + new ShortUrlIdentifier(''), + VisitsParams::fromRawData([]), + $apiKey !== null ? $apiKey->spec() : null, + ); + } } diff --git a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php index 3566b285..e7cc0041 100644 --- a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php @@ -18,12 +18,15 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolver; +use Shlinkio\Shlink\Rest\Entity\ApiKey; +use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; use function Functional\map; use function range; class ShortUrlResolverTest extends TestCase { + use ApiKeyHelpersTrait; use ProphecyTrait; private ShortUrlResolver $urlResolver; @@ -35,37 +38,43 @@ class ShortUrlResolverTest extends TestCase $this->urlResolver = new ShortUrlResolver($this->em->reveal()); } - /** @test */ - public function shortCodeIsProperlyParsed(): void + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function shortCodeIsProperlyParsed(?ApiKey $apiKey): void { $shortUrl = new ShortUrl('expected_url'); $shortCode = $shortUrl->getShortCode(); $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $findOne = $repo->findOne($shortCode, null)->willReturn($shortUrl); + $findOne = $repo->findOne($shortCode, null, $apiKey !== null ? $apiKey->spec() : null)->willReturn($shortUrl); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - $result = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode)); + $result = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey); self::assertSame($shortUrl, $result); $findOne->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); } - /** @test */ - public function exceptionIsThrownIfShortcodeIsNotFound(): void + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function exceptionIsThrownIfShortcodeIsNotFound(?ApiKey $apiKey): void { $shortCode = 'abc123'; $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $findOne = $repo->findOne($shortCode, null)->willReturn(null); - $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + $findOne = $repo->findOne($shortCode, null, $apiKey !== null ? $apiKey->spec() : null)->willReturn(null); + $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal(), $apiKey); $this->expectException(ShortUrlNotFoundException::class); $findOne->shouldBeCalledOnce(); $getRepo->shouldBeCalledOnce(); - $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode)); + $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey); } /** @test */ diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index fc2de22b..99f26a53 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -20,11 +20,14 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Service\ShortUrlService; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; +use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; use function count; class ShortUrlServiceTest extends TestCase { + use ApiKeyHelpersTrait; use ProphecyTrait; private ShortUrlService $service; @@ -48,8 +51,11 @@ class ShortUrlServiceTest extends TestCase ); } - /** @test */ - public function listedUrlsAreReturnedFromEntityManager(): void + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void { $list = [ new ShortUrl(''), @@ -63,25 +69,29 @@ class ShortUrlServiceTest extends TestCase $repo->countList(Argument::cetera())->willReturn(count($list))->shouldBeCalledOnce(); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - $list = $this->service->listShortUrls(ShortUrlsParams::emptyInstance()); + $list = $this->service->listShortUrls(ShortUrlsParams::emptyInstance(), $apiKey); self::assertEquals(4, $list->getCurrentItemCount()); } - /** @test */ - public function providedTagsAreGetFromRepoAndSetToTheShortUrl(): void + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function providedTagsAreGetFromRepoAndSetToTheShortUrl(?ApiKey $apiKey): void { $shortUrl = $this->prophesize(ShortUrl::class); $shortUrl->setTags(Argument::any())->shouldBeCalledOnce(); $shortCode = 'abc123'; - $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn($shortUrl->reveal()) - ->shouldBeCalledOnce(); + $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey) + ->willReturn($shortUrl->reveal()) + ->shouldBeCalledOnce(); $tagRepo = $this->prophesize(EntityRepository::class); $tagRepo->findOneBy(['name' => 'foo'])->willReturn(new Tag('foo'))->shouldBeCalledOnce(); $tagRepo->findOneBy(['name' => 'bar'])->willReturn(null)->shouldBeCalledOnce(); $this->em->getRepository(Tag::class)->willReturn($tagRepo->reveal()); - $this->service->setTagsByShortCode(new ShortUrlIdentifier($shortCode), ['foo', 'bar']); + $this->service->setTagsByShortCode(new ShortUrlIdentifier($shortCode), ['foo', 'bar'], $apiKey); } /** @@ -90,15 +100,19 @@ class ShortUrlServiceTest extends TestCase */ public function updateMetadataByShortCodeUpdatesProvidedData( int $expectedValidateCalls, - ShortUrlEdit $shortUrlEdit + ShortUrlEdit $shortUrlEdit, + ?ApiKey $apiKey ): void { $originalLongUrl = 'originalLongUrl'; $shortUrl = new ShortUrl($originalLongUrl); - $findShortUrl = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier('abc123'))->willReturn($shortUrl); + $findShortUrl = $this->urlResolver->resolveShortUrl( + new ShortUrlIdentifier('abc123'), + $apiKey, + )->willReturn($shortUrl); $flush = $this->em->flush()->willReturn(null); - $result = $this->service->updateMetadataByShortCode(new ShortUrlIdentifier('abc123'), $shortUrlEdit); + $result = $this->service->updateMetadataByShortCode(new ShortUrlIdentifier('abc123'), $shortUrlEdit, $apiKey); self::assertSame($shortUrl, $result); self::assertEquals($shortUrlEdit->validSince(), $shortUrl->getValidSince()); @@ -121,19 +135,19 @@ class ShortUrlServiceTest extends TestCase 'validUntil' => Chronos::parse('2017-01-05 00:00:00')->toAtomString(), 'maxVisits' => 5, ], - )]; + ), null]; yield 'long URL' => [1, ShortUrlEdit::fromRawData( [ 'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(), 'maxVisits' => 10, 'longUrl' => 'modifiedLongUrl', ], - )]; + ), new ApiKey()]; yield 'long URL with validation' => [1, ShortUrlEdit::fromRawData( [ 'longUrl' => 'modifiedLongUrl', 'validateUrl' => true, ], - )]; + ), null]; } } diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index 16fd8683..5f518184 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -10,14 +10,20 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\Tag; +use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; +use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagService; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; +use Shlinkio\Shlink\Rest\Entity\ApiKey; +use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; class TagServiceTest extends TestCase { + use ApiKeyHelpersTrait; use ProphecyTrait; private TagService $service; @@ -28,7 +34,7 @@ class TagServiceTest extends TestCase { $this->em = $this->prophesize(EntityManagerInterface::class); $this->repo = $this->prophesize(TagRepository::class); - $this->em->getRepository(Tag::class)->willReturn($this->repo->reveal())->shouldBeCalled(); + $this->em->getRepository(Tag::class)->willReturn($this->repo->reveal()); $this->service = new TagService($this->em->reveal()); } @@ -38,37 +44,55 @@ class TagServiceTest extends TestCase { $expected = [new Tag('foo'), new Tag('bar')]; - $find = $this->repo->findBy(Argument::cetera())->willReturn($expected); + $match = $this->repo->match(Argument::cetera())->willReturn($expected); $result = $this->service->listTags(); self::assertEquals($expected, $result); - $find->shouldHaveBeenCalled(); + $match->shouldHaveBeenCalled(); } - /** @test */ - public function tagsInfoDelegatesOnRepository(): void + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function tagsInfoDelegatesOnRepository(?ApiKey $apiKey): void { $expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)]; - $find = $this->repo->findTagsWithInfo()->willReturn($expected); + $find = $this->repo->findTagsWithInfo($apiKey === null ? null : $apiKey->spec())->willReturn($expected); - $result = $this->service->tagsInfo(); + $result = $this->service->tagsInfo($apiKey); self::assertEquals($expected, $result); $find->shouldHaveBeenCalled(); } - /** @test */ - public function deleteTagsDelegatesOnRepository(): void + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function deleteTagsDelegatesOnRepository(?ApiKey $apiKey): void { $delete = $this->repo->deleteByName(['foo', 'bar'])->willReturn(4); - $this->service->deleteTags(['foo', 'bar']); + $this->service->deleteTags(['foo', 'bar'], $apiKey); $delete->shouldHaveBeenCalled(); } + /** @test */ + public function deleteTagsThrowsExceptionWhenProvidedApiKeyIsNotAdmin(): void + { + $delete = $this->repo->deleteByName(['foo', 'bar']); + + $this->expectException(ForbiddenTagOperationException::class); + $this->expectExceptionMessage('You are not allowed to delete tags'); + $delete->shouldNotBeCalled(); + + $this->service->deleteTags(['foo', 'bar'], ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls())); + } + /** @test */ public function createTagsPersistsEntities(): void { @@ -84,15 +108,18 @@ class TagServiceTest extends TestCase $flush->shouldHaveBeenCalled(); } - /** @test */ - public function renameInvalidTagThrowsException(): void + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function renameInvalidTagThrowsException(?ApiKey $apiKey): void { $find = $this->repo->findOneBy(Argument::cetera())->willReturn(null); $find->shouldBeCalled(); $this->expectException(TagNotFoundException::class); - $this->service->renameTag('foo', 'bar'); + $this->service->renameTag(TagRenaming::fromNames('foo', 'bar'), $apiKey); } /** @@ -107,7 +134,7 @@ class TagServiceTest extends TestCase $countTags = $this->repo->count(Argument::cetera())->willReturn($count); $flush = $this->em->flush()->willReturn(null); - $tag = $this->service->renameTag($oldName, $newName); + $tag = $this->service->renameTag(TagRenaming::fromNames($oldName, $newName)); self::assertSame($expected, $tag); self::assertEquals($newName, (string) $tag); @@ -122,8 +149,11 @@ class TagServiceTest extends TestCase yield 'different names names' => ['foo', 'bar', 0]; } - /** @test */ - public function renameTagToAnExistingNameThrowsException(): void + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function renameTagToAnExistingNameThrowsException(?ApiKey $apiKey): void { $find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo')); $countTags = $this->repo->count(Argument::cetera())->willReturn(1); @@ -134,6 +164,21 @@ class TagServiceTest extends TestCase $flush->shouldNotBeCalled(); $this->expectException(TagConflictException::class); - $this->service->renameTag('foo', 'bar'); + $this->service->renameTag(TagRenaming::fromNames('foo', 'bar'), $apiKey); + } + + /** @test */ + public function renamingTagThrowsExceptionWhenProvidedApiKeyIsNotAdmin(): void + { + $getRepo = $this->em->getRepository(Tag::class); + + $this->expectExceptionMessage(ForbiddenTagOperationException::class); + $this->expectExceptionMessage('You are not allowed to rename tags'); + $getRepo->shouldNotBeCalled(); + + $this->service->renameTag( + TagRenaming::fromNames('foo', 'bar'), + ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()), + ); } } diff --git a/module/Core/test/Service/VisitsTrackerTest.php b/module/Core/test/Service/VisitsTrackerTest.php index 1d9096e3..17135f57 100644 --- a/module/Core/test/Service/VisitsTrackerTest.php +++ b/module/Core/test/Service/VisitsTrackerTest.php @@ -15,7 +15,7 @@ use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; -use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited; +use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlVisited; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; @@ -25,12 +25,15 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\Service\VisitsTracker; +use Shlinkio\Shlink\Rest\Entity\ApiKey; +use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; use function Functional\map; use function range; class VisitsTrackerTest extends TestCase { + use ApiKeyHelpersTrait; use ProphecyTrait; private VisitsTracker $visitsTracker; @@ -42,7 +45,7 @@ class VisitsTrackerTest extends TestCase $this->em = $this->prophesize(EntityManager::class); $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); - $this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), true); + $this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), true); } /** @test */ @@ -58,21 +61,27 @@ class VisitsTrackerTest extends TestCase $this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled(); } - /** @test */ - public function infoReturnsVisitsForCertainShortCode(): void + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function infoReturnsVisitsForCertainShortCode(?ApiKey $apiKey): void { $shortCode = '123ABC'; + $spec = $apiKey === null ? null : $apiKey->spec(); $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $count = $repo->shortCodeIsInUse($shortCode, null)->willReturn(true); + $count = $repo->shortCodeIsInUse($shortCode, null, $spec)->willReturn(true); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce(); $list = map(range(0, 1), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance())); $repo2 = $this->prophesize(VisitRepository::class); - $repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0)->willReturn($list); - $repo2->countVisitsByShortCode($shortCode, null, Argument::type(DateRange::class))->willReturn(1); + $repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0, $spec)->willReturn( + $list, + ); + $repo2->countVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), $spec)->willReturn(1); $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); - $paginator = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams()); + $paginator = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams(), $apiKey); self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems())); $count->shouldHaveBeenCalledOnce(); @@ -83,7 +92,7 @@ class VisitsTrackerTest extends TestCase { $shortCode = '123ABC'; $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $count = $repo->shortCodeIsInUse($shortCode, null)->willReturn(false); + $count = $repo->shortCodeIsInUse($shortCode, null, null)->willReturn(false); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce(); $this->expectException(ShortUrlNotFoundException::class); @@ -96,35 +105,40 @@ class VisitsTrackerTest extends TestCase public function throwsExceptionWhenRequestingVisitsForInvalidTag(): void { $tag = 'foo'; + $apiKey = new ApiKey(); $repo = $this->prophesize(TagRepository::class); - $count = $repo->count(['name' => $tag])->willReturn(0); + $tagExists = $repo->tagExists($tag, $apiKey)->willReturn(false); $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); $this->expectException(TagNotFoundException::class); - $count->shouldBeCalledOnce(); + $tagExists->shouldBeCalledOnce(); $getRepo->shouldBeCalledOnce(); - $this->visitsTracker->visitsForTag($tag, new VisitsParams()); + $this->visitsTracker->visitsForTag($tag, new VisitsParams(), $apiKey); } - /** @test */ - public function visitsForTagAreReturnedAsExpected(): void + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function visitsForTagAreReturnedAsExpected(?ApiKey $apiKey): void { $tag = 'foo'; $repo = $this->prophesize(TagRepository::class); - $count = $repo->count(['name' => $tag])->willReturn(1); + $tagExists = $repo->tagExists($tag, $apiKey)->willReturn(true); $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + $spec = $apiKey === null ? null : $apiKey->spec(); $list = map(range(0, 1), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance())); $repo2 = $this->prophesize(VisitRepository::class); - $repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0)->willReturn($list); - $repo2->countVisitsByTag($tag, Argument::type(DateRange::class))->willReturn(1); + $repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0, $spec)->willReturn($list); + $repo2->countVisitsByTag($tag, Argument::type(DateRange::class), $spec)->willReturn(1); $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); - $paginator = $this->visitsTracker->visitsForTag($tag, new VisitsParams()); + $paginator = $this->visitsTracker->visitsForTag($tag, new VisitsParams(), $apiKey); self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems())); - $count->shouldHaveBeenCalledOnce(); + $tagExists->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); } } diff --git a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php index 5791d579..9cea7883 100644 --- a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -11,7 +11,6 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; -use Shlinkio\Shlink\Rest\Entity\ApiKey; class PersistenceShortUrlRelationResolverTest extends TestCase { @@ -63,38 +62,4 @@ class PersistenceShortUrlRelationResolverTest extends TestCase yield 'not found domain' => [null, $authority]; yield 'found domain' => [new Domain($authority), $authority]; } - - /** @test */ - public function returnsEmptyWhenNoApiKeyIsProvided(): void - { - $getRepository = $this->em->getRepository(ApiKey::class); - - self::assertNull($this->resolver->resolveApiKey(null)); - $getRepository->shouldNotHaveBeenCalled(); - } - - /** - * @test - * @dataProvider provideFoundApiKeys - */ - public function triesToFindApiKeyWhenValueIsProvided(?ApiKey $foundApiKey, string $key): void - { - $repo = $this->prophesize(ObjectRepository::class); - $find = $repo->findOneBy(['key' => $key])->willReturn($foundApiKey); - $getRepository = $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); - - $result = $this->resolver->resolveApiKey($key); - - self::assertSame($result, $foundApiKey); - $find->shouldHaveBeenCalledOnce(); - $getRepository->shouldHaveBeenCalledOnce(); - } - - public function provideFoundApiKeys(): iterable - { - $key = 'abc123'; - - yield 'not found api key' => [null, $key]; - yield 'found api key' => [new ApiKey(), $key]; - } } diff --git a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php index e2d0822c..84d838b9 100644 --- a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php @@ -38,19 +38,4 @@ class SimpleShortUrlRelationResolverTest extends TestCase yield 'empty domain' => [null]; yield 'non-empty domain' => ['domain.com']; } - - /** - * @test - * @dataProvider provideKeys - */ - public function alwaysReturnsNullForApiKeys(?string $key): void - { - self::assertNull($this->resolver->resolveApiKey($key)); - } - - public function provideKeys(): iterable - { - yield 'empty api key' => [null]; - yield 'non-empty api key' => ['abc123']; - } } diff --git a/module/Core/test/Util/ApiKeyHelpersTrait.php b/module/Core/test/Util/ApiKeyHelpersTrait.php new file mode 100644 index 00000000..0b21ed5f --- /dev/null +++ b/module/Core/test/Util/ApiKeyHelpersTrait.php @@ -0,0 +1,16 @@ + [null]; + yield 'admin API key' => [new ApiKey()]; + } +} diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index 2381a73a..cdc76bd4 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -36,7 +36,7 @@ class VisitsStatsHelperTest extends TestCase public function returnsExpectedVisitsStats(int $expectedCount): void { $repo = $this->prophesize(VisitRepository::class); - $count = $repo->count([])->willReturn($expectedCount); + $count = $repo->countVisits(null)->willReturn($expectedCount); $getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal()); $stats = $this->helper->getVisitsStats(); diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index bdd9d3a9..c2181f70 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -41,10 +41,11 @@ return [ ImplicitOptionsMiddleware::class => Middleware\EmptyResponseImplicitOptionsMiddlewareFactory::class, Middleware\BodyParserMiddleware::class => InvokableFactory::class, - Middleware\CrossDomainMiddleware::class => InvokableFactory::class, + Middleware\CrossDomainMiddleware::class => ConfigAbstractFactory::class, Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class => InvokableFactory::class, Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ConfigAbstractFactory::class, Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => ConfigAbstractFactory::class, + Middleware\ShortUrl\OverrideDomainMiddleware::class => ConfigAbstractFactory::class, ], ], @@ -74,12 +75,14 @@ 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, 'config.url_shortener.domain.hostname'], + Action\Domain\ListDomainsAction::class => [DomainService::class], + Middleware\CrossDomainMiddleware::class => ['config.cors'], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'], Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [ 'config.url_shortener.default_short_codes_length', ], + Middleware\ShortUrl\OverrideDomainMiddleware::class => [DomainService::class], ], ]; diff --git a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php index a5084cee..95f53b30 100644 --- a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php +++ b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php @@ -8,6 +8,7 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder; use Doctrine\ORM\Mapping\ClassMetadata; use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType; +use Shlinkio\Shlink\Rest\Entity\ApiKeyRole; use function Shlinkio\Shlink\Core\determineTableName; @@ -34,4 +35,11 @@ return static function (ClassMetadata $metadata, array $emConfig): void { $builder->createField('enabled', Types::BOOLEAN) ->build(); + + $builder->createOneToMany('roles', ApiKeyRole::class) + ->mappedBy('apiKey') + ->setIndexBy('roleName') + ->cascadePersist() + ->orphanRemoval() + ->build(); }; diff --git a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php new file mode 100644 index 00000000..9c6355e3 --- /dev/null +++ b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php @@ -0,0 +1,42 @@ +setTable(determineTableName('api_key_roles', $emConfig)); + + $builder->createField('id', Types::BIGINT) + ->makePrimaryKey() + ->generatedValue('IDENTITY') + ->option('unsigned', true) + ->build(); + + $builder->createField('roleName', Types::STRING) + ->columnName('role_name') + ->length(256) + ->nullable(false) + ->build(); + + $builder->createField('meta', Types::JSON) + ->columnName('meta') + ->nullable(false) + ->build(); + + $builder->createManyToOne('apiKey', ApiKey::class) + ->addJoinColumn('api_key_id', 'id', false, false, 'CASCADE') + ->cascadePersist() + ->build(); + + $builder->addUniqueConstraint(['role_name', 'api_key_id'], 'UQ_role_plus_api_key'); +}; diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index 64333254..a5382c38 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Rest; $contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class; $dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class; +$overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class; return [ @@ -16,9 +17,13 @@ return [ Action\ShortUrl\CreateShortUrlAction::getRouteDef([ $contentNegotiationMiddleware, $dropDomainMiddleware, + $overrideDomainMiddleware, Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class, ]), - Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([$contentNegotiationMiddleware]), + Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([ + $contentNegotiationMiddleware, + $overrideDomainMiddleware, + ]), Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]), Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]), Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]), diff --git a/module/Rest/src/Action/Domain/ListDomainsAction.php b/module/Rest/src/Action/Domain/ListDomainsAction.php index 7362123a..35ce04f3 100644 --- a/module/Rest/src/Action/Domain/ListDomainsAction.php +++ b/module/Rest/src/Action/Domain/ListDomainsAction.php @@ -8,10 +8,8 @@ use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; -use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; - -use function Functional\map; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class ListDomainsAction extends AbstractRestAction { @@ -19,33 +17,21 @@ class ListDomainsAction extends AbstractRestAction protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; private DomainServiceInterface $domainService; - private string $defaultDomain; - public function __construct(DomainServiceInterface $domainService, string $defaultDomain) + public function __construct(DomainServiceInterface $domainService) { $this->domainService = $domainService; - $this->defaultDomain = $defaultDomain; } public function handle(ServerRequestInterface $request): ResponseInterface { - $regularDomains = $this->domainService->listDomainsWithout($this->defaultDomain); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + $domainItems = $this->domainService->listDomains($apiKey); return new JsonResponse([ 'domains' => [ - 'data' => [ - $this->mapDomain($this->defaultDomain, true), - ...map($regularDomains, fn (Domain $domain) => $this->mapDomain($domain->getAuthority())), - ], + 'data' => $domainItems, ], ]); } - - private function mapDomain(string $domain, bool $isDefault = false): array - { - return [ - 'domain' => $domain, - 'isDefault' => $isDefault, - ]; - } } diff --git a/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php b/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php index bd5b487e..73eaa6ee 100644 --- a/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php @@ -10,6 +10,7 @@ use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class DeleteShortUrlAction extends AbstractRestAction { @@ -26,7 +27,10 @@ class DeleteShortUrlAction extends AbstractRestAction public function handle(ServerRequestInterface $request): ResponseInterface { $identifier = ShortUrlIdentifier::fromApiRequest($request); - $this->deleteShortUrlService->deleteByShortCode($identifier); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + + $this->deleteShortUrlService->deleteByShortCode($identifier, false, $apiKey); + return new EmptyResponse(); } } diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php index 30d95ae1..32d95b2d 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlEdit; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class EditShortUrlAction extends AbstractRestAction { @@ -28,8 +29,9 @@ class EditShortUrlAction extends AbstractRestAction { $shortUrlEdit = ShortUrlEdit::fromRawData((array) $request->getParsedBody()); $identifier = ShortUrlIdentifier::fromApiRequest($request); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $this->shortUrlService->updateMetadataByShortCode($identifier, $shortUrlEdit); + $this->shortUrlService->updateMetadataByShortCode($identifier, $shortUrlEdit, $apiKey); return new EmptyResponse(); } } diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php index def36d6c..7d115765 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class EditShortUrlTagsAction extends AbstractRestAction { @@ -35,8 +36,9 @@ class EditShortUrlTagsAction extends AbstractRestAction } ['tags' => $tags] = $bodyParams; $identifier = ShortUrlIdentifier::fromApiRequest($request); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $shortUrl = $this->shortUrlService->setTagsByShortCode($identifier, $tags); + $shortUrl = $this->shortUrlService->setTagsByShortCode($identifier, $tags, $apiKey); return new JsonResponse(['tags' => $shortUrl->getTags()->toArray()]); } } diff --git a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php index 10a0effc..35273dcc 100644 --- a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php +++ b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php @@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class ListShortUrlsAction extends AbstractRestAction { @@ -31,7 +32,10 @@ class ListShortUrlsAction extends AbstractRestAction public function handle(Request $request): Response { - $shortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData($request->getQueryParams())); + $shortUrls = $this->shortUrlService->listShortUrls( + ShortUrlsParams::fromRawData($request->getQueryParams()), + AuthenticationMiddleware::apiKeyFromRequest($request), + ); return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls, new ShortUrlDataTransformer( $this->domainConfig, ))]); diff --git a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php index 9c2cb3e4..99e58fee 100644 --- a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class ResolveShortUrlAction extends AbstractRestAction { @@ -29,7 +30,10 @@ class ResolveShortUrlAction extends AbstractRestAction public function handle(Request $request): Response { $transformer = new ShortUrlDataTransformer($this->domainConfig); - $url = $this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromApiRequest($request)); + $url = $this->urlResolver->resolveShortUrl( + ShortUrlIdentifier::fromApiRequest($request), + AuthenticationMiddleware::apiKeyFromRequest($request), + ); return new JsonResponse($transformer->transform($url)); } diff --git a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php index fe8c44aa..e9edee41 100644 --- a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php @@ -34,10 +34,10 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction protected function buildShortUrlData(Request $request): CreateShortUrlData { $query = $request->getQueryParams(); - $apiKey = $query['apiKey'] ?? ''; $longUrl = $query['longUrl'] ?? null; - if (! $this->apiKeyService->check($apiKey)) { + $apiKeyResult = $this->apiKeyService->check($query['apiKey'] ?? ''); + if (! $apiKeyResult->isValid()) { throw ValidationException::fromArray([ 'apiKey' => 'No API key was provided or it is not valid', ]); @@ -50,7 +50,9 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction } return new CreateShortUrlData($longUrl, [], ShortUrlMeta::fromRawData([ - ShortUrlMetaInputFilter::API_KEY => $apiKey, + ShortUrlMetaInputFilter::API_KEY => $apiKeyResult->apiKey(), + // This will usually be null, unless this API key enforces one specific domain + ShortUrlMetaInputFilter::DOMAIN => $request->getAttribute(ShortUrlMetaInputFilter::DOMAIN), ])); } } diff --git a/module/Rest/src/Action/Tag/DeleteTagsAction.php b/module/Rest/src/Action/Tag/DeleteTagsAction.php index f38c443a..b1be8af5 100644 --- a/module/Rest/src/Action/Tag/DeleteTagsAction.php +++ b/module/Rest/src/Action/Tag/DeleteTagsAction.php @@ -9,6 +9,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class DeleteTagsAction extends AbstractRestAction { @@ -22,18 +23,13 @@ class DeleteTagsAction extends AbstractRestAction $this->tagService = $tagService; } - /** - * Process an incoming server request and return a response, optionally delegating - * to the next middleware component to create the response. - * - * - */ public function handle(ServerRequestInterface $request): ResponseInterface { $query = $request->getQueryParams(); $tags = $query['tags'] ?? []; + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $this->tagService->deleteTags($tags); + $this->tagService->deleteTags($tags, $apiKey); return new EmptyResponse(); } } diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index 0832f17c..48cf923b 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -10,6 +10,7 @@ use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; use function Functional\map; @@ -29,16 +30,17 @@ class ListTagsAction extends AbstractRestAction { $query = $request->getQueryParams(); $withStats = ($query['withStats'] ?? null) === 'true'; + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); if (! $withStats) { return new JsonResponse([ 'tags' => [ - 'data' => $this->tagService->listTags(), + 'data' => $this->tagService->listTags($apiKey), ], ]); } - $tagsInfo = $this->tagService->tagsInfo(); + $tagsInfo = $this->tagService->tagsInfo($apiKey); $data = map($tagsInfo, fn (TagInfo $info) => (string) $info->tag()); return new JsonResponse([ diff --git a/module/Rest/src/Action/Tag/UpdateTagAction.php b/module/Rest/src/Action/Tag/UpdateTagAction.php index fbf93f50..d83d8b9a 100644 --- a/module/Rest/src/Action/Tag/UpdateTagAction.php +++ b/module/Rest/src/Action/Tag/UpdateTagAction.php @@ -7,9 +7,10 @@ namespace Shlinkio\Shlink\Rest\Action\Tag; use Laminas\Diactoros\Response\EmptyResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class UpdateTagAction extends AbstractRestAction { @@ -23,24 +24,12 @@ class UpdateTagAction extends AbstractRestAction $this->tagService = $tagService; } - /** - * Process an incoming server request and return a response, optionally delegating - * to the next middleware component to create the response. - * - * - * @throws \InvalidArgumentException - */ public function handle(ServerRequestInterface $request): ResponseInterface { $body = $request->getParsedBody(); - if (! isset($body['oldName'], $body['newName'])) { - throw ValidationException::fromArray([ - 'oldName' => 'oldName is required', - 'newName' => 'newName is required', - ]); - } + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $this->tagService->renameTag($body['oldName'], $body['newName']); + $this->tagService->renameTag(TagRenaming::fromArray($body), $apiKey); return new EmptyResponse(); } } diff --git a/module/Rest/src/Action/Visit/GlobalVisitsAction.php b/module/Rest/src/Action/Visit/GlobalVisitsAction.php index a27412b2..4810b100 100644 --- a/module/Rest/src/Action/Visit/GlobalVisitsAction.php +++ b/module/Rest/src/Action/Visit/GlobalVisitsAction.php @@ -9,6 +9,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class GlobalVisitsAction extends AbstractRestAction { @@ -24,8 +25,10 @@ class GlobalVisitsAction extends AbstractRestAction public function handle(ServerRequestInterface $request): ResponseInterface { + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + return new JsonResponse([ - 'visits' => $this->statsHelper->getVisitsStats(), + 'visits' => $this->statsHelper->getVisitsStats($apiKey), ]); } } diff --git a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php index 92a7e873..4a9a95e9 100644 --- a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php +++ b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php @@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class ShortUrlVisitsAction extends AbstractRestAction { @@ -30,7 +31,9 @@ class ShortUrlVisitsAction extends AbstractRestAction public function handle(Request $request): Response { $identifier = ShortUrlIdentifier::fromApiRequest($request); - $visits = $this->visitsTracker->info($identifier, VisitsParams::fromRawData($request->getQueryParams())); + $params = VisitsParams::fromRawData($request->getQueryParams()); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + $visits = $this->visitsTracker->info($identifier, $params, $apiKey); return new JsonResponse([ 'visits' => $this->serializePaginator($visits), diff --git a/module/Rest/src/Action/Visit/TagVisitsAction.php b/module/Rest/src/Action/Visit/TagVisitsAction.php index 1107ca5c..c83ee95c 100644 --- a/module/Rest/src/Action/Visit/TagVisitsAction.php +++ b/module/Rest/src/Action/Visit/TagVisitsAction.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class TagVisitsAction extends AbstractRestAction { @@ -29,7 +30,9 @@ class TagVisitsAction extends AbstractRestAction public function handle(Request $request): Response { $tag = $request->getAttribute('tag', ''); - $visits = $this->visitsTracker->visitsForTag($tag, VisitsParams::fromRawData($request->getQueryParams())); + $params = VisitsParams::fromRawData($request->getQueryParams()); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + $visits = $this->visitsTracker->visitsForTag($tag, $params, $apiKey); return new JsonResponse([ 'visits' => $this->serializePaginator($visits), diff --git a/module/Rest/src/ApiKey/Model/RoleDefinition.php b/module/Rest/src/ApiKey/Model/RoleDefinition.php new file mode 100644 index 00000000..569044dc --- /dev/null +++ b/module/Rest/src/ApiKey/Model/RoleDefinition.php @@ -0,0 +1,43 @@ +roleName = $roleName; + $this->meta = $meta; + } + + public static function forAuthoredShortUrls(): self + { + return new self(Role::AUTHORED_SHORT_URLS, []); + } + + public static function forDomain(Domain $domain): self + { + return new self( + Role::DOMAIN_SPECIFIC, + ['domain_id' => $domain->getId(), 'authority' => $domain->getAuthority()], + ); + } + + public function roleName(): string + { + return $this->roleName; + } + + public function meta(): array + { + return $this->meta; + } +} diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php new file mode 100644 index 00000000..ff3211ba --- /dev/null +++ b/module/Rest/src/ApiKey/Role.php @@ -0,0 +1,52 @@ + 'Author only', + self::DOMAIN_SPECIFIC => 'Domain only', + ]; + + public static function toSpec(ApiKeyRole $role, bool $inlined): Specification + { + if ($role->name() === self::AUTHORED_SHORT_URLS) { + return $inlined ? new BelongsToApiKeyInlined($role->apiKey()) : new BelongsToApiKey($role->apiKey()); + } + + if ($role->name() === self::DOMAIN_SPECIFIC) { + $domainId = self::domainIdFromMeta($role->meta()); + return $inlined ? new BelongsToDomainInlined($domainId) : new BelongsToDomain($domainId); + } + + return Spec::andX(); + } + + public static function domainIdFromMeta(array $meta): string + { + return $meta['domain_id'] ?? '-1'; + } + + public static function domainAuthorityFromMeta(array $meta): string + { + return $meta['authority'] ?? ''; + } + + public static function toFriendlyName(string $roleName): string + { + return self::ROLE_FRIENDLY_NAMES[$roleName] ?? ''; + } +} diff --git a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php new file mode 100644 index 00000000..64359d15 --- /dev/null +++ b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php @@ -0,0 +1,31 @@ +apiKey = $apiKey; + $this->fieldToJoin = $fieldToJoin; + } + + protected function getSpec(): Specification + { + return $this->apiKey === null || $this->apiKey->isAdmin() ? Spec::andX() : Spec::andX( + Spec::join($this->fieldToJoin, 's'), + $this->apiKey->spec(), + ); + } +} diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 1d372c9c..62729031 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -5,20 +5,52 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Entity; use Cake\Chronos\Chronos; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Exception; +use Happyr\DoctrineSpecification\Spec; +use Happyr\DoctrineSpecification\Specification\Specification; use Ramsey\Uuid\Uuid; use Shlinkio\Shlink\Common\Entity\AbstractEntity; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; +use Shlinkio\Shlink\Rest\ApiKey\Role; class ApiKey extends AbstractEntity { private string $key; private ?Chronos $expirationDate = null; private bool $enabled; + /** @var Collection|ApiKeyRole[] */ + private Collection $roles; + /** + * @throws Exception + */ public function __construct(?Chronos $expirationDate = null) { $this->key = Uuid::uuid4()->toString(); $this->expirationDate = $expirationDate; $this->enabled = true; + $this->roles = new ArrayCollection(); + } + + public static function withRoles(RoleDefinition ...$roleDefinitions): self + { + $apiKey = new self(); + + foreach ($roleDefinitions as $roleDefinition) { + $apiKey->registerRole($roleDefinition); + } + + return $apiKey; + } + + public static function withKey(string $key, ?Chronos $expirationDate = null): self + { + $apiKey = new self($expirationDate); + $apiKey->key = $key; + + return $apiKey; } public function getExpirationDate(): ?Chronos @@ -54,4 +86,57 @@ class ApiKey extends AbstractEntity { return $this->key; } + + public function toString(): string + { + return $this->key; + } + + public function spec(bool $inlined = false): Specification + { + $specs = $this->roles->map(fn (ApiKeyRole $role) => Role::toSpec($role, $inlined))->getValues(); + return Spec::andX(...$specs); + } + + public function isAdmin(): bool + { + return $this->roles->isEmpty(); + } + + public function hasRole(string $roleName): bool + { + return $this->roles->containsKey($roleName); + } + + public function getRoleMeta(string $roleName): array + { + /** @var ApiKeyRole|null $role */ + $role = $this->roles->get($roleName); + return $role === null ? [] : $role->meta(); + } + + public function mapRoles(callable $fun): array + { + return $this->roles->map(fn (ApiKeyRole $role) => $fun($role->name(), $role->meta()))->getValues(); + } + + public function registerRole(RoleDefinition $roleDefinition): void + { + $roleName = $roleDefinition->roleName(); + $meta = $roleDefinition->meta(); + + if ($this->hasRole($roleName)) { + /** @var ApiKeyRole $role */ + $role = $this->roles->get($roleName); + $role->updateMeta($meta); + } else { + $role = new ApiKeyRole($roleDefinition->roleName(), $roleDefinition->meta(), $this); + $this->roles[$roleName] = $role; + } + } + + public function removeRole(string $roleName): void + { + $this->roles->remove($roleName); + } } diff --git a/module/Rest/src/Entity/ApiKeyRole.php b/module/Rest/src/Entity/ApiKeyRole.php new file mode 100644 index 00000000..99dbb627 --- /dev/null +++ b/module/Rest/src/Entity/ApiKeyRole.php @@ -0,0 +1,41 @@ +roleName = $roleName; + $this->meta = $meta; + $this->apiKey = $apiKey; + } + + public function name(): string + { + return $this->roleName; + } + + public function meta(): array + { + return $this->meta; + } + + public function updateMeta(array $newMeta): void + { + $this->meta = $newMeta; + } + + public function apiKey(): ApiKey + { + return $this->apiKey; + } +} diff --git a/module/Rest/src/Middleware/AuthenticationMiddleware.php b/module/Rest/src/Middleware/AuthenticationMiddleware.php index add9f513..1eff50d2 100644 --- a/module/Rest/src/Middleware/AuthenticationMiddleware.php +++ b/module/Rest/src/Middleware/AuthenticationMiddleware.php @@ -11,6 +11,7 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Exception\MissingAuthenticationException; use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; @@ -43,20 +44,21 @@ class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterfa return $handler->handle($request); } - $apiKey = self::apiKeyFromRequest($request); + $apiKey = $request->getHeaderLine(self::API_KEY_HEADER); if (empty($apiKey)) { throw MissingAuthenticationException::fromExpectedTypes([self::API_KEY_HEADER]); } - if (! $this->apiKeyService->check($apiKey)) { + $result = $this->apiKeyService->check($apiKey); + if (! $result->isValid()) { throw VerifyAuthenticationException::forInvalidApiKey(); } - return $handler->handle($request); + return $handler->handle($request->withAttribute(ApiKey::class, $result->apiKey())); } - public static function apiKeyFromRequest(Request $request): string + public static function apiKeyFromRequest(Request $request): ApiKey { - return $request->getHeaderLine(self::API_KEY_HEADER); + return $request->getAttribute(ApiKey::class); } } diff --git a/module/Rest/src/Middleware/CrossDomainMiddleware.php b/module/Rest/src/Middleware/CrossDomainMiddleware.php index 171142a1..b438f7ec 100644 --- a/module/Rest/src/Middleware/CrossDomainMiddleware.php +++ b/module/Rest/src/Middleware/CrossDomainMiddleware.php @@ -17,6 +17,13 @@ use function implode; class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterface { + private array $config; + + public function __construct(array $config) + { + $this->config = $config; + } + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $response = $handler->handle($request); @@ -25,8 +32,7 @@ class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterfa } // Add Allow-Origin header - $response = $response->withHeader('Access-Control-Allow-Origin', $request->getHeader('Origin')) - ->withHeader('Access-Control-Expose-Headers', AuthenticationMiddleware::API_KEY_HEADER); + $response = $response->withHeader('Access-Control-Allow-Origin', $request->getHeader('Origin')); if ($request->getMethod() !== self::METHOD_OPTIONS) { return $response; } @@ -36,6 +42,8 @@ class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterfa private function addOptionsHeaders(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { + // TODO This won't work. The route has to be matched from the router as this middleware needs to be executed + // before trying to match the route /** @var RouteResult|null $matchedRoute */ $matchedRoute = $request->getAttribute(RouteResult::class); $matchedMethods = $matchedRoute !== null ? $matchedRoute->getAllowedMethods() : [ @@ -48,8 +56,8 @@ class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterfa ]; $corsHeaders = [ 'Access-Control-Allow-Methods' => implode(',', $matchedMethods), - 'Access-Control-Max-Age' => '1000', 'Access-Control-Allow-Headers' => $request->getHeaderLine('Access-Control-Request-Headers'), + 'Access-Control-Max-Age' => $this->config['max_age'], ]; // Options requests should always be empty and have a 204 status code diff --git a/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php b/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php new file mode 100644 index 00000000..817570a8 --- /dev/null +++ b/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php @@ -0,0 +1,46 @@ +domainService = $domainService; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + if (! $apiKey->hasRole(Role::DOMAIN_SPECIFIC)) { + return $handler->handle($request); + } + + $requestMethod = $request->getMethod(); + $domainId = Role::domainIdFromMeta($apiKey->getRoleMeta(Role::DOMAIN_SPECIFIC)); + $domain = $this->domainService->getDomain($domainId); + + if ($requestMethod === RequestMethodInterface::METHOD_POST) { + $payload = $request->getParsedBody(); + $payload[ShortUrlMetaInputFilter::DOMAIN] = $domain->getAuthority(); + + return $handler->handle($request->withParsedBody($payload)); + } + + return $handler->handle($request->withAttribute(ShortUrlMetaInputFilter::DOMAIN, $domain->getAuthority())); + } +} diff --git a/module/Rest/src/Service/ApiKeyCheckResult.php b/module/Rest/src/Service/ApiKeyCheckResult.php new file mode 100644 index 00000000..8ec3f65e --- /dev/null +++ b/module/Rest/src/Service/ApiKeyCheckResult.php @@ -0,0 +1,27 @@ +apiKey = $apiKey; + } + + public function isValid(): bool + { + return $this->apiKey !== null && $this->apiKey->isValid(); + } + + public function apiKey(): ?ApiKey + { + return $this->apiKey; + } +} diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index baa545c0..917cf048 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Rest\Service; use Cake\Chronos\Chronos; use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; use function sprintf; @@ -20,20 +21,23 @@ class ApiKeyService implements ApiKeyServiceInterface $this->em = $em; } - public function create(?Chronos $expirationDate = null): ApiKey + public function create(?Chronos $expirationDate = null, RoleDefinition ...$roleDefinitions): ApiKey { $key = new ApiKey($expirationDate); + foreach ($roleDefinitions as $definition) { + $key->registerRole($definition); + } + $this->em->persist($key); $this->em->flush(); return $key; } - public function check(string $key): bool + public function check(string $key): ApiKeyCheckResult { - /** @var ApiKey|null $apiKey */ $apiKey = $this->getByKey($key); - return $apiKey !== null && $apiKey->isValid(); + return new ApiKeyCheckResult($apiKey); } /** @@ -41,7 +45,6 @@ class ApiKeyService implements ApiKeyServiceInterface */ public function disable(string $key): ApiKey { - /** @var ApiKey|null $apiKey */ $apiKey = $this->getByKey($key); if ($apiKey === null) { throw new InvalidArgumentException(sprintf('API key "%s" does not exist and can\'t be disabled', $key)); @@ -63,7 +66,7 @@ class ApiKeyService implements ApiKeyServiceInterface return $apiKeys; } - public function getByKey(string $key): ?ApiKey + private function getByKey(string $key): ?ApiKey { /** @var ApiKey|null $apiKey */ $apiKey = $this->em->getRepository(ApiKey::class)->findOneBy([ diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index a08d8c60..562f106b 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -6,13 +6,14 @@ namespace Shlinkio\Shlink\Rest\Service; use Cake\Chronos\Chronos; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; interface ApiKeyServiceInterface { - public function create(?Chronos $expirationDate = null): ApiKey; + public function create(?Chronos $expirationDate = null, RoleDefinition ...$roleDefinitions): ApiKey; - public function check(string $key): bool; + public function check(string $key): ApiKeyCheckResult; /** * @throws InvalidArgumentException @@ -23,6 +24,4 @@ interface ApiKeyServiceInterface * @return ApiKey[] */ public function listKeys(bool $enabledOnly = false): array; - - public function getByKey(string $key): ?ApiKey; } diff --git a/module/Rest/test-api/Action/CreateShortUrlActionTest.php b/module/Rest/test-api/Action/CreateShortUrlActionTest.php index c9bf6fe5..5e388b0d 100644 --- a/module/Rest/test-api/Action/CreateShortUrlActionTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlActionTest.php @@ -244,18 +244,40 @@ class CreateShortUrlActionTest extends ApiTestCase self::assertNull($payload['domain']); } + /** + * @test + * @dataProvider provideDomains + */ + public function apiKeyDomainIsEnforced(?string $providedDomain): void + { + [$statusCode, ['domain' => $returnedDomain]] = $this->createShortUrl( + ['domain' => $providedDomain], + 'domain_api_key', + ); + + self::assertEquals(self::STATUS_OK, $statusCode); + self::assertEquals('example.com', $returnedDomain); + } + + public function provideDomains(): iterable + { + yield 'no domain' => [null]; + yield 'invalid domain' => ['this-will-be-overwritten.com']; + yield 'example domain' => ['example.com']; + } + /** * @return array { * @var int $statusCode * @var array $payload * } */ - private function createShortUrl(array $body = []): array + private function createShortUrl(array $body = [], string $apiKey = 'valid_api_key'): array { if (! isset($body['longUrl'])) { $body['longUrl'] = 'https://app.shlink.io'; } - $resp = $this->callApiWithKey(self::METHOD_POST, '/short-urls', [RequestOptions::JSON => $body]); + $resp = $this->callApiWithKey(self::METHOD_POST, '/short-urls', [RequestOptions::JSON => $body], $apiKey); $payload = $this->getJsonResponsePayload($resp); return [$resp->getStatusCode(), $payload]; diff --git a/module/Rest/test-api/Action/DeleteShortUrlActionTest.php b/module/Rest/test-api/Action/DeleteShortUrlActionTest.php index 7c66ff0b..76968cbd 100644 --- a/module/Rest/test-api/Action/DeleteShortUrlActionTest.php +++ b/module/Rest/test-api/Action/DeleteShortUrlActionTest.php @@ -18,9 +18,10 @@ class DeleteShortUrlActionTest extends ApiTestCase public function notFoundErrorIsReturnWhenDeletingInvalidUrl( string $shortCode, ?string $domain, - string $expectedDetail + string $expectedDetail, + string $apiKey ): void { - $resp = $this->callApiWithKey(self::METHOD_DELETE, $this->buildShortUrlPath($shortCode, $domain)); + $resp = $this->callApiWithKey(self::METHOD_DELETE, $this->buildShortUrlPath($shortCode, $domain), [], $apiKey); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); diff --git a/module/Rest/test-api/Action/DeleteTagsTest.php b/module/Rest/test-api/Action/DeleteTagsTest.php new file mode 100644 index 00000000..ca175b69 --- /dev/null +++ b/module/Rest/test-api/Action/DeleteTagsTest.php @@ -0,0 +1,35 @@ +callApiWithKey(self::METHOD_DELETE, '/tags', [ + RequestOptions::QUERY => ['tags' => ['foo']], + ], $apiKey); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(self::STATUS_FORBIDDEN, $resp->getStatusCode()); + self::assertEquals(self::STATUS_FORBIDDEN, $payload['status']); + self::assertEquals('FORBIDDEN_OPERATION', $payload['type']); + self::assertEquals('You are not allowed to delete tags', $payload['detail']); + self::assertEquals('Forbidden tag operation', $payload['title']); + } + + public function provideNonAdminApiKeys(): iterable + { + yield 'author' => ['author_api_key']; + yield 'domain' => ['domain_api_key']; + } +} diff --git a/module/Rest/test-api/Action/EditShortUrlActionTest.php b/module/Rest/test-api/Action/EditShortUrlActionTest.php index e6b37eba..a909130a 100644 --- a/module/Rest/test-api/Action/EditShortUrlActionTest.php +++ b/module/Rest/test-api/Action/EditShortUrlActionTest.php @@ -104,10 +104,11 @@ class EditShortUrlActionTest extends ApiTestCase public function tryingToEditInvalidUrlReturnsNotFoundError( string $shortCode, ?string $domain, - string $expectedDetail + string $expectedDetail, + string $apiKey ): void { $url = $this->buildShortUrlPath($shortCode, $domain); - $resp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => []]); + $resp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => []], $apiKey); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); diff --git a/module/Rest/test-api/Action/EditShortUrlTagsActionTest.php b/module/Rest/test-api/Action/EditShortUrlTagsActionTest.php index 84d2af80..7fe45c73 100644 --- a/module/Rest/test-api/Action/EditShortUrlTagsActionTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTagsActionTest.php @@ -34,12 +34,13 @@ class EditShortUrlTagsActionTest extends ApiTestCase public function providingInvalidShortCodeReturnsBadRequest( string $shortCode, ?string $domain, - string $expectedDetail + string $expectedDetail, + string $apiKey ): void { $url = $this->buildShortUrlPath($shortCode, $domain, '/tags'); $resp = $this->callApiWithKey(self::METHOD_PUT, $url, [RequestOptions::JSON => [ 'tags' => ['foo', 'bar'], - ]]); + ]], $apiKey); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); diff --git a/module/Rest/test-api/Action/GlobalVisitsActionTest.php b/module/Rest/test-api/Action/GlobalVisitsActionTest.php index b6767c0f..9c09da10 100644 --- a/module/Rest/test-api/Action/GlobalVisitsActionTest.php +++ b/module/Rest/test-api/Action/GlobalVisitsActionTest.php @@ -8,14 +8,24 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class GlobalVisitsActionTest extends ApiTestCase { - /** @test */ - public function returnsExpectedVisitsStats(): void + /** + * @test + * @dataProvider provideApiKeys + */ + public function returnsExpectedVisitsStats(string $apiKey, int $expectedVisits): void { - $resp = $this->callApiWithKey(self::METHOD_GET, '/visits'); + $resp = $this->callApiWithKey(self::METHOD_GET, '/visits', [], $apiKey); $payload = $this->getJsonResponsePayload($resp); self::assertArrayHasKey('visits', $payload); self::assertArrayHasKey('visitsCount', $payload['visits']); - self::assertEquals(7, $payload['visits']['visitsCount']); + self::assertEquals($expectedVisits, $payload['visits']['visitsCount']); + } + + public function provideApiKeys(): iterable + { + yield 'admin API key' => ['valid_api_key', 7]; + yield 'domain API key' => ['domain_api_key', 0]; + yield 'author API key' => ['author_api_key', 5]; } } diff --git a/module/Rest/test-api/Action/ListDomainsTest.php b/module/Rest/test-api/Action/ListDomainsTest.php index 045197e8..cf3167f8 100644 --- a/module/Rest/test-api/Action/ListDomainsTest.php +++ b/module/Rest/test-api/Action/ListDomainsTest.php @@ -8,30 +8,50 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class ListDomainsTest extends ApiTestCase { - /** @test */ - public function domainsAreProperlyListed(): void + /** + * @test + * @dataProvider provideApiKeysAndDomains + */ + public function domainsAreProperlyListed(string $apiKey, array $expectedDomains): void { - $resp = $this->callApiWithKey(self::METHOD_GET, '/domains'); + $resp = $this->callApiWithKey(self::METHOD_GET, '/domains', [], $apiKey); $respPayload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_OK, $resp->getStatusCode()); self::assertEquals([ 'domains' => [ - 'data' => [ - [ - 'domain' => 'doma.in', - 'isDefault' => true, - ], - [ - 'domain' => 'example.com', - 'isDefault' => false, - ], - [ - 'domain' => 'some-domain.com', - 'isDefault' => false, - ], - ], + 'data' => $expectedDomains, ], ], $respPayload); } + + public function provideApiKeysAndDomains(): iterable + { + yield 'admin API key' => ['valid_api_key', [ + [ + 'domain' => 'doma.in', + 'isDefault' => true, + ], + [ + 'domain' => 'example.com', + 'isDefault' => false, + ], + [ + 'domain' => 'some-domain.com', + 'isDefault' => false, + ], + ]]; + yield 'author API key' => ['author_api_key', [ + [ + 'domain' => 'doma.in', + 'isDefault' => true, + ], + ]]; + yield 'domain API key' => ['domain_api_key', [ + [ + 'domain' => 'example.com', + 'isDefault' => false, + ], + ]]; + } } diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index 2f1cf484..e38374c8 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -92,7 +92,7 @@ class ListShortUrlsTest extends ApiTestCase . '/considerations-to-properly-use-open-source-software-projects/', 'dateCreated' => '2019-01-01T00:00:30+00:00', 'visitsCount' => 0, - 'tags' => [], + 'tags' => ['foo'], 'meta' => [ 'validSince' => null, 'validUntil' => null, @@ -105,9 +105,9 @@ class ListShortUrlsTest extends ApiTestCase * @test * @dataProvider provideFilteredLists */ - public function shortUrlsAreProperlyListed(array $query, array $expectedShortUrls): void + public function shortUrlsAreProperlyListed(array $query, array $expectedShortUrls, string $apiKey): void { - $resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls', [RequestOptions::QUERY => $query]); + $resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls', [RequestOptions::QUERY => $query], $apiKey); $respPayload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_OK, $resp->getStatusCode()); @@ -128,7 +128,7 @@ class ListShortUrlsTest extends ApiTestCase self::SHORT_URL_META, self::SHORT_URL_CUSTOM_SLUG, self::SHORT_URL_CUSTOM_DOMAIN, - ]]; + ], 'valid_api_key']; yield [['orderBy' => 'shortCode'], [ self::SHORT_URL_SHLINK, self::SHORT_URL_CUSTOM_SLUG, @@ -136,7 +136,7 @@ class ListShortUrlsTest extends ApiTestCase self::SHORT_URL_META, self::SHORT_URL_DOCS, self::SHORT_URL_CUSTOM_DOMAIN, - ]]; + ], 'valid_api_key']; yield [['orderBy' => ['shortCode' => 'DESC']], [ // Deprecated self::SHORT_URL_DOCS, self::SHORT_URL_CUSTOM_DOMAIN, @@ -144,7 +144,7 @@ class ListShortUrlsTest extends ApiTestCase self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, self::SHORT_URL_CUSTOM_SLUG, self::SHORT_URL_SHLINK, - ]]; + ], 'valid_api_key']; yield [['orderBy' => 'shortCode-DESC'], [ self::SHORT_URL_DOCS, self::SHORT_URL_CUSTOM_DOMAIN, @@ -152,34 +152,43 @@ class ListShortUrlsTest extends ApiTestCase self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, self::SHORT_URL_CUSTOM_SLUG, self::SHORT_URL_SHLINK, - ]]; + ], 'valid_api_key']; yield [['startDate' => Chronos::parse('2018-12-01')->toAtomString()], [ self::SHORT_URL_META, self::SHORT_URL_CUSTOM_SLUG, self::SHORT_URL_CUSTOM_DOMAIN, - ]]; + ], 'valid_api_key']; yield [['endDate' => Chronos::parse('2018-12-01')->toAtomString()], [ self::SHORT_URL_SHLINK, self::SHORT_URL_DOCS, self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, - ]]; + ], 'valid_api_key']; yield [['tags' => ['foo']], [ self::SHORT_URL_SHLINK, self::SHORT_URL_META, - ]]; + self::SHORT_URL_CUSTOM_DOMAIN, + ], 'valid_api_key']; yield [['tags' => ['bar']], [ self::SHORT_URL_META, - ]]; + ], 'valid_api_key']; yield [['tags' => ['foo'], 'endDate' => Chronos::parse('2018-12-01')->toAtomString()], [ self::SHORT_URL_SHLINK, - ]]; + ], 'valid_api_key']; yield [['searchTerm' => 'alejandro'], [ self::SHORT_URL_META, self::SHORT_URL_CUSTOM_DOMAIN, - ]]; + ], 'valid_api_key']; yield [['searchTerm' => 'example.com'], [ self::SHORT_URL_CUSTOM_DOMAIN, - ]]; + ], 'valid_api_key']; + yield [[], [ + self::SHORT_URL_SHLINK, + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_SLUG, + ], 'author_api_key']; + yield [[], [ + self::SHORT_URL_CUSTOM_DOMAIN, + ], 'domain_api_key']; } private function buildPagination(int $itemsCount): array diff --git a/module/Rest/test-api/Action/ListTagsActionTest.php b/module/Rest/test-api/Action/ListTagsActionTest.php index 9191b4e0..188e6bdf 100644 --- a/module/Rest/test-api/Action/ListTagsActionTest.php +++ b/module/Rest/test-api/Action/ListTagsActionTest.php @@ -13,9 +13,9 @@ class ListTagsActionTest extends ApiTestCase * @test * @dataProvider provideQueries */ - public function expectedListOfTagsIsReturned(array $query, array $expectedTags): void + public function expectedListOfTagsIsReturned(string $apiKey, array $query, array $expectedTags): void { - $resp = $this->callApiWithKey(self::METHOD_GET, '/tags', [RequestOptions::QUERY => $query]); + $resp = $this->callApiWithKey(self::METHOD_GET, '/tags', [RequestOptions::QUERY => $query], $apiKey); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(['tags' => $expectedTags], $payload); @@ -23,10 +23,10 @@ class ListTagsActionTest extends ApiTestCase public function provideQueries(): iterable { - yield 'stats not requested' => [[], [ + yield 'admin API key without stats' => ['valid_api_key', [], [ 'data' => ['bar', 'baz', 'foo'], ]]; - yield 'stats requested' => [['withStats' => 'true'], [ + yield 'admin API key with stats' => ['valid_api_key', ['withStats' => 'true'], [ 'data' => ['bar', 'baz', 'foo'], 'stats' => [ [ @@ -39,6 +39,25 @@ class ListTagsActionTest extends ApiTestCase 'shortUrlsCount' => 0, 'visitsCount' => 0, ], + [ + 'tag' => 'foo', + 'shortUrlsCount' => 3, + 'visitsCount' => 5, + ], + ], + ]]; + + yield 'author API key without stats' => ['author_api_key', [], [ + 'data' => ['bar', 'foo'], + ]]; + yield 'author API key with stats' => ['author_api_key', ['withStats' => 'true'], [ + 'data' => ['bar', 'foo'], + 'stats' => [ + [ + 'tag' => 'bar', + 'shortUrlsCount' => 1, + 'visitsCount' => 2, + ], [ 'tag' => 'foo', 'shortUrlsCount' => 2, @@ -46,5 +65,19 @@ class ListTagsActionTest extends ApiTestCase ], ], ]]; + + yield 'domain API key without stats' => ['domain_api_key', [], [ + 'data' => ['foo'], + ]]; + yield 'domain API key with stats' => ['domain_api_key', ['withStats' => 'true'], [ + 'data' => ['foo'], + 'stats' => [ + [ + 'tag' => 'foo', + 'shortUrlsCount' => 1, + 'visitsCount' => 0, + ], + ], + ]]; } } diff --git a/module/Rest/test-api/Action/RenameTagTest.php b/module/Rest/test-api/Action/RenameTagTest.php new file mode 100644 index 00000000..7ed4ff4f --- /dev/null +++ b/module/Rest/test-api/Action/RenameTagTest.php @@ -0,0 +1,38 @@ +callApiWithKey(self::METHOD_PUT, '/tags', [ + RequestOptions::JSON => [ + 'oldName' => 'foo', + 'newName' => 'foo_renamed', + ], + ], $apiKey); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(self::STATUS_FORBIDDEN, $resp->getStatusCode()); + self::assertEquals(self::STATUS_FORBIDDEN, $payload['status']); + self::assertEquals('FORBIDDEN_OPERATION', $payload['type']); + self::assertEquals('You are not allowed to rename tags', $payload['detail']); + self::assertEquals('Forbidden tag operation', $payload['title']); + } + + public function provideNonAdminApiKeys(): iterable + { + yield 'author' => ['author_api_key']; + yield 'domain' => ['domain_api_key']; + } +} diff --git a/module/Rest/test-api/Action/ResolveShortUrlActionTest.php b/module/Rest/test-api/Action/ResolveShortUrlActionTest.php index cf1a7212..7996e459 100644 --- a/module/Rest/test-api/Action/ResolveShortUrlActionTest.php +++ b/module/Rest/test-api/Action/ResolveShortUrlActionTest.php @@ -50,9 +50,10 @@ class ResolveShortUrlActionTest extends ApiTestCase public function tryingToResolveInvalidUrlReturnsNotFoundError( string $shortCode, ?string $domain, - string $expectedDetail + string $expectedDetail, + string $apiKey ): void { - $resp = $this->callApiWithKey(self::METHOD_GET, $this->buildShortUrlPath($shortCode, $domain)); + $resp = $this->callApiWithKey(self::METHOD_GET, $this->buildShortUrlPath($shortCode, $domain), [], $apiKey); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); diff --git a/module/Rest/test-api/Action/ShortUrlVisitsActionTest.php b/module/Rest/test-api/Action/ShortUrlVisitsActionTest.php index 6e2463a2..22864108 100644 --- a/module/Rest/test-api/Action/ShortUrlVisitsActionTest.php +++ b/module/Rest/test-api/Action/ShortUrlVisitsActionTest.php @@ -22,9 +22,15 @@ class ShortUrlVisitsActionTest extends ApiTestCase public function tryingToGetVisitsForInvalidUrlReturnsNotFoundError( string $shortCode, ?string $domain, - string $expectedDetail + string $expectedDetail, + string $apiKey ): void { - $resp = $this->callApiWithKey(self::METHOD_GET, $this->buildShortUrlPath($shortCode, $domain, '/visits')); + $resp = $this->callApiWithKey( + self::METHOD_GET, + $this->buildShortUrlPath($shortCode, $domain, '/visits'), + [], + $apiKey, + ); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); diff --git a/module/Rest/test-api/Action/TagVisitsActionTest.php b/module/Rest/test-api/Action/TagVisitsActionTest.php index d0f9838b..c1557bdd 100644 --- a/module/Rest/test-api/Action/TagVisitsActionTest.php +++ b/module/Rest/test-api/Action/TagVisitsActionTest.php @@ -14,11 +14,12 @@ class TagVisitsActionTest extends ApiTestCase * @test * @dataProvider provideTags */ - public function expectedVisitsAreReturned(string $tag, int $expectedVisitsAmount): void + public function expectedVisitsAreReturned(string $apiKey, string $tag, int $expectedVisitsAmount): void { - $resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/tags/%s/visits', $tag)); + $resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/tags/%s/visits', $tag), [], $apiKey); $payload = $this->getJsonResponsePayload($resp); + self::assertEquals(self::STATUS_OK, $resp->getStatusCode()); self::assertArrayHasKey('visits', $payload); self::assertArrayHasKey('data', $payload['visits']); self::assertCount($expectedVisitsAmount, $payload['visits']['data']); @@ -26,21 +27,34 @@ class TagVisitsActionTest extends ApiTestCase public function provideTags(): iterable { - yield 'foo' => ['foo', 5]; - yield 'bar' => ['bar', 2]; - yield 'baz' => ['baz', 0]; + yield 'foo with admin API key' => ['valid_api_key', 'foo', 5]; + yield 'bar with admin API key' => ['valid_api_key', 'bar', 2]; + yield 'baz with admin API key' => ['valid_api_key', 'baz', 0]; + yield 'foo with author API key' => ['author_api_key', 'foo', 5]; + yield 'bar with author API key' => ['author_api_key', 'bar', 2]; + yield 'foo with domain API key' => ['domain_api_key', 'foo', 0]; } - /** @test */ - public function notFoundErrorIsReturnedForInvalidTags(): void + /** + * @test + * @dataProvider provideApiKeysAndTags + */ + public function notFoundErrorIsReturnedForInvalidTags(string $apiKey, string $tag): void { - $resp = $this->callApiWithKey(self::METHOD_GET, '/tags/invalid_tag/visits'); + $resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/tags/%s/visits', $tag), [], $apiKey); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); self::assertEquals('TAG_NOT_FOUND', $payload['type']); - self::assertEquals('Tag with name "invalid_tag" could not be found', $payload['detail']); + self::assertEquals(sprintf('Tag with name "%s" could not be found', $tag), $payload['detail']); self::assertEquals('Tag not found', $payload['title']); } + + public function provideApiKeysAndTags(): iterable + { + yield 'admin API key with invalid tag' => ['valid_api_key', 'invalid_tag']; + yield 'domain API key with valid tag not used' => ['domain_api_key', 'bar']; + yield 'author API key with valid tag not used' => ['author_api_key', 'baz']; + } } diff --git a/module/Rest/test-api/Fixtures/ApiKeyFixture.php b/module/Rest/test-api/Fixtures/ApiKeyFixture.php index 971054fd..c6383968 100644 --- a/module/Rest/test-api/Fixtures/ApiKeyFixture.php +++ b/module/Rest/test-api/Fixtures/ApiKeyFixture.php @@ -5,28 +5,43 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Fixtures; use Cake\Chronos\Chronos; -use Doctrine\Common\DataFixtures\FixtureInterface; +use Doctrine\Common\DataFixtures\AbstractFixture; +use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; -use ReflectionObject; +use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; -class ApiKeyFixture implements FixtureInterface +class ApiKeyFixture extends AbstractFixture implements DependentFixtureInterface { + public function getDependencies(): array + { + return [DomainFixture::class]; + } + public function load(ObjectManager $manager): void { $manager->persist($this->buildApiKey('valid_api_key', true)); $manager->persist($this->buildApiKey('disabled_api_key', false)); $manager->persist($this->buildApiKey('expired_api_key', true, Chronos::now()->subDay())); + + $authorApiKey = $this->buildApiKey('author_api_key', true); + $authorApiKey->registerRole(RoleDefinition::forAuthoredShortUrls()); + $manager->persist($authorApiKey); + $this->addReference('author_api_key', $authorApiKey); + + /** @var Domain $exampleDomain */ + $exampleDomain = $this->getReference('example_domain'); + $domainApiKey = $this->buildApiKey('domain_api_key', true); + $domainApiKey->registerRole(RoleDefinition::forDomain($exampleDomain)); + $manager->persist($domainApiKey); + $manager->flush(); } private function buildApiKey(string $key, bool $enabled, ?Chronos $expiresAt = null): ApiKey { - $apiKey = new ApiKey($expiresAt); - $refObj = new ReflectionObject($apiKey); - $keyProp = $refObj->getProperty('key'); - $keyProp->setAccessible(true); - $keyProp->setValue($apiKey, $key); + $apiKey = ApiKey::withKey($key, $expiresAt); if (! $enabled) { $apiKey->disable(); diff --git a/module/Rest/test-api/Fixtures/DomainFixture.php b/module/Rest/test-api/Fixtures/DomainFixture.php index 4c30b5b8..576586a6 100644 --- a/module/Rest/test-api/Fixtures/DomainFixture.php +++ b/module/Rest/test-api/Fixtures/DomainFixture.php @@ -12,8 +12,11 @@ class DomainFixture extends AbstractFixture { public function load(ObjectManager $manager): void { - $orphanDomain = new Domain('this_domain_is_detached.com'); - $manager->persist($orphanDomain); + $domain = new Domain('example.com'); + $manager->persist($domain); + $this->addReference('example_domain', $domain); + + $manager->persist(new Domain('this_domain_is_detached.com')); $manager->flush(); } } diff --git a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php index 0aa13a82..954d2059 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php @@ -6,34 +6,45 @@ namespace ShlinkioApiTest\Shlink\Rest\Fixtures; use Cake\Chronos\Chronos; use Doctrine\Common\DataFixtures\AbstractFixture; +use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; use ReflectionObject; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; +use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -class ShortUrlsFixture extends AbstractFixture +class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterface { - /** - * Load data fixtures with the passed EntityManager - * - */ + public function getDependencies(): array + { + return [ApiKeyFixture::class]; + } + public function load(ObjectManager $manager): void { + /** @var ApiKey $authorApiKey */ + $authorApiKey = $this->getReference('author_api_key'); + $abcShortUrl = $this->setShortUrlDate( - new ShortUrl('https://shlink.io', ShortUrlMeta::fromRawData(['customSlug' => 'abc123'])), + new ShortUrl('https://shlink.io', ShortUrlMeta::fromRawData( + ['customSlug' => 'abc123', 'apiKey' => $authorApiKey], + )), '2018-05-01', ); $manager->persist($abcShortUrl); $defShortUrl = $this->setShortUrlDate(new ShortUrl( 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', - ShortUrlMeta::fromRawData(['validSince' => Chronos::parse('2020-05-01'), 'customSlug' => 'def456']), + ShortUrlMeta::fromRawData( + ['validSince' => Chronos::parse('2020-05-01'), 'customSlug' => 'def456', 'apiKey' => $authorApiKey], + ), ), '2019-01-01 00:00:10'); $manager->persist($defShortUrl); $customShortUrl = $this->setShortUrlDate(new ShortUrl( 'https://shlink.io', - ShortUrlMeta::fromRawData(['customSlug' => 'custom', 'maxVisits' => 2]), + ShortUrlMeta::fromRawData(['customSlug' => 'custom', 'maxVisits' => 2, 'apiKey' => $authorApiKey]), ), '2019-01-01 00:00:20'); $manager->persist($customShortUrl); @@ -46,6 +57,7 @@ class ShortUrlsFixture extends AbstractFixture $withDomainDuplicatingShortCode = $this->setShortUrlDate(new ShortUrl( 'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/', ShortUrlMeta::fromRawData(['domain' => 'example.com', 'customSlug' => 'ghi789']), + new PersistenceShortUrlRelationResolver($manager), ), '2019-01-01 00:00:30'); $manager->persist($withDomainDuplicatingShortCode); @@ -60,6 +72,7 @@ class ShortUrlsFixture extends AbstractFixture $this->addReference('abc123_short_url', $abcShortUrl); $this->addReference('def456_short_url', $defShortUrl); $this->addReference('ghi789_short_url', $ghiShortUrl); + $this->addReference('example_short_url', $withDomainDuplicatingShortCode); } private function setShortUrlDate(ShortUrl $shortUrl, string $date): ShortUrl diff --git a/module/Rest/test-api/Fixtures/TagsFixture.php b/module/Rest/test-api/Fixtures/TagsFixture.php index 5d3333cc..bf16104e 100644 --- a/module/Rest/test-api/Fixtures/TagsFixture.php +++ b/module/Rest/test-api/Fixtures/TagsFixture.php @@ -34,6 +34,10 @@ class TagsFixture extends AbstractFixture implements DependentFixtureInterface $defShortUrl = $this->getReference('def456_short_url'); $defShortUrl->setTags(new ArrayCollection([$fooTag, $barTag])); + /** @var ShortUrl $exampleShortUrl */ + $exampleShortUrl = $this->getReference('example_short_url'); + $exampleShortUrl->setTags(new ArrayCollection([$fooTag])); + $manager->flush(); } } diff --git a/module/Rest/test-api/Fixtures/VisitsFixture.php b/module/Rest/test-api/Fixtures/VisitsFixture.php index a07d95d1..73601748 100644 --- a/module/Rest/test-api/Fixtures/VisitsFixture.php +++ b/module/Rest/test-api/Fixtures/VisitsFixture.php @@ -31,10 +31,10 @@ class VisitsFixture extends AbstractFixture implements DependentFixtureInterface $manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', '', '127.0.0.1'))); $manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', ''))); - /** @var ShortUrl $defShortUrl */ - $defShortUrl = $this->getReference('ghi789_short_url'); - $manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4'))); - $manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', ''))); + /** @var ShortUrl $ghiShortUrl */ + $ghiShortUrl = $this->getReference('ghi789_short_url'); + $manager->persist(new Visit($ghiShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4'))); + $manager->persist(new Visit($ghiShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', ''))); $manager->flush(); } diff --git a/module/Rest/test-api/Middleware/CorsTest.php b/module/Rest/test-api/Middleware/CorsTest.php new file mode 100644 index 00000000..a1ca9901 --- /dev/null +++ b/module/Rest/test-api/Middleware/CorsTest.php @@ -0,0 +1,80 @@ +callApiWithKey(self::METHOD_GET, '/short-urls'); + + self::assertEquals(200, $resp->getStatusCode()); + self::assertFalse($resp->hasHeader('Access-Control-Allow-Origin')); + self::assertFalse($resp->hasHeader('Access-Control-Allow-Methods')); + self::assertFalse($resp->hasHeader('Access-Control-Max-Age')); + self::assertFalse($resp->hasHeader('Access-Control-Allow-Headers')); + } + + /** + * @test + * @dataProvider provideOrigins + */ + public function responseIncludesCorsHeadersIfOriginIsSent( + string $origin, + string $endpoint, + int $expectedStatusCode + ): void { + $resp = $this->callApiWithKey(self::METHOD_GET, $endpoint, [ + RequestOptions::HEADERS => ['Origin' => $origin], + ]); + + self::assertEquals($expectedStatusCode, $resp->getStatusCode()); + self::assertEquals($origin, $resp->getHeaderLine('Access-Control-Allow-Origin')); + self::assertFalse($resp->hasHeader('Access-Control-Allow-Methods')); + self::assertFalse($resp->hasHeader('Access-Control-Max-Age')); + self::assertFalse($resp->hasHeader('Access-Control-Allow-Headers')); + } + + public function provideOrigins(): iterable + { + yield 'foo.com' => ['foo.com', '/short-urls', 200]; + yield 'bar.io' => ['bar.io', '/foo/bar', 404]; + yield 'baz.dev' => ['baz.dev', '/short-urls', 200]; + } + + /** + * @test + * @dataProvider providePreflightEndpoints + */ + public function preflightRequestsIncludeExtraCorsHeaders(string $endpoint, string $expectedAllowedMethods): void + { + $allowedHeaders = 'Authorization'; + $resp = $this->callApiWithKey(self::METHOD_OPTIONS, $endpoint, [ + RequestOptions::HEADERS => [ + 'Origin' => 'foo.com', + 'Access-Control-Request-Headers' => $allowedHeaders, + ], + ]); + + self::assertEquals(204, $resp->getStatusCode()); + self::assertTrue($resp->hasHeader('Access-Control-Allow-Origin')); + self::assertTrue($resp->hasHeader('Access-Control-Max-Age')); + self::assertEquals($expectedAllowedMethods, $resp->getHeaderLine('Access-Control-Allow-Methods')); + self::assertEquals($allowedHeaders, $resp->getHeaderLine('Access-Control-Allow-Headers')); + } + + public function providePreflightEndpoints(): iterable + { + yield 'invalid route' => ['/foo/bar', 'GET,POST,PUT,PATCH,DELETE,OPTIONS']; + yield 'short URLs routes' => ['/short-urls', 'GET,POST,PUT,PATCH,DELETE,OPTIONS']; +// yield 'short URLs routes' => ['/short-urls', 'GET,POST']; // TODO This should be the good one + yield 'tags routes' => ['/tags', 'GET,POST,PUT,PATCH,DELETE,OPTIONS']; +// yield 'tags routes' => ['/short-urls', 'GET,POST,PUT,DELETE']; // TODO This should be the good one + } +} diff --git a/module/Rest/test-api/Utils/NotFoundUrlHelpersTrait.php b/module/Rest/test-api/Utils/NotFoundUrlHelpersTrait.php index 3cf2ad30..1c415208 100644 --- a/module/Rest/test-api/Utils/NotFoundUrlHelpersTrait.php +++ b/module/Rest/test-api/Utils/NotFoundUrlHelpersTrait.php @@ -4,25 +4,39 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Utils; +use GuzzleHttp\Psr7\Query; use Laminas\Diactoros\Uri; -use function GuzzleHttp\Psr7\build_query; use function sprintf; trait NotFoundUrlHelpersTrait { public function provideInvalidUrls(): iterable { - yield 'invalid shortcode' => ['invalid', null, 'No URL found with short code "invalid"']; + yield 'invalid shortcode' => ['invalid', null, 'No URL found with short code "invalid"', 'valid_api_key']; yield 'invalid shortcode without domain' => [ 'abc123', 'example.com', 'No URL found with short code "abc123" for domain "example.com"', + 'valid_api_key', ]; yield 'invalid shortcode + domain' => [ 'custom-with-domain', 'example.com', 'No URL found with short code "custom-with-domain" for domain "example.com"', + 'valid_api_key', + ]; + yield 'valid shortcode with invalid API key' => [ + 'ghi789', + null, + 'No URL found with short code "ghi789"', + 'author_api_key', + ]; + yield 'valid shortcode + domain with invalid API key' => [ + 'custom-with-domain', + 'some-domain.com', + 'No URL found with short code "custom-with-domain" for domain "some-domain.com"', + 'domain_api_key', ]; } @@ -30,7 +44,7 @@ trait NotFoundUrlHelpersTrait { $url = new Uri(sprintf('/short-urls/%s%s', $shortCode, $suffix)); if ($domain !== null) { - $url = $url->withQuery(build_query(['domain' => $domain])); + $url = $url->withQuery(Query::build(['domain' => $domain])); } return (string) $url; diff --git a/module/Rest/test/Action/Domain/ListDomainsActionTest.php b/module/Rest/test/Action/Domain/ListDomainsActionTest.php index 6750d105..d6dcc4a3 100644 --- a/module/Rest/test/Action/Domain/ListDomainsActionTest.php +++ b/module/Rest/test/Action/Domain/ListDomainsActionTest.php @@ -10,8 +10,9 @@ use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; -use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Rest\Action\Domain\ListDomainsAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class ListDomainsActionTest extends TestCase { @@ -23,37 +24,26 @@ class ListDomainsActionTest extends TestCase public function setUp(): void { $this->domainService = $this->prophesize(DomainServiceInterface::class); - $this->action = new ListDomainsAction($this->domainService->reveal(), 'foo.com'); + $this->action = new ListDomainsAction($this->domainService->reveal()); } /** @test */ public function domainsAreProperlyListed(): void { - $listDomains = $this->domainService->listDomainsWithout('foo.com')->willReturn([ - new Domain('bar.com'), - new Domain('baz.com'), - ]); + $apiKey = new ApiKey(); + $domains = [ + new DomainItem('bar.com', true), + new DomainItem('baz.com', false), + ]; + $listDomains = $this->domainService->listDomains($apiKey)->willReturn($domains); /** @var JsonResponse $resp */ - $resp = $this->action->handle(ServerRequestFactory::fromGlobals()); + $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, $apiKey)); $payload = $resp->getPayload(); self::assertEquals([ 'domains' => [ - 'data' => [ - [ - 'domain' => 'foo.com', - 'isDefault' => true, - ], - [ - 'domain' => 'bar.com', - 'isDefault' => false, - ], - [ - 'domain' => 'baz.com', - 'isDefault' => false, - ], - ], + 'data' => $domains, ], ], $payload); $listDomains->shouldHaveBeenCalledOnce(); diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index 91e6014c..80ccfc17 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Rest\Action\ShortUrl\CreateShortUrlAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use function strpos; @@ -48,19 +49,19 @@ class CreateShortUrlActionTest extends TestCase * @test * @dataProvider provideRequestBodies */ - public function properShortcodeConversionReturnsData(array $body, ShortUrlMeta $expectedMeta, ?string $apiKey): void + public function properShortcodeConversionReturnsData(array $body, array $expectedMeta): void { + $apiKey = new ApiKey(); $shortUrl = new ShortUrl(''); + $expectedMeta['apiKey'] = $apiKey; + $shorten = $this->urlShortener->shorten( Argument::type('string'), Argument::type('array'), - $expectedMeta, + ShortUrlMeta::fromRawData($expectedMeta), )->willReturn($shortUrl); - $request = ServerRequestFactory::fromGlobals()->withParsedBody($body); - if ($apiKey !== null) { - $request = $request->withHeader('X-Api-Key', $apiKey); - } + $request = ServerRequestFactory::fromGlobals()->withParsedBody($body)->withAttribute(ApiKey::class, $apiKey); $response = $this->action->handle($request); @@ -81,14 +82,8 @@ class CreateShortUrlActionTest extends TestCase 'domain' => 'my-domain.com', ]; - yield 'no data' => [['longUrl' => 'http://www.domain.com/foo/bar'], ShortUrlMeta::createEmpty(), null]; - yield 'all data' => [$fullMeta, ShortUrlMeta::fromRawData($fullMeta), null]; - yield 'all data and API key' => (static function (array $meta): array { - $apiKey = 'abc123'; - $meta['apiKey'] = $apiKey; - - return [$meta, ShortUrlMeta::fromRawData($meta), $apiKey]; - })($fullMeta); + yield 'no data' => [['longUrl' => 'http://www.domain.com/foo/bar'], []]; + yield 'all data' => [$fullMeta, $fullMeta]; } /** @@ -103,7 +98,7 @@ class CreateShortUrlActionTest extends TestCase $request = (new ServerRequest())->withParsedBody([ 'longUrl' => 'http://www.domain.com/foo/bar', 'domain' => $domain, - ]); + ])->withAttribute(ApiKey::class, new ApiKey()); $this->expectException(ValidationException::class); $urlToShortCode->shouldNotBeCalled(); diff --git a/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php index 6f724c4e..9be06756 100644 --- a/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php @@ -4,13 +4,14 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; -use Laminas\Diactoros\ServerRequest; +use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\ShortUrl\DeleteShortUrlAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class DeleteShortUrlActionTest extends TestCase { @@ -28,10 +29,13 @@ class DeleteShortUrlActionTest extends TestCase /** @test */ public function emptyResponseIsReturnedIfProperlyDeleted(): void { - $deleteByShortCode = $this->service->deleteByShortCode(Argument::any())->will(function (): void { - }); + $apiKey = new ApiKey(); + $deleteByShortCode = $this->service->deleteByShortCode(Argument::any(), false, $apiKey)->will( + function (): void { + }, + ); - $resp = $this->action->handle(new ServerRequest()); + $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, $apiKey)); self::assertEquals(204, $resp->getStatusCode()); $deleteByShortCode->shouldHaveBeenCalledOnce(); diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php index 087b4298..5e9eadf7 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php @@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\ShortUrl\EditShortUrlAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class EditShortUrlActionTest extends TestCase { @@ -43,6 +44,7 @@ class EditShortUrlActionTest extends TestCase public function correctShortCodeReturnsSuccess(): void { $request = (new ServerRequest())->withAttribute('shortCode', 'abc123') + ->withAttribute(ApiKey::class, new ApiKey()) ->withParsedBody([ 'maxVisits' => 5, ]); diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php index 2fa6f456..9c72dd91 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php @@ -4,15 +4,18 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; -use Laminas\Diactoros\ServerRequest; +use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrlService; use Shlinkio\Shlink\Rest\Action\ShortUrl\EditShortUrlTagsAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class EditShortUrlTagsActionTest extends TestCase { @@ -31,20 +34,29 @@ class EditShortUrlTagsActionTest extends TestCase public function notProvidingTagsReturnsError(): void { $this->expectException(ValidationException::class); - $this->action->handle((new ServerRequest())->withAttribute('shortCode', 'abc123')); + $this->action->handle($this->createRequestWithAPiKey()->withAttribute('shortCode', 'abc123')); } /** @test */ public function tagsListIsReturnedIfCorrectShortCodeIsProvided(): void { $shortCode = 'abc123'; - $this->shortUrlService->setTagsByShortCode(new ShortUrlIdentifier($shortCode), [])->willReturn(new ShortUrl('')) - ->shouldBeCalledOnce(); + $this->shortUrlService->setTagsByShortCode( + new ShortUrlIdentifier($shortCode), + [], + Argument::type(ApiKey::class), + )->willReturn(new ShortUrl('')) + ->shouldBeCalledOnce(); $response = $this->action->handle( - (new ServerRequest())->withAttribute('shortCode', 'abc123') - ->withParsedBody(['tags' => []]), + $this->createRequestWithAPiKey()->withAttribute('shortCode', 'abc123') + ->withParsedBody(['tags' => []]), ); self::assertEquals(200, $response->getStatusCode()); } + + private function createRequestWithAPiKey(): ServerRequestInterface + { + return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, new ApiKey()); + } } diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php index 741eceb5..7c4d47f7 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -15,6 +15,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlService; use Shlinkio\Shlink\Rest\Action\ShortUrl\ListShortUrlsAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class ListShortUrlsActionTest extends TestCase { @@ -46,6 +47,8 @@ class ListShortUrlsActionTest extends TestCase ?string $startDate = null, ?string $endDate = null ): void { + $apiKey = new ApiKey(); + $request = (new ServerRequest())->withQueryParams($query)->withAttribute(ApiKey::class, $apiKey); $listShortUrls = $this->service->listShortUrls(ShortUrlsParams::fromRawData([ 'page' => $expectedPage, 'searchTerm' => $expectedSearchTerm, @@ -53,10 +56,10 @@ class ListShortUrlsActionTest extends TestCase 'orderBy' => $expectedOrderBy, 'startDate' => $startDate, 'endDate' => $endDate, - ]))->willReturn(new Paginator(new ArrayAdapter())); + ]), $apiKey)->willReturn(new Paginator(new ArrayAdapter())); /** @var JsonResponse $response */ - $response = $this->action->handle((new ServerRequest())->withQueryParams($query)); + $response = $this->action->handle($request); $payload = $response->getPayload(); self::assertArrayHasKey('shortUrls', $payload); diff --git a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php index d61f0f64..f4c49a60 100644 --- a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php @@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Rest\Action\ShortUrl\ResolveShortUrlAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use function strpos; @@ -32,12 +33,14 @@ class ResolveShortUrlActionTest extends TestCase public function correctShortCodeReturnsSuccess(): void { $shortCode = 'abc123'; - $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn( + $apiKey = new ApiKey(); + $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey)->willReturn( new ShortUrl('http://domain.com/foo/bar'), )->shouldBeCalledOnce(); - $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); + $request = (new ServerRequest())->withAttribute('shortCode', $shortCode)->withAttribute(ApiKey::class, $apiKey); $response = $this->action->handle($request); + self::assertEquals(200, $response->getStatusCode()); self::assertTrue(strpos($response->getBody()->getContents(), 'http://domain.com/foo/bar') > 0); } diff --git a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php index 62005c8d..b42b95fb 100644 --- a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php @@ -15,6 +15,8 @@ use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Rest\Action\ShortUrl\SingleStepCreateShortUrlAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; +use Shlinkio\Shlink\Rest\Service\ApiKeyCheckResult; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; class SingleStepCreateShortUrlActionTest extends TestCase @@ -44,7 +46,7 @@ class SingleStepCreateShortUrlActionTest extends TestCase public function errorResponseIsReturnedIfInvalidApiKeyIsProvided(): void { $request = (new ServerRequest())->withQueryParams(['apiKey' => 'abc123']); - $findApiKey = $this->apiKeyService->check('abc123')->willReturn(false); + $findApiKey = $this->apiKeyService->check('abc123')->willReturn(new ApiKeyCheckResult()); $this->expectException(ValidationException::class); $findApiKey->shouldBeCalledOnce(); @@ -56,7 +58,7 @@ class SingleStepCreateShortUrlActionTest extends TestCase public function errorResponseIsReturnedIfNoUrlIsProvided(): void { $request = (new ServerRequest())->withQueryParams(['apiKey' => 'abc123']); - $findApiKey = $this->apiKeyService->check('abc123')->willReturn(true); + $findApiKey = $this->apiKeyService->check('abc123')->willReturn(new ApiKeyCheckResult(new ApiKey())); $this->expectException(ValidationException::class); $findApiKey->shouldBeCalledOnce(); @@ -67,18 +69,21 @@ class SingleStepCreateShortUrlActionTest extends TestCase /** @test */ public function properDataIsPassedWhenGeneratingShortCode(): void { + $apiKey = new ApiKey(); + $key = $apiKey->toString(); + $request = (new ServerRequest())->withQueryParams([ - 'apiKey' => 'abc123', + 'apiKey' => $key, 'longUrl' => 'http://foobar.com', ]); - $findApiKey = $this->apiKeyService->check('abc123')->willReturn(true); + $findApiKey = $this->apiKeyService->check($key)->willReturn(new ApiKeyCheckResult($apiKey)); $generateShortCode = $this->urlShortener->shorten( - Argument::that(function (string $argument): string { + Argument::that(function (string $argument): bool { Assert::assertEquals('http://foobar.com', $argument); - return $argument; + return true; }), [], - ShortUrlMeta::fromRawData(['apiKey' => 'abc123']), + ShortUrlMeta::fromRawData(['apiKey' => $apiKey]), )->willReturn(new ShortUrl('')); $resp = $this->action->handle($request); diff --git a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php index b167ee2c..957c01a5 100644 --- a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php +++ b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php @@ -6,10 +6,12 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag; use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\DeleteTagsAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class DeleteTagsActionTest extends TestCase { @@ -30,8 +32,10 @@ class DeleteTagsActionTest extends TestCase */ public function processDelegatesIntoService(?array $tags): void { - $request = (new ServerRequest())->withQueryParams(['tags' => $tags]); - $deleteTags = $this->tagService->deleteTags($tags ?: []); + $request = (new ServerRequest()) + ->withQueryParams(['tags' => $tags]) + ->withAttribute(ApiKey::class, new ApiKey()); + $deleteTags = $this->tagService->deleteTags($tags ?: [], Argument::type(ApiKey::class)); $response = $this->action->handle($request); diff --git a/module/Rest/test/Action/Tag/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php index 2f675536..9bdad15b 100644 --- a/module/Rest/test/Action/Tag/ListTagsActionTest.php +++ b/module/Rest/test/Action/Tag/ListTagsActionTest.php @@ -7,12 +7,15 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class ListTagsActionTest extends TestCase { @@ -34,10 +37,10 @@ class ListTagsActionTest extends TestCase public function returnsBaseDataWhenStatsAreNotRequested(array $query): void { $tags = [new Tag('foo'), new Tag('bar')]; - $listTags = $this->tagService->listTags()->willReturn($tags); + $listTags = $this->tagService->listTags(Argument::type(ApiKey::class))->willReturn($tags); /** @var JsonResponse $resp */ - $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withQueryParams($query)); + $resp = $this->action->handle($this->requestWithApiKey()->withQueryParams($query)); $payload = $resp->getPayload(); self::assertEquals([ @@ -62,10 +65,11 @@ class ListTagsActionTest extends TestCase new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10), ]; - $tagsInfo = $this->tagService->tagsInfo()->willReturn($stats); + $tagsInfo = $this->tagService->tagsInfo(Argument::type(ApiKey::class))->willReturn($stats); + $req = $this->requestWithApiKey()->withQueryParams(['withStats' => 'true']); /** @var JsonResponse $resp */ - $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withQueryParams(['withStats' => 'true'])); + $resp = $this->action->handle($req); $payload = $resp->getPayload(); self::assertEquals([ @@ -76,4 +80,9 @@ class ListTagsActionTest extends TestCase ], $payload); $tagsInfo->shouldHaveBeenCalled(); } + + private function requestWithApiKey(): ServerRequestInterface + { + return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, new ApiKey()); + } } diff --git a/module/Rest/test/Action/Tag/UpdateTagActionTest.php b/module/Rest/test/Action/Tag/UpdateTagActionTest.php index b82c8c2e..681e68f6 100644 --- a/module/Rest/test/Action/Tag/UpdateTagActionTest.php +++ b/module/Rest/test/Action/Tag/UpdateTagActionTest.php @@ -4,14 +4,18 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\Tag; -use Laminas\Diactoros\ServerRequest; +use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\UpdateTagAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class UpdateTagActionTest extends TestCase { @@ -32,7 +36,7 @@ class UpdateTagActionTest extends TestCase */ public function whenInvalidParamsAreProvidedAnErrorIsReturned(array $bodyParams): void { - $request = (new ServerRequest())->withParsedBody($bodyParams); + $request = $this->requestWithApiKey()->withParsedBody($bodyParams); $this->expectException(ValidationException::class); @@ -49,15 +53,23 @@ class UpdateTagActionTest extends TestCase /** @test */ public function correctInvocationRenamesTag(): void { - $request = (new ServerRequest())->withParsedBody([ + $request = $this->requestWithApiKey()->withParsedBody([ 'oldName' => 'foo', 'newName' => 'bar', ]); - $rename = $this->tagService->renameTag('foo', 'bar')->willReturn(new Tag('bar')); + $rename = $this->tagService->renameTag( + TagRenaming::fromNames('foo', 'bar'), + Argument::type(ApiKey::class), + )->willReturn(new Tag('bar')); $resp = $this->action->handle($request); self::assertEquals(204, $resp->getStatusCode()); $rename->shouldHaveBeenCalled(); } + + private function requestWithApiKey(): ServerRequestInterface + { + return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, new ApiKey()); + } } diff --git a/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php b/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php index 6b91ba56..6e3ab1e4 100644 --- a/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php @@ -12,6 +12,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\Visit\GlobalVisitsAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class GlobalVisitsActionTest extends TestCase { @@ -29,11 +30,12 @@ class GlobalVisitsActionTest extends TestCase /** @test */ public function statsAreReturnedFromHelper(): void { + $apiKey = new ApiKey(); $stats = new VisitsStats(5); - $getStats = $this->helper->getVisitsStats()->willReturn($stats); + $getStats = $this->helper->getVisitsStats($apiKey)->willReturn($stats); /** @var JsonResponse $resp */ - $resp = $this->action->handle(ServerRequestFactory::fromGlobals()); + $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, $apiKey)); $payload = $resp->getPayload(); self::assertEquals($payload, ['visits' => $stats]); diff --git a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php index 25e71006..0bedbd37 100644 --- a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php @@ -5,18 +5,20 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\Visit; use Cake\Chronos\Chronos; -use Laminas\Diactoros\ServerRequest; +use Laminas\Diactoros\ServerRequestFactory; use Laminas\Paginator\Adapter\ArrayAdapter; use Laminas\Paginator\Paginator; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTracker; use Shlinkio\Shlink\Rest\Action\Visit\ShortUrlVisitsAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlVisitsActionTest extends TestCase { @@ -35,11 +37,14 @@ class ShortUrlVisitsActionTest extends TestCase public function providingCorrectShortCodeReturnsVisits(): void { $shortCode = 'abc123'; - $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), Argument::type(VisitsParams::class))->willReturn( - new Paginator(new ArrayAdapter([])), - )->shouldBeCalledOnce(); + $this->visitsTracker->info( + new ShortUrlIdentifier($shortCode), + Argument::type(VisitsParams::class), + Argument::type(ApiKey::class), + )->willReturn(new Paginator(new ArrayAdapter([]))) + ->shouldBeCalledOnce(); - $response = $this->action->handle((new ServerRequest())->withAttribute('shortCode', $shortCode)); + $response = $this->action->handle($this->requestWithApiKey()->withAttribute('shortCode', $shortCode)); self::assertEquals(200, $response->getStatusCode()); } @@ -51,18 +56,23 @@ class ShortUrlVisitsActionTest extends TestCase new DateRange(null, Chronos::parse('2016-01-01 00:00:00')), 3, 10, - )) + ), Argument::type(ApiKey::class)) ->willReturn(new Paginator(new ArrayAdapter([]))) ->shouldBeCalledOnce(); $response = $this->action->handle( - (new ServerRequest())->withAttribute('shortCode', $shortCode) - ->withQueryParams([ - 'endDate' => '2016-01-01 00:00:00', - 'page' => '3', - 'itemsPerPage' => '10', - ]), + $this->requestWithApiKey()->withAttribute('shortCode', $shortCode) + ->withQueryParams([ + 'endDate' => '2016-01-01 00:00:00', + 'page' => '3', + 'itemsPerPage' => '10', + ]), ); self::assertEquals(200, $response->getStatusCode()); } + + private function requestWithApiKey(): ServerRequestInterface + { + return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, new ApiKey()); + } } diff --git a/module/Rest/test/Action/Visit/TagVisitsActionTest.php b/module/Rest/test/Action/Visit/TagVisitsActionTest.php index 53dbf8f2..a7598971 100644 --- a/module/Rest/test/Action/Visit/TagVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/TagVisitsActionTest.php @@ -14,6 +14,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTracker; use Shlinkio\Shlink\Rest\Action\Visit\TagVisitsAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class TagVisitsActionTest extends TestCase { @@ -32,11 +33,14 @@ class TagVisitsActionTest extends TestCase public function providingCorrectShortCodeReturnsVisits(): void { $tag = 'foo'; - $getVisits = $this->visitsTracker->visitsForTag($tag, Argument::type(VisitsParams::class))->willReturn( + $apiKey = new ApiKey(); + $getVisits = $this->visitsTracker->visitsForTag($tag, Argument::type(VisitsParams::class), $apiKey)->willReturn( new Paginator(new ArrayAdapter([])), ); - $response = $this->action->handle((new ServerRequest())->withAttribute('tag', $tag)); + $response = $this->action->handle( + (new ServerRequest())->withAttribute('tag', $tag)->withAttribute(ApiKey::class, $apiKey), + ); self::assertEquals(200, $response->getStatusCode()); $getVisits->shouldHaveBeenCalledOnce(); diff --git a/module/Rest/test/ApiKey/RoleTest.php b/module/Rest/test/ApiKey/RoleTest.php new file mode 100644 index 00000000..4cb9ba1b --- /dev/null +++ b/module/Rest/test/ApiKey/RoleTest.php @@ -0,0 +1,104 @@ + [new ApiKeyRole('invalid', [], $apiKey), true, Spec::andX()]; + yield 'not inline invalid role' => [new ApiKeyRole('invalid', [], $apiKey), false, Spec::andX()]; + yield 'inline author role' => [ + new ApiKeyRole(Role::AUTHORED_SHORT_URLS, [], $apiKey), + true, + new BelongsToApiKeyInlined($apiKey), + ]; + yield 'not inline author role' => [ + new ApiKeyRole(Role::AUTHORED_SHORT_URLS, [], $apiKey), + false, + new BelongsToApiKey($apiKey), + ]; + yield 'inline domain role' => [ + new ApiKeyRole(Role::DOMAIN_SPECIFIC, ['domain_id' => '123'], $apiKey), + true, + new BelongsToDomainInlined('123'), + ]; + yield 'not inline domain role' => [ + new ApiKeyRole(Role::DOMAIN_SPECIFIC, ['domain_id' => '456'], $apiKey), + false, + new BelongsToDomain('456'), + ]; + } + + /** + * @test + * @dataProvider provideMetasWithDomainId + */ + public function getsExpectedDomainIdFromMeta(array $meta, string $expectedDomainId): void + { + self::assertEquals($expectedDomainId, Role::domainIdFromMeta($meta)); + } + + public function provideMetasWithDomainId(): iterable + { + yield 'empty meta' => [[], '-1']; + yield 'meta without domain_id' => [['foo' => 'bar'], '-1']; + yield 'meta with domain_id' => [['domain_id' => '123'], '123']; + } + + /** + * @test + * @dataProvider provideMetasWithAuthority + */ + public function getsExpectedAuthorityFromMeta(array $meta, string $expectedAuthority): void + { + self::assertEquals($expectedAuthority, Role::domainAuthorityFromMeta($meta)); + } + + public function provideMetasWithAuthority(): iterable + { + yield 'empty meta' => [[], '']; + yield 'meta without authority' => [['foo' => 'bar'], '']; + yield 'meta with authority' => [['authority' => 'example.com'], 'example.com']; + } + + /** + * @test + * @dataProvider provideRoleNames + */ + public function getsExpectedRoleFriendlyName(string $roleName, string $expectedFriendlyName): void + { + self::assertEquals($expectedFriendlyName, Role::toFriendlyName($roleName)); + } + + public function provideRoleNames(): iterable + { + yield 'unknown' => ['unknown', '']; + yield Role::AUTHORED_SHORT_URLS => [Role::AUTHORED_SHORT_URLS, 'Author only']; + yield Role::DOMAIN_SPECIFIC => [Role::DOMAIN_SPECIFIC, 'Domain only']; + } +} diff --git a/module/Rest/test/ConfigProviderTest.php b/module/Rest/test/ConfigProviderTest.php index 69f745ff..462947c9 100644 --- a/module/Rest/test/ConfigProviderTest.php +++ b/module/Rest/test/ConfigProviderTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest; +use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Rest\ConfigProvider; @@ -21,8 +22,12 @@ class ConfigProviderTest extends TestCase { $config = ($this->configProvider)(); + self::assertCount(5, $config); self::assertArrayHasKey('routes', $config); self::assertArrayHasKey('dependencies', $config); + self::assertArrayHasKey('auth', $config); + self::assertArrayHasKey('entity_manager', $config); + self::assertArrayHasKey(ConfigAbstractFactory::class, $config); } /** diff --git a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php index db721780..39559f67 100644 --- a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php @@ -18,9 +18,11 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Rest\Action\HealthAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Exception\MissingAuthenticationException; use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; +use Shlinkio\Shlink\Rest\Service\ApiKeyCheckResult; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use function Laminas\Stratigility\middleware; @@ -114,7 +116,7 @@ class AuthenticationMiddlewareTest extends TestCase ) ->withHeader('X-Api-Key', $apiKey); - $this->apiKeyService->check($apiKey)->willReturn(false)->shouldBeCalledOnce(); + $this->apiKeyService->check($apiKey)->willReturn(new ApiKeyCheckResult())->shouldBeCalledOnce(); $this->handler->handle($request)->shouldNotBeCalled(); $this->expectException(VerifyAuthenticationException::class); $this->expectExceptionMessage('Provided API key does not exist or is invalid'); @@ -125,16 +127,17 @@ class AuthenticationMiddlewareTest extends TestCase /** @test */ public function validApiKeyFallsBackToNextMiddleware(): void { - $apiKey = 'abc123'; + $apiKey = new ApiKey(); + $key = $apiKey->toString(); $request = ServerRequestFactory::fromGlobals() ->withAttribute( RouteResult::class, RouteResult::fromRoute(new Route('bar', $this->getDummyMiddleware()), []), ) - ->withHeader('X-Api-Key', $apiKey); + ->withHeader('X-Api-Key', $key); - $handle = $this->handler->handle($request)->willReturn(new Response()); - $checkApiKey = $this->apiKeyService->check($apiKey)->willReturn(true); + $handle = $this->handler->handle($request->withAttribute(ApiKey::class, $apiKey))->willReturn(new Response()); + $checkApiKey = $this->apiKeyService->check($key)->willReturn(new ApiKeyCheckResult($apiKey)); $this->middleware->process($request, $this->handler->reveal()); diff --git a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php index 03675fce..907fb678 100644 --- a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php @@ -26,7 +26,7 @@ class CrossDomainMiddlewareTest extends TestCase public function setUp(): void { - $this->middleware = new CrossDomainMiddleware(); + $this->middleware = new CrossDomainMiddleware(['max_age' => 1000]); $this->handler = $this->prophesize(RequestHandlerInterface::class); } @@ -42,7 +42,6 @@ class CrossDomainMiddlewareTest extends TestCase self::assertSame($originalResponse, $response); self::assertEquals(404, $response->getStatusCode()); self::assertArrayNotHasKey('Access-Control-Allow-Origin', $headers); - self::assertArrayNotHasKey('Access-Control-Expose-Headers', $headers); self::assertArrayNotHasKey('Access-Control-Allow-Methods', $headers); self::assertArrayNotHasKey('Access-Control-Max-Age', $headers); self::assertArrayNotHasKey('Access-Control-Allow-Headers', $headers); @@ -63,7 +62,6 @@ class CrossDomainMiddlewareTest extends TestCase $headers = $response->getHeaders(); self::assertEquals('local', $response->getHeaderLine('Access-Control-Allow-Origin')); - self::assertEquals('X-Api-Key', $response->getHeaderLine('Access-Control-Expose-Headers')); self::assertArrayNotHasKey('Access-Control-Allow-Methods', $headers); self::assertArrayNotHasKey('Access-Control-Max-Age', $headers); self::assertArrayNotHasKey('Access-Control-Allow-Headers', $headers); @@ -85,7 +83,6 @@ class CrossDomainMiddlewareTest extends TestCase $headers = $response->getHeaders(); self::assertEquals('local', $response->getHeaderLine('Access-Control-Allow-Origin')); - self::assertEquals('X-Api-Key', $response->getHeaderLine('Access-Control-Expose-Headers')); self::assertArrayHasKey('Access-Control-Allow-Methods', $headers); self::assertEquals('1000', $response->getHeaderLine('Access-Control-Max-Age')); self::assertEquals('foo, bar, baz', $response->getHeaderLine('Access-Control-Allow-Headers')); diff --git a/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php new file mode 100644 index 00000000..dcf4d7ce --- /dev/null +++ b/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php @@ -0,0 +1,141 @@ +apiKey = $this->prophesize(ApiKey::class); + $this->handler = $this->prophesize(RequestHandlerInterface::class); + + $this->domainService = $this->prophesize(DomainServiceInterface::class); + $this->middleware = new OverrideDomainMiddleware($this->domainService->reveal()); + } + + /** @test */ + public function nextMiddlewareIsCalledWhenApiKeyDoesNotHaveProperRole(): void + { + $request = $this->requestWithApiKey(); + $response = new Response(); + $hasRole = $this->apiKey->hasRole(Role::DOMAIN_SPECIFIC)->willReturn(false); + $handle = $this->handler->handle($request)->willReturn($response); + $getDomain = $this->domainService->getDomain(Argument::cetera()); + + $result = $this->middleware->process($request, $this->handler->reveal()); + + self::assertSame($response, $result); + $hasRole->shouldHaveBeenCalledOnce(); + $handle->shouldHaveBeenCalledOnce(); + $getDomain->shouldNotHaveBeenCalled(); + } + + /** + * @test + * @dataProvider provideBodies + */ + public function overwritesRequestBodyWhenMethodIsPost(Domain $domain, array $body, array $expectedBody): void + { + $request = $this->requestWithApiKey()->withMethod('POST')->withParsedBody($body); + $hasRole = $this->apiKey->hasRole(Role::DOMAIN_SPECIFIC)->willReturn(true); + $getRoleMeta = $this->apiKey->getRoleMeta(Role::DOMAIN_SPECIFIC)->willReturn(['domain_id' => '123']); + $getDomain = $this->domainService->getDomain('123')->willReturn($domain); + $handle = $this->handler->handle(Argument::that( + function (ServerRequestInterface $req) use ($expectedBody): bool { + Assert::assertEquals($req->getParsedBody(), $expectedBody); + return true; + }, + ))->willReturn(new Response()); + + $this->middleware->process($request, $this->handler->reveal()); + + $hasRole->shouldHaveBeenCalledOnce(); + $getRoleMeta->shouldHaveBeenCalledOnce(); + $getDomain->shouldHaveBeenCalledOnce(); + $handle->shouldHaveBeenCalledOnce(); + } + + public function provideBodies(): iterable + { + yield 'no domain provided' => [new Domain('foo.com'), [], [ShortUrlMetaInputFilter::DOMAIN => 'foo.com']]; + yield 'other domain provided' => [ + new Domain('bar.com'), + [ShortUrlMetaInputFilter::DOMAIN => 'foo.com'], + [ShortUrlMetaInputFilter::DOMAIN => 'bar.com'], + ]; + yield 'same domain provided' => [ + new Domain('baz.com'), + [ShortUrlMetaInputFilter::DOMAIN => 'baz.com'], + [ShortUrlMetaInputFilter::DOMAIN => 'baz.com'], + ]; + yield 'more body params' => [ + new Domain('doma.in'), + [ShortUrlMetaInputFilter::DOMAIN => 'baz.com', 'something' => 'else', 'foo' => 123], + [ShortUrlMetaInputFilter::DOMAIN => 'doma.in', 'something' => 'else', 'foo' => 123], + ]; + } + + /** + * @test + * @dataProvider provideMethods + */ + public function setsRequestAttributeWhenMethodIsNotPost(string $method): void + { + $domain = new Domain('something.com'); + $request = $this->requestWithApiKey()->withMethod($method); + $hasRole = $this->apiKey->hasRole(Role::DOMAIN_SPECIFIC)->willReturn(true); + $getRoleMeta = $this->apiKey->getRoleMeta(Role::DOMAIN_SPECIFIC)->willReturn(['domain_id' => '123']); + $getDomain = $this->domainService->getDomain('123')->willReturn($domain); + $handle = $this->handler->handle(Argument::that( + function (ServerRequestInterface $req): bool { + Assert::assertEquals($req->getAttribute(ShortUrlMetaInputFilter::DOMAIN), 'something.com'); + return true; + }, + ))->willReturn(new Response()); + + $this->middleware->process($request, $this->handler->reveal()); + + $hasRole->shouldHaveBeenCalledOnce(); + $getRoleMeta->shouldHaveBeenCalledOnce(); + $getDomain->shouldHaveBeenCalledOnce(); + $handle->shouldHaveBeenCalledOnce(); + } + + public function provideMethods(): iterable + { + yield 'GET' => ['GET']; + yield 'PUT' => ['PUT']; + yield 'PATCH' => ['PATCH']; + yield 'DELETE' => ['DELETE']; + } + + private function requestWithApiKey(): ServerRequestInterface + { + return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, $this->apiKey->reveal()); + } +} diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index 656541f0..6879d492 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -12,6 +12,8 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; +use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyService; @@ -31,21 +33,29 @@ class ApiKeyServiceTest extends TestCase /** * @test * @dataProvider provideCreationDate + * @param RoleDefinition[] $roles */ - public function apiKeyIsProperlyCreated(?Chronos $date): void + public function apiKeyIsProperlyCreated(?Chronos $date, array $roles): void { $this->em->flush()->shouldBeCalledOnce(); $this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledOnce(); - $key = $this->service->create($date); + $key = $this->service->create($date, ...$roles); self::assertEquals($date, $key->getExpirationDate()); + foreach ($roles as $roleDefinition) { + self::assertTrue($key->hasRole($roleDefinition->roleName())); + } } public function provideCreationDate(): iterable { - yield 'no expiration date' => [null]; - yield 'expiration date' => [Chronos::parse('2030-01-01')]; + yield 'no expiration date' => [null, []]; + yield 'expiration date' => [Chronos::parse('2030-01-01'), []]; + yield 'roles' => [null, [ + RoleDefinition::forDomain((new Domain(''))->setId('123')), + RoleDefinition::forAuthoredShortUrls(), + ]]; } /** @@ -59,7 +69,10 @@ class ApiKeyServiceTest extends TestCase ->shouldBeCalledOnce(); $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); - self::assertFalse($this->service->check('12345')); + $result = $this->service->check('12345'); + + self::assertFalse($result->isValid()); + self::assertSame($invalidKey, $result->apiKey()); } public function provideInvalidApiKeys(): iterable @@ -72,12 +85,17 @@ class ApiKeyServiceTest extends TestCase /** @test */ public function checkReturnsTrueWhenConditionsAreFavorable(): void { + $apiKey = new ApiKey(); + $repo = $this->prophesize(EntityRepository::class); - $repo->findOneBy(['key' => '12345'])->willReturn(new ApiKey()) + $repo->findOneBy(['key' => '12345'])->willReturn($apiKey) ->shouldBeCalledOnce(); $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); - self::assertTrue($this->service->check('12345')); + $result = $this->service->check('12345'); + + self::assertTrue($result->isValid()); + self::assertSame($apiKey, $result->apiKey()); } /** @test */ diff --git a/phpunit-db.xml b/phpunit-db.xml index a995448f..030f777b 100644 --- a/phpunit-db.xml +++ b/phpunit-db.xml @@ -16,6 +16,9 @@ ./module/*/src/Repository ./module/*/src/**/Repository ./module/*/src/**/**/Repository + ./module/*/src/Spec + ./module/*/src/**/Spec + ./module/*/src/**/**/Spec diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 68f5263a..9c8e02df 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -25,6 +25,9 @@ ./module/Core/src/Repository ./module/Core/src/**/Repository ./module/Core/src/**/**/Repository + ./module/Core/src/Spec + ./module/Core/src/**/Spec + ./module/Core/src/**/**/Spec