Merge pull request #1270 from shlinkio/develop

Release 2.10.0
This commit is contained in:
Alejandro Celaya 2021-12-12 17:27:21 +01:00 committed by GitHub
commit 2102cc4e9a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
113 changed files with 1903 additions and 1097 deletions

View file

@ -8,29 +8,12 @@ on:
- develop
jobs:
lint:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.7.1
coverage: none
- run: composer install --no-interaction --prefer-dist
- run: composer cs
static-analysis:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
command: ['cs', 'stan', 'swagger:validate']
steps:
- name: Checkout code
uses: actions/checkout@v2
@ -39,223 +22,90 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.7.1
extensions: openswoole-4.8.1
coverage: none
- run: composer install --no-interaction --prefer-dist
- run: composer stan
- run: composer ${{ matrix.command }}
unit-tests:
tests:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
test-group: ['unit', 'api']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Start database server
if: ${{ matrix.test-group == 'api' }}
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.7.1
extensions: openswoole-4.8.1
coverage: pcov
ini-values: pcov.directory=module
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:unit:ci
- run: composer install --no-interaction --prefer-dist
- run: composer test:${{ matrix.test-group }}:ci
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '8.0' }}
with:
name: coverage-unit
name: coverage-${{ matrix.test-group }}
path: |
build/coverage-unit
build/coverage-unit.cov
build/coverage-${{ matrix.test-group }}
build/coverage-${{ matrix.test-group }}.cov
db-tests-sqlite:
db-tests:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
platform: ['sqlite:ci', 'mysql', 'maria', 'postgres', 'ms']
env:
LC_ALL: C
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Install MSSQL ODBC
if: ${{ matrix.platform == 'ms' }}
run: sudo ./data/infra/ci/install-ms-odbc.sh
- name: Start database server
if: ${{ matrix.platform != 'sqlite:ci' }}
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_${{ matrix.platform }}
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.7.1
extensions: openswoole-4.8.1, pdo_sqlsrv-5.10.0beta2
coverage: pcov
ini-values: pcov.directory=module
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:db:sqlite:ci
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '8.0' }}
- run: composer install --no-interaction --prefer-dist
- name: Create test database
if: ${{ matrix.platform == 'ms' }}
run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
- name: Run tests
run: composer test:db:${{ matrix.platform }}
- name: Upload code coverage
uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '8.0' && matrix.platform == 'sqlite:ci' }}
with:
name: coverage-db
path: |
build/coverage-db
build/coverage-db.cov
db-tests-mysql:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Start database server
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.7.1
coverage: none
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:db:mysql
db-tests-maria:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Start database server
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_maria
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.7.1
coverage: none
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:db:maria
db-tests-postgres:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Start database server
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.7.1
coverage: none
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:db:postgres
db-tests-ms:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Install MSSQL ODBC
run: sudo ./data/infra/ci/install-ms-odbc.sh
- name: Start database server
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms
- name: Use PHP
if: ${{ matrix.php-version == '8.1' }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.7.1, pdo_sqlsrv-5.10.0beta1
coverage: none
- name: Use PHP
if: ${{ matrix.php-version != '8.1' }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.7.1, pdo_sqlsrv-5.9.0
coverage: none
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- name: Create test database
run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
- run: composer test:db:ms
api-tests:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Start database server
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.7.1
coverage: pcov
ini-values: pcov.directory=module
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: bin/test/run-api-tests.sh
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '8.0' }}
with:
name: coverage-api
path: |
build/coverage-api
build/coverage-api.cov
mutation-tests:
needs:
- unit-tests
- db-tests-sqlite
- api-tests
- tests
- db-tests
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0', '8.1']
test-group: ['unit', 'db']
continue-on-error: ${{ matrix.php-version == '8.1' }}
test-group: ['unit', 'db', 'api']
steps:
- name: Checkout code
uses: actions/checkout@v2
@ -264,23 +114,24 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.7.1
extensions: openswoole-4.8.1
coverage: pcov
ini-values: pcov.directory=module
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: composer install --no-interaction --prefer-dist
- uses: actions/download-artifact@v2
with:
path: build
- run: composer infect:ci:${{ matrix.test-group }}
- if: ${{ matrix.test-group == 'unit' }}
run: composer infect:ci:unit
env:
INFECTION_BADGE_API_KEY: ${{ secrets.INFECTION_BADGE_API_KEY }}
- if: ${{ matrix.test-group != 'unit' }}
run: composer infect:ci:${{ matrix.test-group }}
upload-coverage:
needs:
- unit-tests
- db-tests-sqlite
- api-tests
- tests
- db-tests
runs-on: ubuntu-20.04
strategy:
matrix:

View file

@ -9,7 +9,7 @@ on:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2

View file

@ -20,7 +20,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.7
extensions: openswoole-4.8.1
- if: ${{ matrix.swoole == 'yes' }}
run: ./build.sh ${GITHUB_REF#refs/tags/v}
- if: ${{ matrix.swoole == 'no' }}

View file

@ -0,0 +1,40 @@
name: Publish swagger spec
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Determine version
id: determine_version
run: echo "::set-output name=version::${GITHUB_REF#refs/tags/}"
shell: bash
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: openswoole-4.8.1
coverage: none
- run: composer install --no-interaction --prefer-dist
- run: composer swagger:inline
- run: mkdir ${{ steps.determine_version.outputs.version }}
- run: mv docs/swagger/swagger-inline.json ${{ steps.determine_version.outputs.version }}/oas.json
- name: Publish spec
uses: JamesIves/github-pages-deploy-action@4.1.7
with:
token: ${{ secrets.OAS_PUBLISH_TOKEN }}
repository-name: 'shlinkio/shlink-open-api-specs'
branch: main
folder: ${{ steps.determine_version.outputs.version }}
target-folder: specs/${{ steps.determine_version.outputs.version }}
clean: false

1
.gitignore vendored
View file

@ -11,3 +11,4 @@ docs/swagger-ui*
docs/mercure.html
docker-compose.override.yml
.phpunit.result.cache
docs/swagger/swagger-inlined.json

View file

@ -4,6 +4,52 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [2.10.0] - 2021-12-12
### Added
* [#1163](https://github.com/shlinkio/shlink/issues/1163) Allowed setting not-found redirects for default domain in the same way it's done for any other domain.
This implies a few non-breaking changes:
* The domains list no longer has the values of `INVALID_SHORT_URL_REDIRECT_TO`, `REGULAR_404_REDIRECT_TO` and `BASE_URL_REDIRECT_TO` on the default domain redirects.
* The `GET /domains` endpoint includes a new `defaultRedirects` property in the response, with the default redirects set via config or env vars.
* The `INVALID_SHORT_URL_REDIRECT_TO`, `REGULAR_404_REDIRECT_TO` and `BASE_URL_REDIRECT_TO` env vars are now deprecated, and should be replaced by `DEFAULT_INVALID_SHORT_URL_REDIRECT`, `DEFAULT_REGULAR_404_REDIRECT` and `DEFAULT_BASE_URL_REDIRECT` respectively. Deprecated ones will continue to work until v3.0.0, where they will be removed.
* [#868](https://github.com/shlinkio/shlink/issues/868) Added support to publish real-time updates in a RabbitMQ server.
Shlink will create new exchanges and queues for every topic documented in the [Async API spec](https://api-spec.shlink.io/async-api/), meaning, you will have one queue for orphan visits, one for regular visits, and one queue for every short URL with its visits.
The RabbitMQ server config can be provided via installer config options, or via environment variables.
* [#1204](https://github.com/shlinkio/shlink/issues/1204) Added support for `openswoole` and migrated official docker image to `openswoole`.
* [#1242](https://github.com/shlinkio/shlink/issues/1242) Added support to import urls and visits from YOURLS.
In order to do it, you need to first install this [dedicated plugin](https://slnk.to/yourls-import) in YOURLS, and then run the `short-url:import yourls` command, as with any other source.
* [#1235](https://github.com/shlinkio/shlink/issues/1235) Added support to disable rounding QR codes block sizing via config option, env var or query param.
* [#1188](https://github.com/shlinkio/shlink/issues/1188) Added support for PHP 8.1.
The official docker image has also been updated to use PHP 8.1 by default.
### Changed
* [#844](https://github.com/shlinkio/shlink/issues/844) Added mutation checks to API tests.
* [#1218](https://github.com/shlinkio/shlink/issues/1218) Updated to symfony/mercure 0.6.
* [#1223](https://github.com/shlinkio/shlink/issues/1223) Updated to phpstan 1.0.
* [#1258](https://github.com/shlinkio/shlink/issues/1258) Updated to Symfony 6 components, except symfony/console.
* Added `domain` field to `DeleteShortUrlException` exception.
### Deprecated
* [#1260](https://github.com/shlinkio/shlink/issues/1260) Deprecated `USE_HTTPS` env var that was added in previous release, in favor of the new `IS_HTTPS_ENABLED`.
The old one proved to be confusing and misleading, making people think it was used to actually enable HTTPS transparently, instead of its actual purpose, which is just telling Shlink it is being served with HTTPS.
### Removed
* *Nothing*
### Fixed
* [#1206](https://github.com/shlinkio/shlink/issues/1206) Fixed debugging of the docker image, so that it does not run the commands with `-q` when the `SHELL_VERBOSITY` env var has been provided.
* [#1254](https://github.com/shlinkio/shlink/issues/1254) Fixed examples in swagger docs.
## [2.9.3] - 2021-11-15
### Added
* *Nothing*

View file

@ -1,9 +1,9 @@
FROM php:8.0.9-alpine3.14 as base
FROM php:8.1.0-alpine3.15 as base
ARG SHLINK_VERSION=latest
ENV SHLINK_VERSION ${SHLINK_VERSION}
ENV SWOOLE_VERSION 4.7.1
ENV PDO_SQLSRV_VERSION 5.9.0
ENV OPENSWOOLE_VERSION 4.8.1
ENV PDO_SQLSRV_VERSION 5.10.0beta2
ENV MS_ODBC_SQL_VERSION 17.5.2.2
ENV LC_ALL "C"
@ -11,8 +11,8 @@ WORKDIR /etc/shlink
# Install required PHP extensions
RUN \
# Install mysql and calendar
docker-php-ext-install -j"$(nproc)" pdo_mysql calendar && \
# Install extensions with no extra dependencies
docker-php-ext-install -j"$(nproc)" pdo_mysql calendar sockets bcmath && \
# Install sqlite
apk add --no-cache sqlite-libs sqlite-dev && \
docker-php-ext-install -j"$(nproc)" pdo_sqlite && \
@ -40,10 +40,10 @@ RUN if [ $(uname -m) == "x86_64" ]; then \
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk ; \
fi
# Install swoole
# Install openswoole
RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} && \
pecl install swoole-${SWOOLE_VERSION} && \
docker-php-ext-enable swoole && \
pecl install openswoole-${OPENSWOOLE_VERSION} && \
docker-php-ext-enable openswoole && \
apk del .phpize-deps
@ -65,7 +65,7 @@ LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
COPY --from=builder /etc/shlink .
RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink
# Expose default swoole port
# Expose default openswoole port
EXPOSE 8080
# Copy config specific for the image

View file

@ -2,6 +2,7 @@
[![Build Status](https://img.shields.io/github/workflow/status/shlinkio/shlink/Continuous%20integration/develop?logo=github&style=flat-square)](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Continuous+integration%22)
[![Code Coverage](https://img.shields.io/codecov/c/gh/shlinkio/shlink/develop?style=flat-square)](https://app.codecov.io/gh/shlinkio/shlink)
[![Infection MSI](https://img.shields.io/endpoint?style=flat-square&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fshlinkio%2Fshlink%2Fdevelop)](https://dashboard.stryker-mutator.io/reports/github.com/shlinkio/shlink/develop)
[![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
[![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/main/LICENSE)
@ -33,10 +34,11 @@ The idea is that you can just generate a container using the image and provide t
First, make sure the host where you are going to run shlink fulfills these requirements:
* PHP 8.0
* PHP 8.0 or 8.1
* The next PHP extensions: json, curl, pdo, intl, gd and gmp.
* apcu extension is recommended if you don't plan to use swoole.
* apcu extension is recommended if you don't plan to use swoole or openswoole.
* xml extension is required if you want to generate QR codes in svg format.
* sockets and bcmath extensions are required if you want to integrate with a RabbitMQ instance.
* MySQL, MariaDB, PostgreSQL, Microsoft SQL Server or SQLite.
* The web server of your choice with PHP integration (Apache or Nginx recommended).
@ -48,7 +50,7 @@ In order to run Shlink, you will need a built version of the project. There are
The easiest way to install shlink is by using one of the pre-bundled distributable packages.
Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink*_dist.zip` file that suits your needs. You will find one for every supported PHP version and with/without swoole integration.
Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink*_dist.zip` file that suits your needs. You will find one for every supported PHP version and with/without swoole/openswoole integration.
Finally, decompress the file in the location of your choice.

View file

@ -2,6 +2,7 @@
export APP_ENV=test
export DB_DRIVER=postgres
export TEST_ENV=api
export GENERATE_COVERAGE=${GENERATE_COVERAGE:-"no"}
rm -rf data/log/api-tests

View file

@ -15,68 +15,69 @@
"php": "^8.0",
"ext-json": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.0",
"cakephp/chronos": "^2.2",
"akrabat/ip-address-middleware": "^2.1",
"cakephp/chronos": "^2.3",
"cocur/slugify": "^4.0",
"doctrine/dbal": "^3.1.4",
"doctrine/migrations": "^3.3 <3.3.2",
"doctrine/orm": "^2.9",
"endroid/qr-code": "^4.2",
"geoip2/geoip2": "^2.11",
"guzzlehttp/guzzle": "^7.3",
"doctrine/migrations": "^3.3",
"doctrine/orm": "^2.10",
"endroid/qr-code": "^4.4",
"geoip2/geoip2": "^2.12",
"guzzlehttp/guzzle": "^7.4",
"happyr/doctrine-specification": "^2.0",
"jaybizzle/crawler-detect": "^1.2",
"laminas/laminas-config": "^3.5",
"laminas/laminas-config-aggregator": "^1.5",
"laminas/laminas-diactoros": "^2.6",
"laminas/laminas-inputfilter": "^2.12",
"laminas/laminas-servicemanager": "^3.7",
"laminas/laminas-stdlib": "^3.5",
"jaybizzle/crawler-detect": "^1.2.110",
"laminas/laminas-config": "^3.7",
"laminas/laminas-config-aggregator": "^1.7",
"laminas/laminas-diactoros": "^2.8",
"laminas/laminas-inputfilter": "^2.13",
"laminas/laminas-servicemanager": "^3.10",
"laminas/laminas-stdlib": "^3.6",
"lcobucci/jwt": "^4.1",
"league/uri": "^6.4",
"lstrojny/functional-php": "^1.17",
"mezzio/mezzio": "^3.5",
"mezzio/mezzio-fastroute": "^3.2",
"mezzio/mezzio-problem-details": "^1.4",
"mezzio/mezzio-swoole": "^3.3",
"mezzio/mezzio": "^3.7",
"mezzio/mezzio-fastroute": "^3.3",
"mezzio/mezzio-problem-details": "^1.5",
"mezzio/mezzio-swoole": "^3.5",
"mlocati/ip-lib": "^1.17",
"monolog/monolog": "^2.3",
"nikolaposa/monolog-factory": "^3.1",
"ocramius/proxy-manager": "^2.11",
"pagerfanta/core": "^2.7",
"pagerfanta/core": "^3.5",
"php-amqplib/php-amqplib": "^3.1",
"php-middleware/request-id": "^4.1",
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.7",
"ramsey/uuid": "^3.9",
"rlanvin/php-ip": "3.0.0-rc2",
"shlinkio/shlink-common": "^4.0",
"shlinkio/shlink-config": "^1.2",
"shlinkio/shlink-event-dispatcher": "^2.1",
"shlinkio/shlink-importer": "^2.3.1",
"shlinkio/shlink-installer": "^6.2.1",
"shlinkio/shlink-ip-geolocation": "^2.0",
"symfony/console": "^5.3",
"symfony/filesystem": "^5.3",
"symfony/lock": "^5.3",
"symfony/mercure": "^0.5.3",
"symfony/process": "^5.3",
"symfony/string": "^5.3"
"pugx/shortid-php": "^1.0",
"ramsey/uuid": "^4.2",
"shlinkio/shlink-common": "^4.2",
"shlinkio/shlink-config": "^1.4",
"shlinkio/shlink-event-dispatcher": "^2.3",
"shlinkio/shlink-importer": "^2.5",
"shlinkio/shlink-installer": "^6.3",
"shlinkio/shlink-ip-geolocation": "^2.2",
"symfony/console": "^5.4",
"symfony/filesystem": "^6.0 || ^5.4",
"symfony/lock": "^6.0 || ^5.4",
"symfony/mercure": "^0.6",
"symfony/process": "^6.0 || ^5.4",
"symfony/string": "^6.0 || ^5.4"
},
"require-dev": {
"cebe/php-openapi": "^1.5",
"devster/ubench": "^2.1",
"dms/phpunit-arraysubset-asserts": "^0.3.0",
"eaglewu/swoole-ide-helper": "dev-master",
"infection/infection": "^0.25.0",
"infection/infection": "^0.25.4",
"phpspec/prophecy-phpunit": "^2.0",
"phpstan/phpstan": "^0.12.94",
"phpstan/phpstan-doctrine": "^0.12.42",
"phpstan/phpstan-symfony": "^0.12.41",
"phpstan/phpstan": "^1.2",
"phpstan/phpstan-doctrine": "^1.0",
"phpstan/phpstan-symfony": "^1.0",
"phpunit/php-code-coverage": "^9.2",
"phpunit/phpunit": "^9.5",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.2.0",
"shlinkio/shlink-test-utils": "^2.3",
"symfony/var-dumper": "^5.3",
"veewee/composer-run-parallel": "^1.0"
"shlinkio/shlink-test-utils": "^2.5",
"symfony/var-dumper": "^6.0",
"veewee/composer-run-parallel": "^1.1"
},
"autoload": {
"psr-4": {
@ -107,12 +108,13 @@
"ci": [
"@cs",
"@stan",
"@swagger:validate",
"@test:ci",
"@infect:ci"
],
"ci:parallel": [
"@parallel cs stan test:unit:ci test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
"@parallel test:api infect:ci:unit infect:ci:db"
"@parallel cs stan swagger:validate test:unit:ci test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
"@parallel infect:test:api infect:ci:unit infect:ci:db"
],
"cs": "phpcs",
"cs:fix": "phpcbf",
@ -125,11 +127,11 @@
"test:ci": [
"@test:unit:ci",
"@test:db",
"@test:api"
"@test:api:ci"
],
"test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
"test:unit:ci": "@test:unit --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml",
"test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html",
"test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit/coverage-html",
"test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
"test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
"test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml",
@ -138,18 +140,30 @@
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
"test:api": "bin/test/run-api-tests.sh",
"test:api:ci": "GENERATE_COVERAGE=yes composer test:api",
"infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --only-covering-test-cases --skip-initial-tests",
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80",
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json",
"infect:ci": "@parallel infect:ci:unit infect:ci:db",
"infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=80 --configuration=infection-api.json",
"infect:ci": "@parallel infect:ci:unit infect:ci:db infect:ci:api",
"infect:test": [
"@parallel test:unit:ci test:db:sqlite:ci",
"@parallel test:unit:ci test:db:sqlite:ci test:api:ci",
"@infect:ci"
],
"infect:test:unit": [
"@test:unit:ci",
"@infect:ci:unit"
],
"infect:test:api": [
"@test:api:ci",
"@infect:ci:api"
],
"swagger:validate": "php-openapi validate docs/swagger/swagger.json",
"swagger:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/swagger-inlined.json",
"clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php"
},
"scripts-descriptions": {
"ci": "<fg=blue;options=bold>Alias for \"cs\", \"stan\", \"test:ci\" and \"infect:ci\"</>",
"ci": "<fg=blue;options=bold>Alias for \"cs\", \"stan\", \"swagger:validate\", \"test:ci\" and \"infect:ci\"</>",
"ci:parallel": "<fg=blue;options=bold>Same as \"ci\", but parallelizing tasks as much as possible</>",
"cs": "<fg=blue;options=bold>Checks coding styles</>",
"cs:fix": "<fg=blue;options=bold>Fixes coding styles, when possible</>",
@ -158,6 +172,7 @@
"test:ci": "<fg=blue;options=bold>Runs all test suites, generating all needed reports and logs for CI envs</>",
"test:unit": "<fg=blue;options=bold>Runs unit test suites</>",
"test:unit:ci": "<fg=blue;options=bold>Runs unit test suites, generating all needed reports and logs for CI envs</>",
"test:unit:pretty": "<fg=blue;options=bold>Runs unit test suites and generates an HTML code coverage report</>",
"test:db": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL, MariaDB, PostgreSQL and MsSQL</>",
"test:db:sqlite": "<fg=blue;options=bold>Runs database test suites on a SQLite database</>",
"test:db:sqlite:ci": "<fg=blue;options=bold>Runs database test suites on a SQLite database, generating all needed reports and logs for CI envs</>",
@ -166,11 +181,13 @@
"test:db:postgres": "<fg=blue;options=bold>Runs database test suites on a PostgreSQL database</>",
"test:db:ms": "<fg=blue;options=bold>Runs database test suites on a Miscrosoft SQL Server database</>",
"test:api": "<fg=blue;options=bold>Runs API test suites</>",
"test:unit:pretty": "<fg=blue;options=bold>Runs unit test suites and generates an HTML code coverage report</>",
"test:api:ci": "<fg=blue;options=bold>Runs API test suites, and generates code coverage reports</>",
"infect:ci": "<fg=blue;options=bold>Checks unit and db tests quality applying mutation testing with existing reports and logs</>",
"infect:ci:unit": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing with existing reports and logs</>",
"infect:ci:db": "<fg=blue;options=bold>Checks db tests quality applying mutation testing with existing reports and logs</>",
"infect:test": "<fg=blue;options=bold>Runs unit and db tests, then checks tests quality applying mutation testing</>",
"swagger:validate": "<fg=blue;options=bold>Validates the swagger docs, making sure they fulfil the spec</>",
"swagger:inline": "<fg=blue;options=bold>Inlines swagger docs in a single file</>",
"clean:dev": "<fg=blue;options=bold>Deletes artifacts which are gitignored and could affect dev env</>"
},
"config": {

View file

@ -9,7 +9,7 @@ return [
'user' => 'root',
'password' => 'root',
'driver' => 'pdo_mysql',
'host' => 'shlink_db',
'host' => 'shlink_db_mysql',
'dbname' => 'shlink',
'charset' => 'utf8',
],

View file

@ -56,6 +56,13 @@ return [
Option\QrCode\DefaultMarginConfigOption::class,
Option\QrCode\DefaultFormatConfigOption::class,
Option\QrCode\DefaultErrorCorrectionConfigOption::class,
Option\QrCode\DefaultRoundBlockSizeConfigOption::class,
Option\RabbitMq\RabbitMqEnabledConfigOption::class,
Option\RabbitMq\RabbitMqHostConfigOption::class,
Option\RabbitMq\RabbitMqPortConfigOption::class,
Option\RabbitMq\RabbitMqUserConfigOption::class,
Option\RabbitMq\RabbitMqPasswordConfigOption::class,
Option\RabbitMq\RabbitMqVhostConfigOption::class,
],
'installation_commands' => [

View file

@ -5,7 +5,7 @@ declare(strict_types=1);
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
$isSwoole = extension_loaded('swoole');
$isSwoole = extension_loaded('openswoole');
// For swoole, send logs to standard output
$handler = $isSwoole

View file

@ -7,6 +7,7 @@ use function Shlinkio\Shlink\Common\env;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ROUND_BLOCK_SIZE;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE;
return [
@ -16,6 +17,7 @@ return [
'margin' => (int) env('DEFAULT_QR_CODE_MARGIN', DEFAULT_QR_CODE_MARGIN),
'format' => env('DEFAULT_QR_CODE_FORMAT', DEFAULT_QR_CODE_FORMAT),
'error_correction' => env('DEFAULT_QR_CODE_ERROR_CORRECTION', DEFAULT_QR_CODE_ERROR_CORRECTION),
'round_block_size' => (bool) env('DEFAULT_QR_CODE_ROUND_BLOCK_SIZE', DEFAULT_QR_CODE_ROUND_BLOCK_SIZE),
],
];

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Proxy\LazyServiceFactory;
use PhpAmqpLib\Connection\AMQPStreamConnection;
use function Shlinkio\Shlink\Common\env;
return [
'rabbitmq' => [
'enabled' => (bool) env('RABBITMQ_ENABLED', false),
'host' => env('RABBITMQ_HOST'),
'port' => (int) env('RABBITMQ_PORT', '5672'),
'user' => env('RABBITMQ_USER'),
'password' => env('RABBITMQ_PASSWORD'),
'vhost' => env('RABBITMQ_VHOST', '/'),
],
'dependencies' => [
'factories' => [
AMQPStreamConnection::class => ConfigAbstractFactory::class,
],
'delegators' => [
AMQPStreamConnection::class => [
LazyServiceFactory::class,
],
],
'lazy_services' => [
'class_map' => [
AMQPStreamConnection::class => AMQPStreamConnection::class,
],
],
],
ConfigAbstractFactory::class => [
AMQPStreamConnection::class => [
'config.rabbitmq.host',
'config.rabbitmq.port',
'config.rabbitmq.user',
'config.rabbitmq.password',
'config.rabbitmq.vhost',
],
],
];

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
return [
'rabbitmq' => [
'enabled' => true,
'host' => 'shlink_rabbitmq',
'user' => 'rabbit',
'password' => 'rabbit',
],
];

View file

@ -10,9 +10,10 @@ use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
return [
'not_found_redirects' => [
'invalid_short_url' => env('INVALID_SHORT_URL_REDIRECT_TO'),
'regular_404' => env('REGULAR_404_REDIRECT_TO'),
'base_url' => env('BASE_URL_REDIRECT_TO'),
// Deprecated env vars
'invalid_short_url' => env('DEFAULT_INVALID_SHORT_URL_REDIRECT', env('INVALID_SHORT_URL_REDIRECT_TO')),
'regular_404' => env('DEFAULT_REGULAR_404_REDIRECT', env('REGULAR_404_REDIRECT_TO')),
'base_url' => env('DEFAULT_BASE_URL_REDIRECT', env('BASE_URL_REDIRECT_TO')),
],
'url_shortener' => [

View file

@ -8,13 +8,17 @@ use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
return (static function (): array {
$shortCodesLength = (int) env('DEFAULT_SHORT_CODES_LENGTH', DEFAULT_SHORT_CODES_LENGTH);
$shortCodesLength = $shortCodesLength < MIN_SHORT_CODES_LENGTH ? MIN_SHORT_CODES_LENGTH : $shortCodesLength;
$shortCodesLength = max(
(int) env('DEFAULT_SHORT_CODES_LENGTH', DEFAULT_SHORT_CODES_LENGTH),
MIN_SHORT_CODES_LENGTH,
);
$resolveSchema = static function (): string {
$useHttps = env('USE_HTTPS'); // Deprecated. For v3, set this to true by default, instead of null
if ($useHttps !== null) {
$boolUseHttps = (bool) $useHttps;
return $boolUseHttps ? 'https' : 'http';
// Deprecated. For v3, IS_HTTPS_ENABLED should be true by default, instead of null
// return ((bool) env('IS_HTTPS_ENABLED', true)) ? 'https' : 'http';
$isHttpsEnabled = env('IS_HTTPS_ENABLED', env('USE_HTTPS'));
if ($isHttpsEnabled !== null) {
$boolIsHttpsEnabled = (bool) $isHttpsEnabled;
return $boolIsHttpsEnabled ? 'https' : 'http';
}
return env('SHORT_DOMAIN_SCHEMA', 'http');

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
$isSwoole = extension_loaded('swoole');
$isSwoole = extension_loaded('openswoole');
return [

View file

@ -13,11 +13,17 @@ use Mezzio\Swoole;
use function class_exists;
use function Shlinkio\Shlink\Common\env;
use const PHP_SAPI;
$isCli = PHP_SAPI === 'cli';
return (new ConfigAggregator\ConfigAggregator([
Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class,
Mezzio\Router\FastRouteRouter\ConfigProvider::class,
class_exists(Swoole\ConfigProvider::class) ? Swoole\ConfigProvider::class : new ConfigAggregator\ArrayProvider([]),
$isCli && class_exists(Swoole\ConfigProvider::class)
? Swoole\ConfigProvider::class
: new ConfigAggregator\ArrayProvider([]),
ProblemDetails\ConfigProvider::class,
Diactoros\ConfigProvider::class,
Common\ConfigProvider::class,

View file

@ -18,4 +18,5 @@ const DEFAULT_QR_CODE_SIZE = 300;
const DEFAULT_QR_CODE_MARGIN = 0;
const DEFAULT_QR_CODE_FORMAT = 'png';
const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true;
const MIN_TASK_WORKERS = 4;

View file

@ -20,8 +20,7 @@ $config = $container->get('config');
$em = $container->get(EntityManager::class);
$httpClient = $container->get('shlink_test_api_client');
// Start code coverage collecting on swoole process, and stop it when process shuts down
$httpClient->request('GET', sprintf('http://%s:%s/api-tests/start-coverage', SWOOLE_TESTING_HOST, SWOOLE_TESTING_PORT));
// Dump code coverage when process shuts down
register_shutdown_function(function () use ($httpClient): void {
$httpClient->request(
'GET',

View file

@ -8,13 +8,16 @@ use GuzzleHttp\Client;
use Laminas\ConfigAggregator\ConfigAggregator;
use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Laminas\Stdlib\Glob;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use PHPUnit\Runner\Version;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Driver\Selector;
use SebastianBergmann\CodeCoverage\Filter;
use SebastianBergmann\CodeCoverage\Report\Html\Facade as Html;
use SebastianBergmann\CodeCoverage\Report\PHP;
use SebastianBergmann\CodeCoverage\Report\Xml\Facade as Xml;
@ -27,18 +30,17 @@ use const ShlinkioTest\Shlink\SWOOLE_TESTING_HOST;
use const ShlinkioTest\Shlink\SWOOLE_TESTING_PORT;
$isApiTest = env('TEST_ENV') === 'api';
if ($isApiTest) {
$generateCoverage = env('GENERATE_COVERAGE') === 'yes';
if ($isApiTest && $generateCoverage) {
$filter = new Filter();
foreach (Glob::glob(__DIR__ . '/../../module/*/src') as $item) {
$filter->includeDirectory($item);
}
$filter->includeDirectory(__DIR__ . '/../../module/Core/src');
$filter->includeDirectory(__DIR__ . '/../../module/Rest/src');
$coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter);
}
$buildDbConnection = static function (): array {
$driver = env('DB_DRIVER', 'sqlite');
$isCi = env('CI', false);
$getMysqlHost = static fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria');
$getCiMysqlPort = static fn (string $driver) => $driver === 'mysql' ? '3307' : '3308';
return match ($driver) {
@ -64,7 +66,7 @@ $buildDbConnection = static function (): array {
],
default => [ // mysql and maria
'driver' => 'pdo_mysql',
'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver),
'host' => $isCi ? '127.0.0.1' : sprintf('shlink_db_%s', $driver),
'port' => $isCi ? $getCiMysqlPort($driver) : '3306',
'user' => 'root',
'password' => 'root',
@ -113,26 +115,18 @@ return [
],
'routes' => !$isApiTest ? [] : [
[
'name' => 'start_collecting_coverage',
'path' => '/api-tests/start-coverage',
'middleware' => middleware(static function () use (&$coverage) {
if ($coverage) { // @phpstan-ignore-line
$coverage->start('API tests');
}
return new EmptyResponse();
}),
'allowed_methods' => ['GET'],
],
[
'name' => 'dump_coverage',
'path' => '/api-tests/stop-coverage',
'middleware' => middleware(static function () use (&$coverage) {
// TODO I have tried moving this block to a listener so that it's invoked automatically,
// but then the coverage is generated empty ¯\_(ツ)_/¯
if ($coverage) { // @phpstan-ignore-line
$basePath = __DIR__ . '/../../build/coverage-api';
$coverage->stop();
(new PHP())->process($coverage, $basePath . '.cov');
(new Xml(Version::getVersionString()))->process($coverage, $basePath . '/coverage-xml');
(new Html())->process($coverage, $basePath . '/coverage-html');
}
return new EmptyResponse();
@ -141,6 +135,24 @@ return [
],
],
'middleware_pipeline' => !$isApiTest ? [] : [
'capture_code_coverage' => [
'middleware' => middleware(static function (
ServerRequestInterface $req,
RequestHandlerInterface $handler,
) use (&$coverage): ResponseInterface {
$coverage?->start($req->getHeaderLine('x-coverage-id'));
try {
return $handler->handle($req);
} finally {
$coverage?->stop();
}
}),
'priority' => 9999,
],
],
'mercure' => [
'public_hub_url' => null,
'internal_hub_url' => null,

View file

@ -1,8 +1,8 @@
FROM php:8.0.9-fpm-alpine3.14
FROM php:8.1.0-fpm-alpine3.15
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.20
ENV PDO_SQLSRV_VERSION 5.9.0
ENV APCU_VERSION 5.1.21
ENV PDO_SQLSRV_VERSION 5.10.0beta2
ENV MS_ODBC_SQL_VERSION 17.5.2.2
RUN apk update
@ -34,6 +34,9 @@ RUN docker-php-ext-install pdo_pgsql
RUN apk add --no-cache gmp-dev
RUN docker-php-ext-install gmp
RUN docker-php-ext-install sockets
RUN docker-php-ext-install bcmath
# Install APCu extension
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu \

View file

@ -1,10 +1,10 @@
FROM php:8.0.9-alpine3.14
FROM php:8.1.0-alpine3.15
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.20
ENV APCU_VERSION 5.1.21
ENV INOTIFY_VERSION 3.0.0
ENV SWOOLE_VERSION 4.7.1
ENV PDO_SQLSRV_VERSION 5.9.0
ENV OPENSWOOLE_VERSION 4.8.1
ENV PDO_SQLSRV_VERSION 5.10.0beta2
ENV MS_ODBC_SQL_VERSION 17.5.2.2
RUN apk update
@ -36,6 +36,9 @@ RUN docker-php-ext-install pdo_pgsql
RUN apk add --no-cache gmp-dev
RUN docker-php-ext-install gmp
RUN docker-php-ext-install sockets
RUN docker-php-ext-install bcmath
# Install APCu extension
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu \
@ -54,12 +57,12 @@ RUN mkdir -p /usr/src/php/ext/inotify \
&& docker-php-ext-install inotify \
&& rm /tmp/inotify.tar.gz
# Install swoole, pcov and mssql driver
# Install openswoole, pcov and mssql driver
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
docker-php-ext-enable swoole pdo_sqlsrv pcov && \
pecl install openswoole-${OPENSWOOLE_VERSION} pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
docker-php-ext-enable openswoole pdo_sqlsrv pcov && \
apk del .phpize-deps && \
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk
@ -72,12 +75,12 @@ RUN chmod 777 /home
VOLUME /home/shlink
WORKDIR /home/shlink
# Expose swoole port
# Expose openswoole port
EXPOSE 8080
CMD \
# Install dependencies if the vendor dir does not exist
if [[ ! -d "./vendor" ]]; then /usr/local/bin/composer install ; fi && \
# When restarting the container, swoole might think it is already in execution
# When restarting the container, openswoole might think it is already in execution
# This forces the app to be started every second until the exit code is 0
until php ./vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done

View file

@ -1,7 +1,7 @@
version: '3'
services:
shlink_db:
shlink_db_mysql:
environment:
MYSQL_DATABASE: shlink_test

View file

@ -13,7 +13,7 @@ services:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
shlink_db:
shlink_db_mysql:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro

View file

@ -22,15 +22,18 @@ services:
- ./:/home/shlink/www
- ./data/infra/php.ini:/usr/local/etc/php/php.ini
links:
- shlink_db
- shlink_db_mysql
- shlink_db_postgres
- shlink_db_maria
- shlink_db_ms
- shlink_redis
- shlink_mercure
- shlink_mercure_proxy
- shlink_rabbitmq
environment:
LC_ALL: C
extra_hosts:
- 'host.docker.internal:host-gateway'
shlink_swoole_proxy:
container_name: shlink_swoole_proxy
@ -55,18 +58,21 @@ services:
- ./:/home/shlink
- ./data/infra/php.ini:/usr/local/etc/php/php.ini
links:
- shlink_db
- shlink_db_mysql
- shlink_db_postgres
- shlink_db_maria
- shlink_db_ms
- shlink_redis
- shlink_mercure
- shlink_mercure_proxy
- shlink_rabbitmq
environment:
LC_ALL: C
extra_hosts:
- 'host.docker.internal:host-gateway'
shlink_db:
container_name: shlink_db
shlink_db_mysql:
container_name: shlink_db_mysql
image: mysql:5.7
ports:
- "3307:3306"
@ -131,10 +137,21 @@ services:
shlink_mercure:
container_name: shlink_mercure
image: dunglas/mercure:v0.10
image: dunglas/mercure:v0.13
ports:
- "3080:80"
environment:
CORS_ALLOWED_ORIGINS: "*"
JWT_KEY: "mercure_jwt_key"
USE_FORWARDED_HEADERS: "1"
SERVER_NAME: ":80"
MERCURE_PUBLISHER_JWT_KEY: mercure_jwt_key
MERCURE_SUBSCRIBER_JWT_KEY: mercure_jwt_key
MERCURE_EXTRA_DIRECTIVES: "cors_origins https://app.shlink.io http://localhost:3000 http://127.0.0.1:3000"
shlink_rabbitmq:
container_name: shlink_rabbitmq
image: rabbitmq:3.9-management-alpine
ports:
- "15672:15672"
- "5672:5672"
environment:
RABBITMQ_DEFAULT_USER: "rabbit"
RABBITMQ_DEFAULT_PASS: "rabbit"

View file

@ -5,14 +5,14 @@
This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime.
It exposes a shlink instance served with [swoole](https://www.swoole.co.uk/), which can be linked to external databases to persist data.
It exposes a shlink instance served with [openswoole](https://www.swoole.co.uk/), which can be linked to external databases to persist data.
## Usage
The most basic way to run Shlink's docker image is by providing these mandatory env vars.
* `DEFAULT_DOMAIN`: The default short domain used for this shlink instance. For example **doma.in**.
* `USE_HTTPS`: Either **true** or **false**.
* `IS_HTTPS_ENABLED`: Either **true** or **false**. Tells if Shlink is being served with HTTPs or not.
* `GEOLITE_LICENSE_KEY`: Your GeoLite2 license key. [Learn more](https://shlink.io/documentation/geolite-license-key/) about this.
To run shlink on top of a local docker service, and using an internal SQLite database, do the following:
@ -22,7 +22,7 @@ docker run \
--name shlink \
-p 8080:8080 \
-e DEFAULT_DOMAIN=doma.in \
-e USE_HTTPS=true \
-e IS_HTTPS_ENABLED=true \
-e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 \
shlinkio/shlink:stable
```

View file

@ -1,24 +1,27 @@
#!/usr/bin/env sh
set -e
# If SHELL_VERBOSITY was not explicitly provided, run commands in quite mode (-q)
[ $SHELL_VERBOSITY ] && flags="" || flags="-q"
cd /etc/shlink
echo "Creating fresh database if needed..."
php bin/cli db:create -n -q
php bin/cli db:create -n ${flags}
echo "Updating database..."
php bin/cli db:migrate -n -q
php bin/cli db:migrate -n ${flags}
echo "Generating proxies..."
php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies -n -q
php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies -n ${flags}
echo "Clearing entities cache..."
php vendor/doctrine/orm/bin/doctrine.php orm:clear-cache:metadata -n -q
php vendor/doctrine/orm/bin/doctrine.php orm:clear-cache:metadata -n ${flags}
# Try to download GeoLite2 db file only if the license key env var was defined
if [ ! -z "${GEOLITE_LICENSE_KEY}" ]; then
echo "Downloading GeoLite2 db file..."
php bin/cli visit:download-db -n -q
php bin/cli visit:download-db -n ${flags}
fi
# Periodicaly run visit:locate every hour
@ -30,6 +33,6 @@ if [ $ENABLE_PERIODIC_VISIT_LOCATE ]; then
/usr/sbin/crond &
fi
# When restarting the container, swoole might think it is already in execution
# When restarting the container, openswoole might think it is already in execution
# This forces the app to be started every second until the exit code is 0
until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done

View file

@ -11,7 +11,7 @@
},
"defaultContentType": "application/json",
"channels": {
"http://shlink.io/new-visit": {
"https://shlink.io/new-visit": {
"subscribe": {
"summary": "Receive information about any new visit occurring on any short URL.",
"operationId": "newVisit",
@ -31,7 +31,7 @@
}
}
},
"http://shlink.io/new-visit/{shortCode}": {
"https://shlink.io/new-visit/{shortCode}": {
"parameters": {
"shortCode": {
"description": "The short code of the short URL",
@ -59,7 +59,7 @@
}
}
},
"http://shlink.io/new-orphan-visit": {
"https://shlink.io/new-orphan-visit": {
"subscribe": {
"summary": "Receive information about any new orphan visit.",
"operationId": "newOrphanVisit",

View file

@ -0,0 +1,9 @@
{
"value": {
"title": "Invalid data",
"type": "INVALID_ARGUMENT",
"detail": "Provided data is not valid",
"status": 400,
"invalidElements": ["maxVisits", "validSince"]
}
}

View file

@ -0,0 +1,9 @@
{
"value": {
"detail":"No URL found with short code \"abc123\"",
"title":"Short URL not found",
"type": "INVALID_SHORTCODE",
"status": 404,
"shortCode": "abc123"
}
}

View file

@ -0,0 +1,9 @@
{
"value": {
"detail": "Tag with name \"foo\" could not be found",
"title": "Tag not found",
"type": "TAG_NOT_FOUND",
"status": 404,
"tag": "foo"
}
}

View file

@ -13,16 +13,14 @@
"application/json": {
"schema": {
"$ref": "../definitions/Health.json"
}
}
},
"examples": {
"application/json": {
"status": "pass",
"version": "1.16.0",
"links": {
"about": "https://shlink.io",
"project": "https://github.com/shlinkio/shlink"
},
"example": {
"status": "pass",
"version": "2.10.0",
"links": {
"about": "https://shlink.io",
"project": "https://github.com/shlinkio/shlink"
}
}
}
}
@ -33,21 +31,19 @@
"application/json": {
"schema": {
"$ref": "../definitions/Health.json"
}
}
},
"examples": {
"application/json": {
"status": "fail",
"version": "1.16.0",
"links": {
"about": "https://shlink.io",
"project": "https://github.com/shlinkio/shlink"
},
"example": {
"status": "fail",
"version": "2.10.0",
"links": {
"about": "https://shlink.io",
"project": "https://github.com/shlinkio/shlink"
}
}
}
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/json": {

View file

@ -117,79 +117,77 @@
}
}
}
}
}
},
"examples": {
"application/json": {
"shortUrls": {
"data": [
{
"shortCode": "12C18",
"shortUrl": "https://doma.in/12C18",
"longUrl": "https://store.steampowered.com",
"dateCreated": "2016-08-21T20:34:16+02:00",
"visitsCount": 328,
"tags": [
"games",
"tech"
],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 100
},
"example": {
"shortUrls": {
"data": [
{
"shortCode": "12C18",
"shortUrl": "https://doma.in/12C18",
"longUrl": "https://store.steampowered.com",
"dateCreated": "2016-08-21T20:34:16+02:00",
"visitsCount": 328,
"tags": [
"games",
"tech"
],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 100
},
"domain": null,
"title": "Welcome to Steam",
"crawlable": false
},
"domain": null,
"title": "Welcome to Steam",
"crawlable": false
},
{
"shortCode": "12Kb3",
"shortUrl": "https://doma.in/12Kb3",
"longUrl": "https://shlink.io",
"dateCreated": "2016-05-01T20:34:16+02:00",
"visitsCount": 1029,
"tags": [
"shlink"
],
"meta": {
"validSince": null,
"validUntil": null,
"maxVisits": null
{
"shortCode": "12Kb3",
"shortUrl": "https://doma.in/12Kb3",
"longUrl": "https://shlink.io",
"dateCreated": "2016-05-01T20:34:16+02:00",
"visitsCount": 1029,
"tags": [
"shlink"
],
"meta": {
"validSince": null,
"validUntil": null,
"maxVisits": null
},
"domain": null,
"title": null,
"crawlable": false
},
"domain": null,
"title": null,
"crawlable": false
},
{
"shortCode": "123bA",
"shortUrl": "https://example.com/123bA",
"longUrl": "https://www.google.com",
"dateCreated": "2015-10-01T20:34:16+02:00",
"visitsCount": 25,
"tags": [],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": null
},
"domain": "example.com",
"title": null,
"crawlable": false
{
"shortCode": "123bA",
"shortUrl": "https://example.com/123bA",
"longUrl": "https://www.google.com",
"dateCreated": "2015-10-01T20:34:16+02:00",
"visitsCount": 25,
"tags": [],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": null
},
"domain": "example.com",
"title": null,
"crawlable": false
}
],
"pagination": {
"currentPage": 5,
"pagesCount": 12,
"itemsPerPage": 10,
"itemsInCurrentPage": 10,
"totalItems": 115
}
],
"pagination": {
"currentPage": 5,
"pagesCount": 12,
"itemsPerPage": 10,
"itemsInCurrentPage": 10,
"totalItems": 115
}
}
}
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
@ -267,28 +265,26 @@
"application/json": {
"schema": {
"$ref": "../definitions/ShortUrl.json"
}
}
},
"examples": {
"application/json": {
"shortCode": "12C18",
"shortUrl": "https://doma.in/12C18",
"longUrl": "https://store.steampowered.com",
"dateCreated": "2016-08-21T20:34:16+02:00",
"visitsCount": 0,
"tags": [
"games",
"tech"
],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 500
},
"domain": null,
"title": null,
"crawlable": false
"example": {
"shortCode": "12C18",
"shortUrl": "https://doma.in/12C18",
"longUrl": "https://store.steampowered.com",
"dateCreated": "2016-08-21T20:34:16+02:00",
"visitsCount": 0,
"tags": [
"games",
"tech"
],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 500
},
"domain": null,
"title": null,
"crawlable": false
}
}
}
},
@ -326,15 +322,42 @@
"customSlug": {
"type": "string",
"description": "Provided custom slug when the error type is INVALID_SLUG"
},
"domain": {
"type": "string",
"description": "The domain for which you were trying to create the new short URL"
}
}
}
]
},
"examples": {
"Invalid arguments": {
"$ref": "../examples/short-url-invalid-args.json"
},
"Invalid long URL": {
"value": {
"title": "Invalid URL",
"type": "INVALID_URL",
"detail": "Provided URL foo is invalid. Try with a different one.",
"status": 400,
"url": "https://invalid-url.com"
}
},
"Non-unique slug": {
"value": {
"title": "Invalid custom slug",
"type": "INVALID_SLUG",
"detail": "Provided slug \"my-slug\" is already in use.",
"status": 400,
"customSlug": "my-slug"
}
}
}
}
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {

View file

@ -49,35 +49,33 @@
"application/json": {
"schema": {
"$ref": "../definitions/ShortUrl.json"
},
"example": {
"longUrl": "https://github.com/shlinkio/shlink",
"shortUrl": "https://doma.in/abc123",
"shortCode": "abc123",
"dateCreated": "2016-08-21T20:34:16+02:00",
"visitsCount": 0,
"tags": [
"games",
"tech"
],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 100
},
"domain": null,
"title": null,
"crawlable": false
}
},
"text/plain": {
"schema": {
"type": "string"
}
}
},
"examples": {
"application/json": {
"longUrl": "https://github.com/shlinkio/shlink",
"shortUrl": "https://doma.in/abc123",
"shortCode": "abc123",
"dateCreated": "2016-08-21T20:34:16+02:00",
"visitsCount": 0,
"tags": [
"games",
"tech"
],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 100
},
"domain": null,
"title": null,
"crawlable": false
},
"text/plain": "https://doma.in/abc123"
"example": "https://doma.in/abc123"
}
}
},
"400": {
@ -86,26 +84,24 @@
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
},
"example": {
"title": "Invalid URL",
"type": "INVALID_URL",
"detail": "Provided URL foo is invalid. Try with a different one.",
"status": 400,
"url": "https://invalid-url.com"
}
},
"text/plain": {
"schema": {
"type": "string"
}
},
"example": "INVALID_URL"
}
},
"examples": {
"application/problem+json": {
"title": "Invalid URL",
"type": "INVALID_URL",
"detail": "Provided URL foo is invalid. Try with a different one.",
"status": 400,
"url": "https://invalid-url.com"
},
"text/plain": "INVALID_URL"
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
@ -118,13 +114,6 @@
"type": "string"
}
}
},
"examples": {
"application/problem+json": {
"error": "INTERNAL_SERVER_ERROR",
"message": "Unexpected error occurred"
},
"text/plain": "INTERNAL_SERVER_ERROR"
}
}
}

View file

@ -35,27 +35,25 @@
"application/json": {
"schema": {
"$ref": "../definitions/ShortUrl.json"
}
}
},
"examples": {
"application/json": {
"shortCode": "12Kb3",
"shortUrl": "https://doma.in/12Kb3",
"longUrl": "https://shlink.io",
"dateCreated": "2016-05-01T20:34:16+02:00",
"visitsCount": 1029,
"tags": [
"shlink"
],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 100
},
"domain": null,
"title": null,
"crawlable": false
"example": {
"shortCode": "12Kb3",
"shortUrl": "https://doma.in/12Kb3",
"longUrl": "https://shlink.io",
"dateCreated": "2016-05-01T20:34:16+02:00",
"visitsCount": 1029,
"tags": [
"shlink"
],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 100
},
"domain": null,
"title": null,
"crawlable": false
}
}
}
},
@ -64,12 +62,35 @@
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
"allOf": [
{
"$ref": "../definitions/Error.json"
},
{
"type": "object",
"required": ["shortCode"],
"properties": {
"shortCode": {
"type": "string",
"description": "The short code with which we tried to find the short URL"
},
"domain": {
"type": "string",
"description": "The domain with which we tried to find the short URL"
}
}
}
]
},
"examples": {
"Not found": {
"$ref": "../examples/short-url-not-found.json"
}
}
}
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
@ -129,27 +150,25 @@
"application/json": {
"schema": {
"$ref": "../definitions/ShortUrl.json"
}
}
},
"examples": {
"application/json": {
"shortCode": "12Kb3",
"shortUrl": "https://doma.in/12Kb3",
"longUrl": "https://shlink.io",
"dateCreated": "2016-05-01T20:34:16+02:00",
"visitsCount": 1029,
"tags": [
"shlink"
],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 100
},
"domain": null,
"title": "Shlink - The URL shortener",
"crawlable": false
"example": {
"shortCode": "12Kb3",
"shortUrl": "https://doma.in/12Kb3",
"longUrl": "https://shlink.io",
"dateCreated": "2016-05-01T20:34:16+02:00",
"visitsCount": 1029,
"tags": [
"shlink"
],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 100
},
"domain": null,
"title": "Shlink - The URL shortener",
"crawlable": false
}
}
}
},
@ -182,21 +201,49 @@
}
}
]
},
"examples": {
"Invalid arguments": {
"$ref": "../examples/short-url-invalid-args.json"
}
}
}
}
},
"404": {
"description": "No short URL was found for provided short code.",
"description": "No URL was found for provided short code.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
"allOf": [
{
"$ref": "../definitions/Error.json"
},
{
"type": "object",
"required": ["shortCode"],
"properties": {
"shortCode": {
"type": "string",
"description": "The short code with which we tried to find the short URL"
},
"domain": {
"type": "string",
"description": "The domain with which we tried to find the short URL"
}
}
}
]
},
"examples": {
"Not found": {
"$ref": "../examples/short-url-not-found.json"
}
}
}
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
@ -247,30 +294,75 @@
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
"allOf": [
{
"$ref": "../definitions/Error.json"
},
{
"type": "object",
"required": ["shortCode", "threshold"],
"properties": {
"shortCode": {
"type": "string",
"description": "The short code with which we tried to find the short URL to delete"
},
"domain": {
"type": "string",
"description": "The domain with which we tried to find the short URL to delete"
},
"threshold": {
"type": "number",
"description": "The amount of visits currently configured as threshold to allow deleting short UYRLs or not"
}
}
}
]
},
"example": {
"title": "Cannot delete short URL",
"type": "INVALID_SHORTCODE_DELETION",
"detail": "Impossible to delete short URL with short code \"abc123\", since it has more than \"15\" visits.",
"status": 422,
"shortCode": "abc123",
"threshold": 15
}
}
},
"examples": {
"application/problem+json": {
"title": "Cannot delete short URL",
"type": "INVALID_SHORTCODE_DELETION",
"detail": "It is not possible to delete URL with short code \"abc123\" because it has reached more than \"15\" visits.",
"status": 422
}
}
},
"404": {
"description": "No short URL was found for provided short code.",
"description": "No URL was found for provided short code.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
"allOf": [
{
"$ref": "../definitions/Error.json"
},
{
"type": "object",
"required": ["shortCode"],
"properties": {
"shortCode": {
"type": "string",
"description": "The short code with which we tried to find the short URL"
},
"domain": {
"type": "string",
"description": "The domain with which we tried to find the short URL"
}
}
}
]
},
"examples": {
"Not found": {
"$ref": "../examples/short-url-not-found.json"
}
}
}
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {

View file

@ -69,14 +69,6 @@
}
}
}
},
"examples": {
"application/json": {
"tags": [
"games",
"tech"
]
}
}
},
"400": {
@ -99,7 +91,7 @@
}
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/json": {

View file

@ -97,49 +97,47 @@
}
}
}
}
}
},
"examples": {
"application/json": {
"visits": {
"data": [
{
"referer": "https://twitter.com",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
"visitLocation": null,
"potentialBot": false
},
{
"referer": "https://t.co",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
"visitLocation": {
"cityName": "Cupertino",
"countryCode": "US",
"countryName": "United States",
"latitude": 37.3042,
"longitude": -122.0946,
"regionName": "California",
"timezone": "America/Los_Angeles"
},
"example": {
"visits": {
"data": [
{
"referer": "https://twitter.com",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
"visitLocation": null,
"potentialBot": false
},
"potentialBot": false
},
{
"referer": null,
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "some_web_crawler/1.4",
"visitLocation": null,
"potentialBot": true
{
"referer": "https://t.co",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
"visitLocation": {
"cityName": "Cupertino",
"countryCode": "US",
"countryName": "United States",
"latitude": 37.3042,
"longitude": -122.0946,
"regionName": "California",
"timezone": "America/Los_Angeles"
},
"potentialBot": false
},
{
"referer": null,
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "some_web_crawler/1.4",
"visitLocation": null,
"potentialBot": true
}
],
"pagination": {
"currentPage": 5,
"pagesCount": 12,
"itemsPerPage": 10,
"itemsInCurrentPage": 10,
"totalItems": 115
}
],
"pagination": {
"currentPage": 5,
"pagesCount": 12,
"itemsPerPage": 10,
"itemsInCurrentPage": 10,
"totalItems": 115
}
}
}
@ -151,11 +149,16 @@
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
},
"examples": {
"Short URL not found": {
"$ref": "../examples/short-url-not-found.json"
}
}
}
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {

View file

@ -57,23 +57,47 @@
}
}
}
}
}
},
"examples": {
"application/json": {
"tags": {
"data": [
"games",
"php",
"shlink",
"tech"
]
},
"examples": {
"Without stats": {
"value": {
"tags": {
"data": [
"games",
"php",
"shlink",
"tech"
]
}
}
},
"With stats": {
"value": {
"tags": {
"data": [
"games",
"shlink"
],
"stats": [
{
"tag": "games",
"shortUrlsCount": 10,
"visitsCount": 521
},
{
"tag": "shlink",
"shortUrlsCount": 7,
"visitsCount": 1087
}
]
}
}
}
}
}
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
@ -149,21 +173,9 @@
}
}
}
},
"examples": {
"application/json": {
"tags": {
"data": [
"games",
"php",
"shlink",
"tech"
]
}
}
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
@ -228,6 +240,13 @@
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
},
"example": {
"title": "Invalid data",
"type": "INVALID_ARGUMENT",
"detail": "Provided data is not valid",
"status": 400,
"invalidElements": ["oldName", "newName"]
}
}
}
@ -238,6 +257,12 @@
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
},
"example": {
"detail": "You are not allowed to rename tags",
"title": "Forbidden tag operation",
"type": "FORBIDDEN_OPERATION",
"status": 403
}
}
}
@ -248,6 +273,11 @@
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
},
"examples": {
"Tag not found": {
"$ref": "../examples/tag-not-found.json"
}
}
}
}
@ -258,11 +288,19 @@
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
},
"example": {
"detail": "You cannot rename tag foo, because it already exists",
"title": "Tag conflict",
"type": "TAG_CONFLICT",
"status": 409,
"oldName": "bar",
"newName": "foo"
}
}
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
@ -314,11 +352,17 @@
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
},
"example": {
"detail": "You are not allowed to delete tags",
"title": "Forbidden tag operation",
"type": "FORBIDDEN_OPERATION",
"status": 403
}
}
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {

View file

@ -4,8 +4,8 @@
"tags": [
"Domains"
],
"summary": "List existing domains",
"description": "Returns the list of all domains ever used, with a flag that tells if they are the default domain",
"summary": "List configured domains",
"description": "Returns the list of all domains that have been either used for some short URL, or have explicitly configured redirects.<br/>It also includes the domain redirects, plus the default redirects that will be used for any non-explicitly-configured one.",
"security": [
{
"ApiKey": []
@ -46,50 +46,56 @@
}
}
}
},
"defaultRedirects": {
"$ref": "../definitions/NotFoundRedirects.json"
}
}
}
}
}
}
},
"examples": {
"application/json": {
"domains": {
"data": [
{
"domain": "example.com",
"isDefault": true,
"redirects": {
"baseUrlRedirect": "https://example.com/my-landing-page",
"regular404Redirect": null,
"invalidShortUrlRedirect": "https://example.com/invalid-url"
}
},
{
"domain": "aaa.com",
"isDefault": false,
"redirects": {
"baseUrlRedirect": null,
"regular404Redirect": null,
"invalidShortUrlRedirect": null
}
},
{
"domain": "bbb.com",
"isDefault": false,
"redirects": {
"baseUrlRedirect": null,
"regular404Redirect": null,
"invalidShortUrlRedirect": "https://example.com/invalid-url"
},
"example": {
"domains": {
"data": [
{
"domain": "example.com",
"isDefault": true,
"redirects": {
"baseUrlRedirect": "https://example.com/my-landing-page",
"regular404Redirect": null,
"invalidShortUrlRedirect": "https://example.com/invalid-url"
}
},
{
"domain": "aaa.com",
"isDefault": false,
"redirects": {
"baseUrlRedirect": null,
"regular404Redirect": null,
"invalidShortUrlRedirect": null
}
},
{
"domain": "bbb.com",
"isDefault": false,
"redirects": {
"baseUrlRedirect": null,
"regular404Redirect": null,
"invalidShortUrlRedirect": "https://example.com/invalid-url"
}
}
],
"defaultRedirects": {
"baseUrlRedirect": "https://somewhere.com",
"regular404Redirect": null,
"invalidShortUrlRedirect": null
}
]
}
}
}
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {

View file

@ -55,15 +55,13 @@
"$ref": "../definitions/NotFoundRedirects.json"
}
]
},
"example": {
"baseUrlRedirect": "https://example.com/my-landing-page",
"regular404Redirect": null,
"invalidShortUrlRedirect": "https://example.com/invalid-url"
}
}
},
"examples": {
"application/json": {
"baseUrlRedirect": "https://example.com/my-landing-page",
"regular404Redirect": null,
"invalidShortUrlRedirect": "https://example.com/invalid-url"
}
}
},
"400": {
@ -95,21 +93,18 @@
}
}
]
},
"example": {
"title": "Invalid data",
"type": "INVALID_ARGUMENT",
"detail": "Provided data is not valid",
"status": 400,
"invalidElements": ["domain", "invalidShortUrlRedirect"]
}
}
}
},
"403": {
"description": "Default domain was provided, and it cannot be edited this way.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {

View file

@ -23,15 +23,13 @@
"application/json": {
"schema": {
"$ref": "../definitions/MercureInfo.json"
},
"example": {
"mercureHubUrl": "https://example.com/.well-known/mercure",
"jwt": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTaGxpbmsiLCJpYXQiOjE1ODY2ODY3MzIsImV4cCI6MTU4Njk0NTkzMiwibWVyY3VyZSI6eyJzdWJzY3JpYmUiOltdfX0.P-519lgU7dFz0bbNlRG1CXyqugGbaHon4kw6fu4QBdQ",
"jwtExpiration": "2020-04-15T12:18:52+02:00"
}
}
},
"examples": {
"application/json": {
"mercureHubUrl": "https://example.com/.well-known/mercure",
"jwt": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTaGxpbmsiLCJpYXQiOjE1ODY2ODY3MzIsImV4cCI6MTU4Njk0NTkzMiwibWVyY3VyZSI6eyJzdWJzY3JpYmUiOltdfX0.P-519lgU7dFz0bbNlRG1CXyqugGbaHon4kw6fu4QBdQ",
"jwtExpiration": "2020-04-15T12:18:52+02:00"
}
}
},
"501": {
@ -40,19 +38,17 @@
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
},
"example": {
"title": "Mercure integration not configured",
"type": "MERCURE_NOT_CONFIGURED",
"detail": "This Shlink instance is not integrated with a mercure hub.",
"status": 501
}
}
},
"examples": {
"application/json": {
"title": "Mercure integration not configured",
"type": "MERCURE_NOT_CONFIGURED",
"detail": "This Shlink instance is not integrated with a mercure hub.",
"status": 501
}
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {

View file

@ -94,49 +94,47 @@
}
}
}
}
}
},
"examples": {
"application/json": {
"visits": {
"data": [
{
"referer": "https://twitter.com",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
"visitLocation": null,
"potentialBot": false
},
{
"referer": "https://t.co",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
"visitLocation": {
"cityName": "Cupertino",
"countryCode": "US",
"countryName": "United States",
"latitude": 37.3042,
"longitude": -122.0946,
"regionName": "California",
"timezone": "America/Los_Angeles"
},
"example": {
"visits": {
"data": [
{
"referer": "https://twitter.com",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
"visitLocation": null,
"potentialBot": false
},
"potentialBot": false
},
{
"referer": null,
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "some_web_crawler/1.4",
"visitLocation": null,
"potentialBot": true
{
"referer": "https://t.co",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
"visitLocation": {
"cityName": "Cupertino",
"countryCode": "US",
"countryName": "United States",
"latitude": 37.3042,
"longitude": -122.0946,
"regionName": "California",
"timezone": "America/Los_Angeles"
},
"potentialBot": false
},
{
"referer": null,
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "some_web_crawler/1.4",
"visitLocation": null,
"potentialBot": true
}
],
"pagination": {
"currentPage": 5,
"pagesCount": 12,
"itemsPerPage": 10,
"itemsInCurrentPage": 10,
"totalItems": 115
}
],
"pagination": {
"currentPage": 5,
"pagesCount": 12,
"itemsPerPage": 10,
"itemsInCurrentPage": 10,
"totalItems": 115
}
}
}
@ -148,11 +146,16 @@
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
},
"examples": {
"Tag not found": {
"$ref": "../examples/tag-not-found.json"
}
}
}
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {

View file

@ -28,19 +28,17 @@
"$ref": "../definitions/VisitStats.json"
}
}
}
}
},
"examples": {
"application/json": {
"visits": {
"visitsCount": 1569874,
"orphanVisitsCount": 71345
},
"example": {
"visits": {
"visitsCount": 1569874,
"orphanVisitsCount": 71345
}
}
}
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {

View file

@ -85,61 +85,59 @@
}
}
}
}
}
},
"examples": {
"application/json": {
"visits": {
"data": [
{
"referer": "https://twitter.com",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
"visitLocation": null,
"potentialBot": false,
"visitedUrl": "https://doma.in",
"type": "base_url"
},
{
"referer": "https://t.co",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
"visitLocation": {
"cityName": "Cupertino",
"countryCode": "US",
"countryName": "United States",
"latitude": 37.3042,
"longitude": -122.0946,
"regionName": "California",
"timezone": "America/Los_Angeles"
},
"example": {
"visits": {
"data": [
{
"referer": "https://twitter.com",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
"visitLocation": null,
"potentialBot": false,
"visitedUrl": "https://doma.in",
"type": "base_url"
},
"potentialBot": false,
"visitedUrl": "https://doma.in/foo",
"type": "invalid_short_url"
},
{
"referer": null,
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "some_web_crawler/1.4",
"visitLocation": null,
"potentialBot": true,
"visitedUrl": "https://doma.in/foo/bar/baz",
"type": "regular_404"
{
"referer": "https://t.co",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
"visitLocation": {
"cityName": "Cupertino",
"countryCode": "US",
"countryName": "United States",
"latitude": 37.3042,
"longitude": -122.0946,
"regionName": "California",
"timezone": "America/Los_Angeles"
},
"potentialBot": false,
"visitedUrl": "https://doma.in/foo",
"type": "invalid_short_url"
},
{
"referer": null,
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "some_web_crawler/1.4",
"visitLocation": null,
"potentialBot": true,
"visitedUrl": "https://doma.in/foo/bar/baz",
"type": "regular_404"
}
],
"pagination": {
"currentPage": 5,
"pagesCount": 12,
"itemsPerPage": 10,
"itemsInCurrentPage": 10,
"totalItems": 115
}
],
"pagination": {
"currentPage": 5,
"pagesCount": 12,
"itemsPerPage": 10,
"itemsInCurrentPage": 10,
"totalItems": 115
}
}
}
}
},
"500": {
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {

View file

@ -60,6 +60,17 @@
"enum": ["L", "M", "Q", "H"],
"default": "L"
}
},
{
"name": "roundBlockSize",
"in": "query",
"description": "Allows to disable block size rounding, which might reduce the readability of the QR code, but ensures no extra margin is added.",
"required": false,
"schema": {
"type": "string",
"enum": ["true", "false"],
"default": "false"
}
}
],
"responses": {

View file

@ -21,7 +21,7 @@
"name": "size",
"in": "path",
"description": "The size of the image to be returned.",
"required": false,
"required": true,
"schema": {
"type": "integer",
"minimum": 50,

23
infection-api.json Normal file
View file

@ -0,0 +1,23 @@
{
"source": {
"directories": [
"module/*/src"
]
},
"timeout": 5,
"logs": {
"text": "build/infection-api/infection-log.txt",
"summary": "build/infection-api/summary-log.txt",
"debug": "build/infection-api/debug-log.txt"
},
"tmpDir": "build/infection-api/temp",
"phpUnit": {
"configDir": "."
},
"testFrameworkOptions": "--configuration=phpunit-api.xml",
"mutators": {
"@default": true,
"IdenticalEqual": false,
"NotIdenticalNotEqual": false
}
}

View file

@ -8,7 +8,10 @@
"logs": {
"text": "build/infection-unit/infection-log.txt",
"summary": "build/infection-unit/summary-log.txt",
"debug": "build/infection-unit/debug-log.txt"
"debug": "build/infection-unit/debug-log.txt",
"badge": {
"branch": "develop"
}
},
"tmpDir": "build/infection-unit/temp",
"phpUnit": {

View file

@ -8,7 +8,7 @@ return [
'cli' => [
'commands' => [
Command\ShortUrl\GenerateShortUrlCommand::NAME => Command\ShortUrl\GenerateShortUrlCommand::class,
Command\ShortUrl\CreateShortUrlCommand::NAME => Command\ShortUrl\CreateShortUrlCommand::class,
Command\ShortUrl\ResolveUrlCommand::NAME => Command\ShortUrl\ResolveUrlCommand::class,
Command\ShortUrl\ListShortUrlsCommand::NAME => Command\ShortUrl\ListShortUrlsCommand::class,
Command\ShortUrl\GetVisitsCommand::NAME => Command\ShortUrl\GetVisitsCommand::class,

View file

@ -39,7 +39,7 @@ return [
ApiKey\RoleResolver::class => ConfigAbstractFactory::class,
Command\ShortUrl\GenerateShortUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\CreateShortUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\ListShortUrlsCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\GetVisitsCommand::class => ConfigAbstractFactory::class,
@ -75,10 +75,11 @@ return [
Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class],
ApiKey\RoleResolver::class => [DomainService::class],
Command\ShortUrl\GenerateShortUrlCommand::class => [
Command\ShortUrl\CreateShortUrlCommand::class => [
Service\UrlShortener::class,
ShortUrlStringifier::class,
'config.url_shortener.default_short_codes_length',
'config.url_shortener.domain.hostname',
],
Command\ShortUrl\ResolveUrlCommand::class => [Service\ShortUrl\ShortUrlResolver::class],
Command\ShortUrl\ListShortUrlsCommand::class => [

View file

@ -26,14 +26,17 @@ use function method_exists;
use function sprintf;
use function str_contains;
class GenerateShortUrlCommand extends BaseCommand
class CreateShortUrlCommand extends BaseCommand
{
public const NAME = 'short-url:generate';
public const NAME = 'short-url:create';
private ?SymfonyStyle $io;
public function __construct(
private UrlShortenerInterface $urlShortener,
private ShortUrlStringifierInterface $stringifier,
private int $defaultShortCodeLength,
private string $defaultDomain,
) {
parent::__construct();
}
@ -42,6 +45,7 @@ class GenerateShortUrlCommand extends BaseCommand
{
$this
->setName(self::NAME)
->setAliases(['short-url:generate']) // Deprecated
->setDescription('Generates a short URL for provided long URL and returns it')
->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to parse')
->addOption(
@ -122,21 +126,33 @@ class GenerateShortUrlCommand extends BaseCommand
protected function interact(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$this->verifyLongUrlArgument($input, $output);
$this->verifyDomainArgument($input);
}
private function verifyLongUrlArgument(InputInterface $input, OutputInterface $output): void
{
$longUrl = $input->getArgument('longUrl');
if (! empty($longUrl)) {
return;
}
$io = $this->getIO($input, $output);
$longUrl = $io->ask('Which URL do you want to shorten?');
if (! empty($longUrl)) {
$input->setArgument('longUrl', $longUrl);
}
}
private function verifyDomainArgument(InputInterface $input): void
{
$domain = $input->getOption('domain');
$input->setOption('domain', $domain === $this->defaultDomain ? null : $domain);
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$io = $this->getIO($input, $output);
$longUrl = $input->getArgument('longUrl');
if (empty($longUrl)) {
$io->error('A URL was not provided!');
@ -196,4 +212,9 @@ class GenerateShortUrlCommand extends BaseCommand
return null;
}
private function getIO(InputInterface $input, OutputInterface $output): SymfonyStyle
{
return $this->io ?? ($this->io = new SymfonyStyle($input, $output));
}
}

View file

@ -131,7 +131,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
];
if ($all) {
$data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = -1;
$data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = Paginator::ALL_ITEMS;
}
do {

View file

@ -45,7 +45,8 @@ class ListTagsCommand extends Command
return map(
$tags,
fn (TagInfo $tagInfo) => [(string) $tagInfo->tag(), $tagInfo->shortUrlsCount(), $tagInfo->visitsCount()],
static fn (TagInfo $tagInfo) =>
[$tagInfo->tag()->__toString(), $tagInfo->shortUrlsCount(), $tagInfo->visitsCount()],
);
}
}

View file

@ -34,7 +34,7 @@ class ProcessRunner implements ProcessRunnerInterface
}
/** @var DebugFormatterHelper $formatter */
$formatter = $this->helper->getHelperSet()->get('debug_formatter');
$formatter = $this->helper->getHelperSet()?->get('debug_formatter') ?? new DebugFormatterHelper();
/** @var Process $process */
$process = ($this->createProcess)($cmd);

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
@ -45,19 +46,25 @@ class ListKeysCommandTest extends TestCase
public function provideKeysAndOutputs(): iterable
{
$dateInThePast = Chronos::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00');
yield 'all keys' => [
[$apiKey1 = ApiKey::create(), $apiKey2 = ApiKey::create(), $apiKey3 = ApiKey::create()],
[
$apiKey1 = ApiKey::create()->disable(),
$apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withExpirationDate($dateInThePast)),
$apiKey3 = ApiKey::create(),
],
false,
<<<OUTPUT
+--------------------------------------+------+------------+-----------------+-------+
| Key | Name | Is enabled | Expiration date | Roles |
+--------------------------------------+------+------------+-----------------+-------+
| {$apiKey1} | - | +++ | - | Admin |
+--------------------------------------+------+------------+-----------------+-------+
| {$apiKey2} | - | +++ | - | Admin |
+--------------------------------------+------+------------+-----------------+-------+
| {$apiKey3} | - | +++ | - | Admin |
+--------------------------------------+------+------------+-----------------+-------+
+--------------------------------------+------+------------+---------------------------+-------+
| Key | Name | Is enabled | Expiration date | Roles |
+--------------------------------------+------+------------+---------------------------+-------+
| {$apiKey1} | - | --- | - | Admin |
+--------------------------------------+------+------------+---------------------------+-------+
| {$apiKey2} | - | --- | 2020-01-01T00:00:00+00:00 | Admin |
+--------------------------------------+------+------------+---------------------------+-------+
| {$apiKey3} | - | +++ | - | Admin |
+--------------------------------------+------+------------+---------------------------+-------+
OUTPUT,
];

View file

@ -126,8 +126,8 @@ class DomainRedirectsCommandTest extends TestCase
$listDomains = $this->domainService->listDomains()->willReturn([
DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()),
DomainItem::forExistingDomain(Domain::withAuthority('existing-one.com')),
DomainItem::forExistingDomain(Domain::withAuthority($domainAuthority)),
DomainItem::forNonDefaultDomain(Domain::withAuthority('existing-one.com')),
DomainItem::forNonDefaultDomain(Domain::withAuthority($domainAuthority)),
]);
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
$configureRedirects = $this->domainService->configureNotFoundRedirects(
@ -156,8 +156,8 @@ class DomainRedirectsCommandTest extends TestCase
$listDomains = $this->domainService->listDomains()->willReturn([
DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()),
DomainItem::forExistingDomain(Domain::withAuthority('existing-one.com')),
DomainItem::forExistingDomain(Domain::withAuthority('existing-two.com')),
DomainItem::forNonDefaultDomain(Domain::withAuthority('existing-one.com')),
DomainItem::forNonDefaultDomain(Domain::withAuthority('existing-two.com')),
]);
$findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain);
$configureRedirects = $this->domainService->configureNotFoundRedirects(

View file

@ -47,8 +47,8 @@ class ListDomainsCommandTest extends TestCase
'base_url' => 'https://foo.com/default/base',
'invalid_short_url' => 'https://foo.com/default/invalid',
])),
DomainItem::forExistingDomain(Domain::withAuthority('bar.com')),
DomainItem::forExistingDomain($bazDomain),
DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com')),
DomainItem::forNonDefaultDomain($bazDomain),
]);
$this->commandTester->execute($input);

View file

@ -8,7 +8,7 @@ use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GenerateShortUrlCommand;
use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
@ -19,10 +19,12 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester;
class GenerateShortUrlCommandTest extends TestCase
class CreateShortUrlCommandTest extends TestCase
{
use CliTestUtilsTrait;
private const DEFAULT_DOMAIN = 'default.com';
private CommandTester $commandTester;
private ObjectProphecy $urlShortener;
private ObjectProphecy $stringifier;
@ -33,7 +35,12 @@ class GenerateShortUrlCommandTest extends TestCase
$this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class);
$this->stringifier->stringify(Argument::type(ShortUrl::class))->willReturn('');
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), $this->stringifier->reveal(), 5);
$command = new CreateShortUrlCommand(
$this->urlShortener->reveal(),
$this->stringifier->reveal(),
5,
self::DEFAULT_DOMAIN,
);
$this->commandTester = $this->testerForCommand($command);
}
@ -110,6 +117,34 @@ class GenerateShortUrlCommandTest extends TestCase
$stringify->shouldHaveBeenCalledOnce();
}
/**
* @test
* @dataProvider provideDomains
*/
public function properlyProcessesProvidedDomain(array $input, ?string $expectedDomain): void
{
$shorten = $this->urlShortener->shorten(
Argument::that(function (ShortUrlMeta $meta) use ($expectedDomain) {
Assert::assertEquals($expectedDomain, $meta->getDomain());
return true;
}),
)->willReturn(ShortUrl::createEmpty());
$input['longUrl'] = 'http://domain.com/foo/bar';
$this->commandTester->execute($input);
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
$shorten->shouldHaveBeenCalledOnce();
}
public function provideDomains(): iterable
{
yield 'no domain' => [[], null];
yield 'non-default domain foo' => [['--domain' => 'foo.com'], 'foo.com'];
yield 'non-default domain bar' => [['-d' => 'bar.com'], 'bar.com'];
yield 'default domain' => [['--domain' => self::DEFAULT_DOMAIN], null];
}
/**
* @test
* @dataProvider provideFlags

View file

@ -83,7 +83,10 @@ class DeleteShortUrlCommandTest extends TestCase
$ignoreThreshold = array_pop($args);
if (!$ignoreThreshold) {
throw Exception\DeleteShortUrlException::fromVisitsThreshold(10, $shortCode);
throw Exception\DeleteShortUrlException::fromVisitsThreshold(
10,
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
);
}
},
);
@ -93,7 +96,7 @@ class DeleteShortUrlCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
self::assertStringContainsString(sprintf(
'Impossible to delete short URL with short code "%s" since it has more than "10" visits.',
'Impossible to delete short URL with short code "%s", since it has more than "10" visits.',
$shortCode,
), $output);
self::assertStringContainsString($expectedMessage, $output);
@ -112,7 +115,10 @@ class DeleteShortUrlCommandTest extends TestCase
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode(new ShortUrlIdentifier($shortCode), false)->willThrow(
Exception\DeleteShortUrlException::fromVisitsThreshold(10, $shortCode),
Exception\DeleteShortUrlException::fromVisitsThreshold(
10,
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
),
);
$this->commandTester->setInputs(['no']);
@ -120,7 +126,7 @@ class DeleteShortUrlCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
self::assertStringContainsString(sprintf(
'Impossible to delete short URL with short code "%s" since it has more than "10" visits.',
'Impossible to delete short URL with short code "%s", since it has more than "10" visits.',
$shortCode,
), $output);
self::assertStringContainsString('Short URL was not deleted.', $output);

View file

@ -271,7 +271,7 @@ class ListShortUrlsCommandTest extends TestCase
'startDate' => null,
'endDate' => null,
'orderBy' => null,
'itemsPerPage' => -1,
'itemsPerPage' => Paginator::ALL_ITEMS,
]))->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute(['--all' => true]);

View file

@ -49,12 +49,18 @@ class ListTagsCommandTest extends TestCase
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('| foo', $output);
self::assertStringContainsString('| bar', $output);
self::assertStringContainsString('| 10 ', $output);
self::assertStringContainsString('| 2 ', $output);
self::assertStringContainsString('| 7 ', $output);
self::assertStringContainsString('| 32 ', $output);
self::assertEquals(
<<<OUTPUT
+------+-------------+---------------+
| Name | URLs amount | Visits amount |
+------+-------------+---------------+
| foo | 10 | 2 |
| bar | 7 | 32 |
+------+-------------+---------------+
OUTPUT,
$output,
);
$tagsInfo->shouldHaveBeenCalled();
}
}

View file

@ -119,11 +119,7 @@ return [
],
Service\ShortUrl\ShortUrlResolver::class => ['em'],
Service\ShortUrl\ShortCodeHelper::class => ['em'],
Domain\DomainService::class => [
'em',
'config.url_shortener.domain.hostname',
Options\NotFoundRedirectOptions::class,
],
Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'],
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
Util\DoctrineBatchHelper::class => ['em'],

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use PhpAmqpLib\Connection\AMQPStreamConnection;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
@ -22,6 +23,7 @@ return [
'async' => [
EventDispatcher\Event\VisitLocated::class => [
EventDispatcher\NotifyVisitToMercure::class,
EventDispatcher\NotifyVisitToRabbitMq::class,
EventDispatcher\NotifyVisitToWebHooks::class,
EventDispatcher\UpdateGeoLiteDb::class,
],
@ -33,6 +35,7 @@ return [
EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class,
EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class,
],
@ -40,6 +43,9 @@ return [
EventDispatcher\NotifyVisitToMercure::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\NotifyVisitToRabbitMq::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\NotifyVisitToWebHooks::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
@ -68,6 +74,13 @@ return [
'em',
'Logger_Shlink',
],
EventDispatcher\NotifyVisitToRabbitMq::class => [
AMQPStreamConnection::class,
'em',
'Logger_Shlink',
Visit\Transformer\OrphanVisitDataTransformer::class,
'config.rabbitmq.enabled',
],
EventDispatcher\UpdateGeoLiteDb::class => [GeolocationDbUpdater::class, 'Logger_Shlink'],
],

View file

@ -9,6 +9,9 @@ use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelInterface;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelLow;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelMedium;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelQuartile;
use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeInterface;
use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeMargin;
use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeNone;
use Endroid\QrCode\Writer\PngWriter;
use Endroid\QrCode\Writer\SvgWriter;
use Endroid\QrCode\Writer\WriterInterface;
@ -31,6 +34,7 @@ final class QrCodeParams
private int $margin,
private WriterInterface $writer,
private ErrorCorrectionLevelInterface $errorCorrectionLevel,
private RoundBlockSizeModeInterface $roundBlockSizeMode,
) {
}
@ -43,6 +47,7 @@ final class QrCodeParams
self::resolveMargin($query, $defaults),
self::resolveWriter($query, $defaults),
self::resolveErrorCorrection($query, $defaults),
self::resolveRoundBlockSize($query, $defaults),
);
}
@ -90,6 +95,14 @@ final class QrCodeParams
};
}
private static function resolveRoundBlockSize(array $query, QrCodeOptions $defaults): RoundBlockSizeModeInterface
{
$doNotRoundBlockSize = isset($query['roundBlockSize'])
? $query['roundBlockSize'] === 'false'
: ! $defaults->roundBlockSize();
return $doNotRoundBlockSize ? new RoundBlockSizeModeNone() : new RoundBlockSizeModeMargin();
}
private static function normalizeParam(string $param): string
{
return strtolower(trim($param));
@ -114,4 +127,9 @@ final class QrCodeParams
{
return $this->errorCorrectionLevel;
}
public function roundBlockSizeMode(): RoundBlockSizeModeInterface
{
return $this->roundBlockSizeMode;
}
}

View file

@ -45,7 +45,8 @@ class QrCodeAction implements MiddlewareInterface
->size($params->size())
->margin($params->margin())
->writer($params->writer())
->errorCorrectionLevel($params->errorCorrectionLevel());
->errorCorrectionLevel($params->errorCorrectionLevel())
->roundBlockSizeMode($params->roundBlockSizeMode());
return new QrCodeResponse($qrCodeBuilder->build());
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config;
final class EmptyNotFoundRedirectConfig implements NotFoundRedirectConfigInterface
{
public function invalidShortUrlRedirect(): ?string
{
return null;
}
public function hasInvalidShortUrlRedirect(): bool
{
return false;
}
public function regular404Redirect(): ?string
{
return null;
}
public function hasRegular404Redirect(): bool
{
return false;
}
public function baseUrlRedirect(): ?string
{
return null;
}
public function hasBaseUrlRedirect(): bool
{
return false;
}
}

View file

@ -5,25 +5,23 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Config\EmptyNotFoundRedirectConfig;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use Shlinkio\Shlink\Core\Exception\InvalidDomainException;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function Functional\first;
use function Functional\group;
use function Functional\map;
class DomainService implements DomainServiceInterface
{
public function __construct(
private EntityManagerInterface $em,
private string $defaultDomain,
private NotFoundRedirectOptions $redirectOptions,
) {
public function __construct(private EntityManagerInterface $em, private string $defaultDomain)
{
}
/**
@ -31,21 +29,34 @@ class DomainService implements DomainServiceInterface
*/
public function listDomains(?ApiKey $apiKey = null): array
{
/** @var DomainRepositoryInterface $repo */
$repo = $this->em->getRepository(Domain::class);
$domains = $repo->findDomainsWithout($this->defaultDomain, $apiKey);
$mappedDomains = map($domains, fn (Domain $domain) => DomainItem::forExistingDomain($domain));
[$default, $domains] = $this->defaultDomainAndRest($apiKey);
$mappedDomains = map($domains, fn (Domain $domain) => DomainItem::forNonDefaultDomain($domain));
if ($apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) {
return $mappedDomains;
}
return [
DomainItem::forDefaultDomain($this->defaultDomain, $this->redirectOptions),
DomainItem::forDefaultDomain($this->defaultDomain, $default ?? new EmptyNotFoundRedirectConfig()),
...$mappedDomains,
];
}
/**
* @return array{Domain|null, Domain[]}
*/
private function defaultDomainAndRest(?ApiKey $apiKey): array
{
/** @var DomainRepositoryInterface $repo */
$repo = $this->em->getRepository(Domain::class);
$groups = group(
$repo->findDomains($apiKey),
fn (Domain $domain) => $domain->getAuthority() === $this->defaultDomain ? 'default' : 'domains',
);
return [first($groups['default'] ?? []), $groups['domains'] ?? []];
}
/**
* @throws DomainNotFoundException
*/
@ -62,8 +73,7 @@ class DomainService implements DomainServiceInterface
public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain
{
$repo = $this->em->getRepository(Domain::class);
return $repo->findOneByAuthority($authority, $apiKey);
return $this->em->getRepository(Domain::class)->findOneByAuthority($authority, $apiKey);
}
/**
@ -79,17 +89,12 @@ class DomainService implements DomainServiceInterface
/**
* @throws DomainNotFoundException
* @throws InvalidDomainException
*/
public function configureNotFoundRedirects(
string $authority,
NotFoundRedirects $notFoundRedirects,
?ApiKey $apiKey = null,
): Domain {
if ($authority === $this->defaultDomain) {
throw InvalidDomainException::forDefaultDomainRedirects();
}
$domain = $this->getPersistedDomain($authority, $apiKey);
$domain->configureNotFoundRedirects($notFoundRedirects);

View file

@ -8,7 +8,6 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use Shlinkio\Shlink\Core\Exception\InvalidDomainException;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface DomainServiceInterface
@ -32,7 +31,6 @@ interface DomainServiceInterface
/**
* @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided
* @throws InvalidDomainException If default domain is provided
*/
public function configureNotFoundRedirects(
string $authority,

View file

@ -18,7 +18,7 @@ final class DomainItem implements JsonSerializable
) {
}
public static function forExistingDomain(Domain $domain): self
public static function forNonDefaultDomain(Domain $domain): self
{
return new self($domain->getAuthority(), $domain, false);
}

View file

@ -8,7 +8,6 @@ use Doctrine\ORM\Query\Expr\Join;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Happyr\DoctrineSpecification\Spec;
use Shlinkio\Shlink\Core\Domain\Spec\IsDomain;
use Shlinkio\Shlink\Core\Domain\Spec\IsNotAuthority;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToApiKey;
@ -20,7 +19,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
/**
* @return Domain[]
*/
public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array
public function findDomains(?ApiKey $apiKey = null): array
{
$qb = $this->createQueryBuilder('d');
$qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d')
@ -31,7 +30,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
->orHaving($qb->expr()->isNotNull('d.regular404Redirect'))
->orHaving($qb->expr()->isNotNull('d.invalidShortUrlRedirect'));
$specs = $this->determineExtraSpecs($excludedAuthority, $apiKey);
$specs = $this->determineExtraSpecs($apiKey);
foreach ($specs as [$alias, $spec]) {
$this->applySpecification($qb, $spec, $alias);
}
@ -47,7 +46,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
->setParameter('authority', $authority)
->setMaxResults(1);
$specs = $this->determineExtraSpecs(null, $apiKey);
$specs = $this->determineExtraSpecs($apiKey);
foreach ($specs as [$alias, $spec]) {
$this->applySpecification($qb, $spec, $alias);
}
@ -55,12 +54,8 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
return $qb->getQuery()->getOneOrNullResult();
}
private function determineExtraSpecs(?string $excludedAuthority, ?ApiKey $apiKey): iterable
private function determineExtraSpecs(?ApiKey $apiKey): iterable
{
if ($excludedAuthority !== null) {
yield ['d', new IsNotAuthority($excludedAuthority)];
}
// FIXME The $apiKey->spec() method cannot be used here, as it returns a single spec which assumes the
// ShortUrl is the root entity. Here, the Domain is the root entity.
// Think on a way to centralize the conditional behavior and make $apiKey->spec() more flexible.

View file

@ -14,7 +14,7 @@ interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificatio
/**
* @return Domain[]
*/
public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array;
public function findDomains(?ApiKey $apiKey = null): array;
public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain;
}

View file

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain\Spec;
use Happyr\DoctrineSpecification\Filter\Filter;
use Happyr\DoctrineSpecification\Spec;
use Happyr\DoctrineSpecification\Specification\BaseSpecification;
class IsNotAuthority extends BaseSpecification
{
public function __construct(private string $authority, ?string $context = null)
{
parent::__construct($context);
}
protected function getSpec(): Filter
{
return Spec::not(Spec::eq('authority', $this->authority));
}
}

View file

@ -103,7 +103,7 @@ class Visit extends AbstractEntity implements JsonSerializable
}
try {
return (string) IpAddress::fromString($address)->getAnonymizedCopy();
return IpAddress::fromString($address)->getAnonymizedCopy()->__toString();
} catch (InvalidArgumentException) {
return null;
}

View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher;
use Doctrine\ORM\EntityManagerInterface;
use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Exchange\AMQPExchangeType;
use PhpAmqpLib\Message\AMQPMessage;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Throwable;
use function Shlinkio\Shlink\Common\json_encode;
use function sprintf;
class NotifyVisitToRabbitMq
{
private const NEW_VISIT_QUEUE = 'https://shlink.io/new-visit';
private const NEW_ORPHAN_VISIT_QUEUE = 'https://shlink.io/new-orphan-visit';
public function __construct(
private AMQPStreamConnection $connection,
private EntityManagerInterface $em,
private LoggerInterface $logger,
private DataTransformerInterface $orphanVisitTransformer,
private bool $isEnabled,
) {
}
public function __invoke(VisitLocated $shortUrlLocated): void
{
if (! $this->isEnabled) {
return;
}
$visitId = $shortUrlLocated->visitId();
$visit = $this->em->find(Visit::class, $visitId);
if ($visit === null) {
$this->logger->warning('Tried to notify RabbitMQ for visit with id "{visitId}", but it does not exist.', [
'visitId' => $visitId,
]);
return;
}
if (! $this->connection->isConnected()) {
$this->connection->reconnect();
}
$queues = $this->determineQueuesToPublishTo($visit);
$message = $this->visitToMessage($visit);
try {
$channel = $this->connection->channel();
foreach ($queues as $queue) {
// Declare an exchange and a queue that will persist server restarts
$exchange = $queue; // We use the same name for the exchange and the queue
$channel->exchange_declare($exchange, AMQPExchangeType::DIRECT, false, true, false);
$channel->queue_declare($queue, false, true, false, false);
// Bind the exchange and the queue together, and publish the message
$channel->queue_bind($queue, $exchange);
$channel->basic_publish($message, $exchange);
}
$channel->close();
} catch (Throwable $e) {
$this->logger->debug('Error while trying to notify RabbitMQ with new visit. {e}', ['e' => $e]);
} finally {
$this->connection->close();
}
}
/**
* @return string[]
*/
private function determineQueuesToPublishTo(Visit $visit): array
{
if ($visit->isOrphan()) {
return [self::NEW_ORPHAN_VISIT_QUEUE];
}
return [
self::NEW_VISIT_QUEUE,
sprintf('%s/%s', self::NEW_VISIT_QUEUE, $visit->getShortUrl()?->getShortCode()),
];
}
private function visitToMessage(Visit $visit): AMQPMessage
{
$messageBody = json_encode(! $visit->isOrphan() ? $visit : $this->orphanVisitTransformer->transform($visit));
return new AMQPMessage($messageBody, [
'content_type' => 'application/json',
'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT,
]);
}
}

View file

@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use function sprintf;
@ -17,11 +18,15 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE
private const TITLE = 'Cannot delete short URL';
private const TYPE = 'INVALID_SHORTCODE_DELETION'; // FIXME Deprecated: Should be INVALID_SHORT_URL_DELETION
public static function fromVisitsThreshold(int $threshold, string $shortCode): self
public static function fromVisitsThreshold(int $threshold, ShortUrlIdentifier $identifier): self
{
$shortCode = $identifier->shortCode();
$domain = $identifier->domain();
$suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain);
$e = new self(sprintf(
'Impossible to delete short URL with short code "%s" since it has more than "%s" visits.',
'Impossible to delete short URL with short code "%s"%s, since it has more than "%s" visits.',
$shortCode,
$suffix,
$threshold,
));
@ -34,6 +39,10 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE
'threshold' => $threshold,
];
if ($domain !== null) {
$e->additional['domain'] = $domain;
}
return $e;
}

View file

@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
class InvalidDomainException extends DomainException implements ProblemDetailsExceptionInterface
{
use CommonProblemDetailsExceptionTrait;
private const TITLE = 'Invalid domain';
private const TYPE = 'INVALID_DOMAIN';
private function __construct(string $message)
{
parent::__construct($message);
}
public static function forDefaultDomainRedirects(): self
{
$e = new self('You cannot configure default domain\'s redirects this way. Use the configuration or env vars.');
$e->detail = $e->getMessage();
$e->title = self::TITLE;
$e->type = self::TYPE;
$e->status = StatusCodeInterface::STATUS_FORBIDDEN;
return $e;
}
}

View file

@ -8,11 +8,9 @@ use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\Entity\Visit;
use Symfony\Component\Mercure\Update;
use function json_encode;
use function Shlinkio\Shlink\Common\json_encode;
use function sprintf;
use const JSON_THROW_ON_ERROR;
final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface
{
private const NEW_VISIT_TOPIC = 'https://shlink.io/new-visit';
@ -26,7 +24,7 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface
public function newVisitUpdate(Visit $visit): Update
{
return new Update(self::NEW_VISIT_TOPIC, $this->serialize([
return new Update(self::NEW_VISIT_TOPIC, json_encode([
'shortUrl' => $this->shortUrlTransformer->transform($visit->getShortUrl()),
'visit' => $visit,
]));
@ -34,7 +32,7 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface
public function newOrphanVisitUpdate(Visit $visit): Update
{
return new Update(self::NEW_ORPHAN_VISIT_TOPIC, $this->serialize([
return new Update(self::NEW_ORPHAN_VISIT_TOPIC, json_encode([
'visit' => $this->orphanVisitTransformer->transform($visit),
]));
}
@ -44,14 +42,9 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface
$shortUrl = $visit->getShortUrl();
$topic = sprintf('%s/%s', self::NEW_VISIT_TOPIC, $shortUrl?->getShortCode());
return new Update($topic, $this->serialize([
return new Update($topic, json_encode([
'shortUrl' => $this->shortUrlTransformer->transform($shortUrl),
'visit' => $visit,
]));
}
private function serialize(array $data): string
{
return json_encode($data, JSON_THROW_ON_ERROR);
}
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Model;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use function Shlinkio\Shlink\Core\parseDateRangeFromQuery;
@ -11,7 +12,6 @@ use function Shlinkio\Shlink\Core\parseDateRangeFromQuery;
final class VisitsParams
{
private const FIRST_PAGE = 1;
private const ALL_ITEMS = -1;
private DateRange $dateRange;
private int $page;
@ -36,10 +36,10 @@ final class VisitsParams
private function determineItemsPerPage(?int $itemsPerPage): int
{
if ($itemsPerPage !== null && $itemsPerPage < 0) {
return self::ALL_ITEMS;
return Paginator::ALL_ITEMS;
}
return $itemsPerPage ?? self::ALL_ITEMS;
return $itemsPerPage ?? Paginator::ALL_ITEMS;
}
public static function fromRawData(array $query): self

View file

@ -9,6 +9,7 @@ use Laminas\Stdlib\AbstractOptions;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ROUND_BLOCK_SIZE;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE;
class QrCodeOptions extends AbstractOptions
@ -17,6 +18,7 @@ class QrCodeOptions extends AbstractOptions
private int $margin = DEFAULT_QR_CODE_MARGIN;
private string $format = DEFAULT_QR_CODE_FORMAT;
private string $errorCorrection = DEFAULT_QR_CODE_ERROR_CORRECTION;
private bool $roundBlockSize = DEFAULT_QR_CODE_ROUND_BLOCK_SIZE;
public function size(): int
{
@ -57,4 +59,14 @@ class QrCodeOptions extends AbstractOptions
{
$this->errorCorrection = $errorCorrection;
}
public function roundBlockSize(): bool
{
return $this->roundBlockSize;
}
protected function setRoundBlockSize(bool $roundBlockSize): void
{
$this->roundBlockSize = $roundBlockSize;
}
}

View file

@ -226,8 +226,6 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
'id' => 'visit_location_id',
]);
$query = $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm);
return $query->getResult();
return $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm)->getResult();
}
}

View file

@ -33,7 +33,7 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface
if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) {
throw Exception\DeleteShortUrlException::fromVisitsThreshold(
$this->deleteShortUrlsOptions->getVisitsThreshold(),
$shortUrl->getShortCode(),
$identifier,
);
}

View file

@ -17,6 +17,6 @@ class BelongsToApiKeyInlined implements Filter
public function getFilter(QueryBuilder $qb, string $dqlAlias): string
{
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
return (string) $qb->expr()->eq('s.authorApiKey', '\'' . $this->apiKey->getId() . '\'');
return $qb->expr()->eq('s.authorApiKey', '\'' . $this->apiKey->getId() . '\'')->__toString();
}
}

View file

@ -16,6 +16,6 @@ class BelongsToDomainInlined implements Filter
public function getFilter(QueryBuilder $qb, string $context): string
{
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
return (string) $qb->expr()->eq('s.domain', '\'' . $this->domainId . '\'');
return $qb->expr()->eq('s.domain', '\'' . $this->domainId . '\'')->__toString();
}
}

View file

@ -10,20 +10,13 @@ use function sprintf;
final class TagRenaming
{
private string $oldName;
private string $newName;
private function __construct()
private function __construct(private string $oldName, private string $newName)
{
}
public static function fromNames(string $oldName, string $newName): self
{
$o = new self();
$o->oldName = $oldName;
$o->newName = $newName;
return $o;
return new self($oldName, $newName);
}
public static function fromArray(array $payload): self

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Validation;
use Laminas\InputFilter\InputFilter;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Validation;
class ShortUrlsParamsInputFilter extends InputFilter
@ -32,7 +33,7 @@ class ShortUrlsParamsInputFilter extends InputFilter
$this->add($this->createInput(self::SEARCH_TERM, false));
$this->add($this->createNumericInput(self::PAGE, false));
$this->add($this->createNumericInput(self::ITEMS_PER_PAGE, false, -1));
$this->add($this->createNumericInput(self::ITEMS_PER_PAGE, false, Paginator::ALL_ITEMS));
$this->add($this->createTagsInput(self::TAGS, false));
}

View file

@ -5,9 +5,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit;
use Fig\Http\Message\RequestMethodInterface;
use InvalidArgumentException;
use IPLib\Address\IPv4;
use IPLib\Factory;
use IPLib\Range\RangeInterface;
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
use PhpIP\IP;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
@ -73,9 +74,8 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
return false;
}
try {
$ip = IP::create($remoteAddr);
} catch (InvalidArgumentException) {
$ip = IPv4::parseString($remoteAddr);
if ($ip === null) {
return false;
}
@ -83,24 +83,23 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
$disableTrackingFrom = $this->trackingOptions->disableTrackingFrom();
return some($disableTrackingFrom, function (string $value) use ($ip, $remoteAddrParts): bool {
try {
return match (true) {
str_contains($value, '*') => $ip->matches($this->parseValueWithWildcards($value, $remoteAddrParts)),
str_contains($value, '/') => $ip->isIn($value),
default => $ip->matches($value),
};
} catch (InvalidArgumentException) {
return false;
}
$range = match (true) {
str_contains($value, '*') => $this->parseValueWithWildcards($value, $remoteAddrParts),
default => Factory::parseRangeString($value),
};
return $range !== null && $ip->matches($range);
});
}
private function parseValueWithWildcards(string $value, array $remoteAddrParts): string
private function parseValueWithWildcards(string $value, array $remoteAddrParts): ?RangeInterface
{
// Replace wildcard parts with the corresponding ones from the remote address
return implode('.', map(
explode('.', $value),
fn (string $part, int $index) => $part === '*' ? $remoteAddrParts[$index] : $part,
));
return Factory::parseRangeString(
implode('.', map(
explode('.', $value),
fn (string $part, int $index) => $part === '*' ? $remoteAddrParts[$index] : $part,
)),
);
}
}

View file

@ -50,27 +50,7 @@ class DomainRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
self::assertEquals(
[$barDomain, $bazDomain, $detachedWithRedirects, $fooDomain],
$this->repo->findDomainsWithout(null),
);
self::assertEquals(
[$barDomain, $bazDomain, $detachedWithRedirects],
$this->repo->findDomainsWithout('foo.com'),
);
self::assertEquals(
[$bazDomain, $detachedWithRedirects, $fooDomain],
$this->repo->findDomainsWithout('bar.com'),
);
self::assertEquals(
[$barDomain, $detachedWithRedirects, $fooDomain],
$this->repo->findDomainsWithout('baz.com'),
);
self::assertEquals(
[$barDomain, $bazDomain, $fooDomain],
$this->repo->findDomainsWithout('detached-with-redirects.com'),
);
self::assertEquals([$barDomain, $bazDomain, $detachedWithRedirects, $fooDomain], $this->repo->findDomains());
self::assertEquals($barDomain, $this->repo->findOneByAuthority('bar.com'));
self::assertEquals($detachedWithRedirects, $this->repo->findOneByAuthority('detached-with-redirects.com'));
self::assertNull($this->repo->findOneByAuthority('does-not-exist.com'));
@ -121,14 +101,11 @@ class DomainRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
self::assertEquals([$fooDomain], $this->repo->findDomainsWithout(null, $fooDomainApiKey));
self::assertEquals([$barDomain], $this->repo->findDomainsWithout(null, $barDomainApiKey));
self::assertEquals(
[$detachedWithRedirects],
$this->repo->findDomainsWithout(null, $detachedWithRedirectsApiKey),
);
self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout(null, $authorApiKey));
self::assertEquals([], $this->repo->findDomainsWithout(null, $authorAndDomainApiKey));
self::assertEquals([$fooDomain], $this->repo->findDomains($fooDomainApiKey));
self::assertEquals([$barDomain], $this->repo->findDomains($barDomainApiKey));
self::assertEquals([$detachedWithRedirects], $this->repo->findDomains($detachedWithRedirectsApiKey));
self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomains($authorApiKey));
self::assertEquals([], $this->repo->findDomains($authorAndDomainApiKey));
self::assertEquals($fooDomain, $this->repo->findOneByAuthority('foo.com', $authorApiKey));
self::assertNull($this->repo->findOneByAuthority('bar.com', $authorApiKey));

View file

@ -25,11 +25,16 @@ use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use function getimagesizefromstring;
use function imagecolorat;
use function imagecreatefromstring;
class QrCodeActionTest extends TestCase
{
use ProphecyTrait;
private const WHITE = 0xFFFFFF;
private const BLACK = 0x0;
private QrCodeAction $action;
private ObjectProphecy $urlResolver;
private QrCodeOptions $options;
@ -135,7 +140,7 @@ class QrCodeActionTest extends TestCase
$delegate = $this->prophesize(RequestHandlerInterface::class);
$resp = $this->action->process($req->withAttribute('shortCode', $code), $delegate->reveal());
[$size] = getimagesizefromstring((string) $resp->getBody());
[$size] = getimagesizefromstring($resp->getBody()->__toString());
self::assertEquals($expectedSize, $size);
}
@ -199,4 +204,41 @@ class QrCodeActionTest extends TestCase
538,
];
}
/**
* @test
* @dataProvider provideRoundBlockSize
*/
public function imageCanRemoveExtraMarginWhenBlockRoundIsDisabled(
array $defaults,
?string $roundBlockSize,
int $expectedColor,
): void {
$this->options->setFromArray($defaults);
$code = 'abc123';
$req = ServerRequestFactory::fromGlobals()
->withQueryParams(['size' => 250, 'roundBlockSize' => $roundBlockSize])
->withAttribute('shortCode', $code);
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn(
ShortUrl::withLongUrl('https://shlink.io'),
);
$delegate = $this->prophesize(RequestHandlerInterface::class);
$resp = $this->action->process($req, $delegate->reveal());
$image = imagecreatefromstring($resp->getBody()->__toString());
$color = imagecolorat($image, 1, 1);
self::assertEquals($color, $expectedColor);
}
public function provideRoundBlockSize(): iterable
{
yield 'no round block param' => [[], null, self::WHITE];
yield 'no round block param, but disabled by default' => [['round_block_size' => false], null, self::BLACK];
yield 'round block: "true"' => [[], 'true', self::WHITE];
yield 'round block: "true", but disabled by default' => [['round_block_size' => false], 'true', self::WHITE];
yield 'round block: "false"' => [[], 'false', self::BLACK];
yield 'round block: "false", but enabled by default' => [['round_block_size' => true], 'false', self::BLACK];
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Config;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Config\EmptyNotFoundRedirectConfig;
class EmptyNotFoundRedirectConfigTest extends TestCase
{
private EmptyNotFoundRedirectConfig $redirectsConfig;
protected function setUp(): void
{
$this->redirectsConfig = new EmptyNotFoundRedirectConfig();
}
/** @test */
public function allMethodsReturnHardcodedValues(): void
{
self::assertNull($this->redirectsConfig->invalidShortUrlRedirect());
self::assertFalse($this->redirectsConfig->hasInvalidShortUrlRedirect());
self::assertNull($this->redirectsConfig->regular404Redirect());
self::assertFalse($this->redirectsConfig->hasRegular404Redirect());
self::assertNull($this->redirectsConfig->baseUrlRedirect());
self::assertFalse($this->redirectsConfig->hasBaseUrlRedirect());
}
}

View file

@ -29,7 +29,7 @@ class SimplifiedConfigParserTest extends TestCase
'entity_manager' => [
'connection' => [
'driver' => 'mysql',
'host' => 'shlink_db',
'host' => 'shlink_db_mysql',
'port' => '3306',
],
],
@ -78,7 +78,7 @@ class SimplifiedConfigParserTest extends TestCase
'entity_manager' => [
'connection' => [
'driver' => 'mysql',
'host' => 'shlink_db',
'host' => 'shlink_db_mysql',
'dbname' => 'shlink',
'user' => 'foo',
'password' => 'bar',

View file

@ -9,14 +9,13 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Config\EmptyNotFoundRedirectConfig;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use Shlinkio\Shlink\Core\Exception\InvalidDomainException;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -31,7 +30,7 @@ class DomainServiceTest extends TestCase
public function setUp(): void
{
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->domainService = new DomainService($this->em->reveal(), 'default.com', new NotFoundRedirectOptions());
$this->domainService = new DomainService($this->em->reveal(), 'default.com');
}
/**
@ -42,7 +41,7 @@ class DomainServiceTest extends TestCase
{
$repo = $this->prophesize(DomainRepositoryInterface::class);
$getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal());
$findDomains = $repo->findDomainsWithout('default.com', $apiKey)->willReturn($domains);
$findDomains = $repo->findDomains($apiKey)->willReturn($domains);
$result = $this->domainService->listDomains($apiKey);
@ -53,7 +52,7 @@ class DomainServiceTest extends TestCase
public function provideExcludedDomains(): iterable
{
$default = DomainItem::forDefaultDomain('default.com', new NotFoundRedirectOptions());
$default = DomainItem::forDefaultDomain('default.com', new EmptyNotFoundRedirectConfig());
$adminApiKey = ApiKey::create();
$domainSpecificApiKey = ApiKey::fromMeta(
ApiKeyMeta::withRoles(RoleDefinition::forDomain(Domain::withAuthority('')->setId('123'))),
@ -62,15 +61,15 @@ class DomainServiceTest extends TestCase
yield 'empty list without API key' => [[], [$default], null];
yield 'one item without API key' => [
[Domain::withAuthority('bar.com')],
[$default, DomainItem::forExistingDomain(Domain::withAuthority('bar.com'))],
[$default, DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com'))],
null,
];
yield 'multiple items without API key' => [
[Domain::withAuthority('foo.com'), Domain::withAuthority('bar.com')],
[
$default,
DomainItem::forExistingDomain(Domain::withAuthority('foo.com')),
DomainItem::forExistingDomain(Domain::withAuthority('bar.com')),
DomainItem::forNonDefaultDomain(Domain::withAuthority('foo.com')),
DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com')),
],
null,
];
@ -78,15 +77,15 @@ class DomainServiceTest extends TestCase
yield 'empty list with admin API key' => [[], [$default], $adminApiKey];
yield 'one item with admin API key' => [
[Domain::withAuthority('bar.com')],
[$default, DomainItem::forExistingDomain(Domain::withAuthority('bar.com'))],
[$default, DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com'))],
$adminApiKey,
];
yield 'multiple items with admin API key' => [
[Domain::withAuthority('foo.com'), Domain::withAuthority('bar.com')],
[
$default,
DomainItem::forExistingDomain(Domain::withAuthority('foo.com')),
DomainItem::forExistingDomain(Domain::withAuthority('bar.com')),
DomainItem::forNonDefaultDomain(Domain::withAuthority('foo.com')),
DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com')),
],
$adminApiKey,
];
@ -94,14 +93,14 @@ class DomainServiceTest extends TestCase
yield 'empty list with domain-specific API key' => [[], [], $domainSpecificApiKey];
yield 'one item with domain-specific API key' => [
[Domain::withAuthority('bar.com')],
[DomainItem::forExistingDomain(Domain::withAuthority('bar.com'))],
[DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com'))],
$domainSpecificApiKey,
];
yield 'multiple items with domain-specific API key' => [
[Domain::withAuthority('foo.com'), Domain::withAuthority('bar.com')],
[
DomainItem::forExistingDomain(Domain::withAuthority('foo.com')),
DomainItem::forExistingDomain(Domain::withAuthority('bar.com')),
DomainItem::forNonDefaultDomain(Domain::withAuthority('foo.com')),
DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com')),
],
$domainSpecificApiKey,
];
@ -214,15 +213,4 @@ class DomainServiceTest extends TestCase
yield 'domain not found and author API key' => [null, $authorApiKey];
yield 'domain found and author API key' => [$domain, $authorApiKey];
}
/** @test */
public function anExceptionIsThrowsWhenTryingToEditRedirectsForDefaultDomain(): void
{
$this->expectException(InvalidDomainException::class);
$this->expectExceptionMessage(
'You cannot configure default domain\'s redirects this way. Use the configuration or env vars.',
);
$this->domainService->configureNotFoundRedirects('default.com', NotFoundRedirects::withoutRedirects());
}
}

View file

@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
use Doctrine\ORM\EntityManagerInterface;
use DomainException;
use Exception;
use PhpAmqpLib\Channel\AMQPChannel;
use PhpAmqpLib\Connection\AMQPStreamConnection;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
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\NotifyVisitToRabbitMq;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Transformer\OrphanVisitDataTransformer;
use Throwable;
use function count;
use function Functional\contains;
class NotifyVisitToRabbitMqTest extends TestCase
{
use ProphecyTrait;
private NotifyVisitToRabbitMq $listener;
private ObjectProphecy $connection;
private ObjectProphecy $em;
private ObjectProphecy $logger;
private ObjectProphecy $orphanVisitTransformer;
private ObjectProphecy $channel;
protected function setUp(): void
{
$this->channel = $this->prophesize(AMQPChannel::class);
$this->connection = $this->prophesize(AMQPStreamConnection::class);
$this->connection->isConnected()->willReturn(false);
$this->connection->channel()->willReturn($this->channel->reveal());
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->logger = $this->prophesize(LoggerInterface::class);
$this->listener = new NotifyVisitToRabbitMq(
$this->connection->reveal(),
$this->em->reveal(),
$this->logger->reveal(),
new OrphanVisitDataTransformer(),
true,
);
}
/** @test */
public function doesNothingWhenTheFeatureIsNotEnabled(): void
{
$listener = new NotifyVisitToRabbitMq(
$this->connection->reveal(),
$this->em->reveal(),
$this->logger->reveal(),
new OrphanVisitDataTransformer(),
false,
);
$listener(new VisitLocated('123'));
$this->em->find(Argument::cetera())->shouldNotHaveBeenCalled();
$this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled();
$this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled();
$this->connection->isConnected()->shouldNotHaveBeenCalled();
$this->connection->close()->shouldNotHaveBeenCalled();
}
/** @test */
public function notificationsAreNotSentWhenVisitCannotBeFound(): void
{
$visitId = '123';
$findVisit = $this->em->find(Visit::class, $visitId)->willReturn(null);
$logWarning = $this->logger->warning(
'Tried to notify RabbitMQ for visit with id "{visitId}", but it does not exist.',
['visitId' => $visitId],
);
($this->listener)(new VisitLocated($visitId));
$findVisit->shouldHaveBeenCalledOnce();
$logWarning->shouldHaveBeenCalledOnce();
$this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled();
$this->connection->isConnected()->shouldNotHaveBeenCalled();
$this->connection->close()->shouldNotHaveBeenCalled();
}
/**
* @test
* @dataProvider provideVisits
*/
public function expectedChannelsAreNotifiedBasedOnTheVisitType(Visit $visit, array $expectedChannels): void
{
$visitId = '123';
$findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit);
$argumentWithExpectedChannel = Argument::that(fn (string $channel) => contains($expectedChannels, $channel));
($this->listener)(new VisitLocated($visitId));
$findVisit->shouldHaveBeenCalledOnce();
$this->channel->exchange_declare($argumentWithExpectedChannel, Argument::cetera())->shouldHaveBeenCalledTimes(
count($expectedChannels),
);
$this->channel->queue_declare($argumentWithExpectedChannel, Argument::cetera())->shouldHaveBeenCalledTimes(
count($expectedChannels),
);
$this->channel->queue_bind(
$argumentWithExpectedChannel,
$argumentWithExpectedChannel,
)->shouldHaveBeenCalledTimes(count($expectedChannels));
$this->channel->basic_publish(Argument::any(), $argumentWithExpectedChannel)->shouldHaveBeenCalledTimes(
count($expectedChannels),
);
$this->channel->close()->shouldHaveBeenCalledOnce();
$this->connection->reconnect()->shouldHaveBeenCalledOnce();
$this->connection->close()->shouldHaveBeenCalledOnce();
$this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled();
}
public function provideVisits(): iterable
{
$visitor = Visitor::emptyInstance();
yield 'orphan visit' => [Visit::forBasePath($visitor), ['https://shlink.io/new-orphan-visit']];
yield 'non-orphan visit' => [
Visit::forValidShortUrl(
ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
'longUrl' => 'foo',
'customSlug' => 'bar',
])),
$visitor,
),
['https://shlink.io/new-visit', 'https://shlink.io/new-visit/bar'],
];
}
/**
* @test
* @dataProvider provideExceptions
*/
public function printsDebugMessageInCaseOfError(Throwable $e): void
{
$visitId = '123';
$findVisit = $this->em->find(Visit::class, $visitId)->willReturn(Visit::forBasePath(Visitor::emptyInstance()));
$channel = $this->connection->channel()->willThrow($e);
($this->listener)(new VisitLocated($visitId));
$this->logger->debug(
'Error while trying to notify RabbitMQ with new visit. {e}',
['e' => $e],
)->shouldHaveBeenCalledOnce();
$this->connection->close()->shouldHaveBeenCalledOnce();
$this->connection->reconnect()->shouldHaveBeenCalledOnce();
$findVisit->shouldHaveBeenCalledOnce();
$channel->shouldHaveBeenCalledOnce();
$this->channel->close()->shouldNotHaveBeenCalled();
}
public function provideExceptions(): iterable
{
yield [new RuntimeException('RuntimeException Error')];
yield [new Exception('Exception Error')];
yield [new DomainException('DomainException Error')];
}
}

View file

@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use function Functional\map;
use function range;
@ -23,7 +24,10 @@ class DeleteShortUrlExceptionTest extends TestCase
string $shortCode,
string $expectedMessage,
): void {
$e = DeleteShortUrlException::fromVisitsThreshold($threshold, $shortCode);
$e = DeleteShortUrlException::fromVisitsThreshold(
$threshold,
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
);
self::assertEquals($threshold, $e->getVisitsThreshold());
self::assertEquals($expectedMessage, $e->getMessage());
@ -41,10 +45,29 @@ class DeleteShortUrlExceptionTest extends TestCase
{
return map(range(5, 50, 5), function (int $number) {
return [$number, $shortCode = generateRandomShortCode(6), sprintf(
'Impossible to delete short URL with short code "%s" since it has more than "%s" visits.',
'Impossible to delete short URL with short code "%s", since it has more than "%s" visits.',
$shortCode,
$number,
)];
});
}
/** @test */
public function domainIsPartOfAdditionalWhenProvidedInIdentifier(): void
{
$e = DeleteShortUrlException::fromVisitsThreshold(
10,
ShortUrlIdentifier::fromShortCodeAndDomain('abc123', 'doma.in'),
);
$expectedMessage = 'Impossible to delete short URL with short code "abc123" for domain "doma.in", since it '
. 'has more than "10" visits.';
self::assertEquals([
'shortCode' => 'abc123',
'domain' => 'doma.in',
'threshold' => 10,
], $e->getAdditionalData());
self::assertEquals($expectedMessage, $e->getMessage());
self::assertEquals($expectedMessage, $e->getDetail());
}
}

View file

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\InvalidDomainException;
class InvalidDomainExceptionTest extends TestCase
{
/** @test */
public function configuresTheExceptionAsExpected(): void
{
$e = InvalidDomainException::forDefaultDomainRedirects();
$expected = 'You cannot configure default domain\'s redirects this way. Use the configuration or env vars.';
self::assertEquals($expected, $e->getMessage());
self::assertEquals($expected, $e->getDetail());
self::assertEquals('Invalid domain', $e->getTitle());
self::assertEquals('INVALID_DOMAIN', $e->getType());
self::assertEquals(403, $e->getStatus());
}
}

View file

@ -51,7 +51,7 @@ class DeleteShortUrlServiceTest extends TestCase
$this->expectException(DeleteShortUrlException::class);
$this->expectExceptionMessage(sprintf(
'Impossible to delete short URL with short code "%s" since it has more than "5" visits.',
'Impossible to delete short URL with short code "%s", since it has more than "5" visits.',
$this->shortCode,
));

View file

@ -9,7 +9,7 @@ use Laminas\ServiceManager\Factory\InvokableFactory;
use Mezzio\Router\Middleware\ImplicitOptionsMiddleware;
use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Options;
use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Core\Tag\TagService;
@ -55,7 +55,7 @@ return [
ConfigAbstractFactory::class => [
ApiKeyService::class => ['em'],
Action\HealthAction::class => ['em', AppOptions::class],
Action\HealthAction::class => ['em', Options\AppOptions::class],
Action\MercureInfoAction::class => [LcobucciJwtProvider::class, 'config.mercure'],
Action\ShortUrl\CreateShortUrlAction::class => [Service\UrlShortener::class, ShortUrlDataTransformer::class],
Action\ShortUrl\SingleStepCreateShortUrlAction::class => [
@ -81,7 +81,7 @@ return [
Action\Tag\DeleteTagsAction::class => [TagService::class],
Action\Tag\CreateTagsAction::class => [TagService::class],
Action\Tag\UpdateTagAction::class => [TagService::class],
Action\Domain\ListDomainsAction::class => [DomainService::class],
Action\Domain\ListDomainsAction::class => [DomainService::class, Options\NotFoundRedirectOptions::class],
Action\Domain\DomainRedirectsAction::class => [DomainService::class],
Middleware\CrossDomainMiddleware::class => ['config.cors'],

View file

@ -7,7 +7,9 @@ namespace Shlinkio\Shlink\Rest\Action\Domain;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
@ -16,7 +18,7 @@ class ListDomainsAction extends AbstractRestAction
protected const ROUTE_PATH = '/domains';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
public function __construct(private DomainServiceInterface $domainService)
public function __construct(private DomainServiceInterface $domainService, private NotFoundRedirectOptions $options)
{
}
@ -28,6 +30,7 @@ class ListDomainsAction extends AbstractRestAction
return new JsonResponse([
'domains' => [
'data' => $domainItems,
'defaultRedirects' => NotFoundRedirects::fromConfig($this->options),
],
]);
}

Some files were not shown because too many files have changed in this diff Show more