Merge pull request #1549 from shlinkio/develop

Release 3.3.0
This commit is contained in:
Alejandro Celaya 2022-09-18 19:36:55 +02:00 committed by GitHub
commit c8acb5de68
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
243 changed files with 2940 additions and 1244 deletions

View file

@ -1,3 +1,4 @@
bin/rr
config/autoload/*local*
data/infra
data/cache/*
@ -22,4 +23,4 @@ infection*
**/test*
build*
**/.*
bin/helper
!config/roadrunner/.rr.yml

47
.github/actions/ci-setup/action.yml vendored Normal file
View file

@ -0,0 +1,47 @@
name: CI setup
description: 'Sets up the environment to run CI actions for Shlink'
inputs:
install-deps:
description: 'Tells if dependencies should be installed with composer. Default value is "yes"'
required: true
default: 'yes'
php-version:
description: 'The PHP version to be setup'
required: true
php-extensions:
description: 'The PHP extensions to install'
required: false
default: ''
extensions-cache-key:
description: 'The key used to cache PHP extensions. If empty value is provided, extension caching is disabled'
required: true
runs:
using: composite
steps:
- name: Setup cache environment
id: extcache
uses: shivammathur/cache-extensions@v1
with:
php-version: ${{ inputs.php-version }}
extensions: ${{ inputs.php-extensions }}
key: ${{ inputs.extensions-cache-key }}
- name: Cache extensions
uses: actions/cache@v2
with:
path: ${{ steps.extcache.outputs.dir }}
key: ${{ steps.extcache.outputs.key }}
restore-keys: ${{ steps.extcache.outputs.key }}
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ inputs.php-version }}
tools: composer
extensions: ${{ inputs.php-extensions }}
coverage: pcov
ini-values: pcov.directory=module
- name: Install dependencies
if: ${{ inputs.install-deps == 'yes' }}
run: composer install --no-interaction --prefer-dist
shell: bash

44
.github/workflows/ci-db-tests.yml vendored Normal file
View file

@ -0,0 +1,44 @@
name: Database tests
on:
workflow_call:
inputs:
platform:
type: string
required: true
description: One of sqlite:ci, mysql, maria, postgres or ms
jobs:
db-tests:
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: [ '8.1' ]
env:
LC_ALL: C
steps:
- uses: actions/checkout@v3
- name: Install MSSQL ODBC
if: ${{ inputs.platform == 'ms' }}
run: sudo ./data/infra/ci/install-ms-odbc.sh
- name: Start database server
if: ${{ inputs.platform != 'sqlite:ci' }}
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_${{ inputs.platform }}
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-4.11.1, pdo_sqlsrv-5.10.1
extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }}
- name: Create test database
if: ${{ inputs.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:${{ inputs.platform }}
- name: Upload code coverage
uses: actions/upload-artifact@v3
if: ${{ matrix.php-version == '8.1' && inputs.platform == 'sqlite:ci' }}
with:
name: coverage-db
path: |
build/coverage-db
build/coverage-db.cov

45
.github/workflows/ci-mutation-tests.yml vendored Normal file
View file

@ -0,0 +1,45 @@
name: Mutation tests
on:
workflow_call:
inputs:
test-group:
type: string
required: true
description: One of unit, db, api or cli
jobs:
mutation-tests:
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: [ '8.1' ]
steps:
- uses: actions/checkout@v3
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-4.11.1
extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
- uses: actions/download-artifact@v3
with:
name: coverage-${{ inputs.test-group }}
path: build
- name: Resolve infection args
id: infection_args
run: echo "::set-output name=args::--logger-github=false"
# TODO Try to filter mutation tests to improve execution times. Investigate why --git-diff-lines --git-diff-base=develop does not work
# run: |
# BRANCH="${GITHUB_REF#refs/heads/}" |
# if [[ $BRANCH == 'main' || $BRANCH == 'develop' ]]; then
# echo "::set-output name=args::--logger-github=false"
# else
# echo "::set-output name=args::--logger-github=false --git-diff-lines --git-diff-base=develop"
# fi;
shell: bash
- if: ${{ inputs.test-group == 'unit' }}
run: composer infect:ci:unit -- ${{ steps.infection_args.outputs.args }}
env:
INFECTION_BADGE_API_KEY: ${{ secrets.INFECTION_BADGE_API_KEY }}
- if: ${{ inputs.test-group != 'unit' }}
run: composer infect:ci:${{ inputs.test-group }} -- ${{ steps.infection_args.outputs.args }}

37
.github/workflows/ci-tests.yml vendored Normal file
View file

@ -0,0 +1,37 @@
name: Tests
on:
workflow_call:
inputs:
test-group:
type: string
required: true
description: One of unit, api or cli
jobs:
tests:
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.1']
steps:
- uses: actions/checkout@v3
- name: Start postgres database server
if: ${{ inputs.test-group == 'api' }}
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
- name: Start maria database server
if: ${{ inputs.test-group == 'cli' }}
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_maria
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-4.11.1
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
- run: composer test:${{ inputs.test-group }}:ci
- uses: actions/upload-artifact@v3
if: ${{ matrix.php-version == '8.1' }}
with:
name: coverage-${{ inputs.test-group }}
path: |
build/coverage-${{ inputs.test-group }}
build/coverage-${{ inputs.test-group }}.cov

View file

@ -16,146 +16,124 @@ jobs:
php-version: ['8.1']
command: ['cs', 'stan', 'swagger:validate']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use PHP
uses: shivammathur/setup-php@v2
- uses: actions/checkout@v3
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: openswoole-4.11.1
coverage: none
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
php-extensions: openswoole-4.11.1
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ matrix.command }}
- run: composer ${{ matrix.command }}
tests:
unit-tests:
uses: './.github/workflows/ci-tests.yml'
with:
test-group: unit
cli-tests:
uses: './.github/workflows/ci-tests.yml'
with:
test-group: cli
openswoole-api-tests:
uses: './.github/workflows/ci-tests.yml'
with:
test-group: api
roadrunner-api-tests:
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.1']
test-group: ['unit', 'api']
php-version: [ '8.1' ]
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
- uses: actions/checkout@v3
- run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: openswoole-4.11.1
coverage: pcov
ini-values: pcov.directory=module
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- run: composer test:${{ matrix.test-group }}:ci
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '8.1' }}
with:
name: coverage-${{ matrix.test-group }}
path: |
build/coverage-${{ matrix.test-group }}
build/coverage-${{ matrix.test-group }}.cov
- run: composer install --no-interaction --prefer-dist
- run: ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr
- run: composer test:api:rr
db-tests:
runs-on: ubuntu-22.04
strategy:
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: openswoole-4.11.1, pdo_sqlsrv-5.10.1
coverage: pcov
ini-values: pcov.directory=module
- name: Install dependencies
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.1' && matrix.platform == 'sqlite:ci' }}
with:
name: coverage-db
path: |
build/coverage-db
build/coverage-db.cov
sqlite-db-tests:
uses: './.github/workflows/ci-db-tests.yml'
with:
platform: 'sqlite:ci'
mutation-tests:
mysql-db-tests:
uses: './.github/workflows/ci-db-tests.yml'
with:
platform: 'mysql'
maria-db-tests:
uses: './.github/workflows/ci-db-tests.yml'
with:
platform: 'maria'
postgres-db-tests:
uses: './.github/workflows/ci-db-tests.yml'
with:
platform: 'postgres'
ms-db-tests:
uses: './.github/workflows/ci-db-tests.yml'
with:
platform: 'ms'
unit-mutation-tests:
needs:
- tests
- db-tests
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.1']
test-group: ['unit', 'db', 'api']
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: openswoole-4.11.1
coverage: pcov
ini-values: pcov.directory=module
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- uses: actions/download-artifact@v2
with:
path: build
- 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 }}
- unit-tests
uses: './.github/workflows/ci-mutation-tests.yml'
with:
test-group: unit
db-mutation-tests:
needs:
- sqlite-db-tests
uses: './.github/workflows/ci-mutation-tests.yml'
with:
test-group: db
api-mutation-tests:
needs:
- openswoole-api-tests
uses: './.github/workflows/ci-mutation-tests.yml'
with:
test-group: api
cli-mutation-tests:
needs:
- cli-tests
uses: './.github/workflows/ci-mutation-tests.yml'
with:
test-group: cli
upload-coverage:
needs:
- tests
- db-tests
- unit-tests
- openswoole-api-tests
- cli-tests
- sqlite-db-tests
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.1']
steps:
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
coverage: pcov
ini-values: pcov.directory=module
- uses: actions/download-artifact@v2
- uses: actions/download-artifact@v3
with:
path: build
- run: mv build/coverage-unit/coverage-unit.cov build/coverage-unit.cov
- run: mv build/coverage-db/coverage-db.cov build/coverage-db.cov
- run: mv build/coverage-api/coverage-api.cov build/coverage-api.cov
- run: mv build/coverage-cli/coverage-cli.cov build/coverage-cli.cov
- run: wget https://phar.phpunit.de/phpcov-8.2.1.phar
- run: php phpcov-8.2.1.phar merge build --clover build/clover.xml
- name: Publish coverage
@ -165,7 +143,10 @@ jobs:
delete-artifacts:
needs:
- mutation-tests
- unit-mutation-tests
- db-mutation-tests
- api-mutation-tests
- cli-mutation-tests
- upload-coverage
runs-on: ubuntu-22.04
steps:
@ -175,12 +156,13 @@ jobs:
coverage-unit
coverage-db
coverage-api
coverage-cli
build-docker-image:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: 100
- uses: marceloprado/has-changed-path@v1

View file

@ -1,4 +1,4 @@
name: Build docker image
name: Build and publish docker image
on:
push:
@ -8,21 +8,20 @@ on:
- 'v*'
jobs:
build:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
with:
version: latest
- name: Login to docker hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build the image
run: bash ./docker/build
build-openswool:
uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main
secrets: inherit
with:
image-name: shlinkio/shlink
version-arg-name: SHLINK_VERSION
build-roadrunner:
uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main
secrets: inherit
with:
image-name: shlinkio/shlink
version-arg-name: SHLINK_VERSION
platforms: 'linux/arm64/v8,linux/amd64'
tags-suffix: roadrunner
extra-build-args: |
SHLINK_RUNTIME=rr

View file

@ -13,19 +13,18 @@ jobs:
php-version: ['8.1']
swoole: ['yes', 'no']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use PHP
uses: shivammathur/setup-php@v2
- uses: actions/checkout@v3
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: openswoole-4.11.1
php-extensions: openswoole-4.11.1
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
install-deps: 'no'
- if: ${{ matrix.swoole == 'yes' }}
run: ./build.sh ${GITHUB_REF#refs/tags/v}
- if: ${{ matrix.swoole == 'no' }}
run: ./build.sh ${GITHUB_REF#refs/tags/v} --no-swoole
- uses: actions/upload-artifact@v2
- uses: actions/upload-artifact@v3
with:
name: dist-files-${{ matrix.php-version }}-${{ matrix.swoole }}
path: build
@ -34,9 +33,8 @@ jobs:
needs: ['build']
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v2
- uses: actions/download-artifact@v2
- uses: actions/checkout@v3
- uses: actions/download-artifact@v3
with:
path: build
- name: Publish release with assets

View file

@ -12,20 +12,16 @@ jobs:
matrix:
php-version: ['8.1']
steps:
- name: Checkout code
uses: actions/checkout@v2
- uses: actions/checkout@v3
- 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
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: openswoole-4.11.1
coverage: none
- run: composer install --no-interaction --prefer-dist
php-extensions: openswoole-4.11.1
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
- run: composer swagger:inline
- run: mkdir ${{ steps.determine_version.outputs.version }}
- run: mv docs/swagger/swagger-inlined.json ${{ steps.determine_version.outputs.version }}/open-api-spec.json

3
.gitignore vendored
View file

@ -1,4 +1,7 @@
.idea
bin/.rr.*
bin/rr
config/roadrunner/.pid
build
!docker/build
composer.lock

View file

@ -4,6 +4,56 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [3.3.0] - 2022-09-18
### Added
* [#1221](https://github.com/shlinkio/shlink/issues/1221) Added experimental support to run Shlink with [RoadRunner](https://roadrunner.dev) instead of openswoole.
* [#1531](https://github.com/shlinkio/shlink/issues/1531) and [#1090](https://github.com/shlinkio/shlink/issues/1090) Added support for trailing slashes in short URLs.
* [#1406](https://github.com/shlinkio/shlink/issues/1406) Added new REST API version 3.
When making requests to the REST API with `/rest/v3/...` and an error occurs, all error types will be different, with the next correlation:
* `INVALID_ARGUMENT` -> `https://shlink.io/api/error/invalid-data`
* `INVALID_SHORT_URL_DELETION` -> `https://shlink.io/api/error/invalid-short-url-deletion`
* `DOMAIN_NOT_FOUND` -> `https://shlink.io/api/error/domain-not-found`
* `FORBIDDEN_OPERATION` -> `https://shlink.io/api/error/forbidden-tag-operation`
* `INVALID_URL` -> `https://shlink.io/api/error/invalid-url`
* `INVALID_SLUG` -> `https://shlink.io/api/error/non-unique-slug`
* `INVALID_SHORTCODE` -> `https://shlink.io/api/error/short-url-not-found`
* `TAG_CONFLICT` -> `https://shlink.io/api/error/tag-conflict`
* `TAG_NOT_FOUND` -> `https://shlink.io/api/error/tag-not-found`
* `MERCURE_NOT_CONFIGURED` -> `https://shlink.io/api/error/mercure-not-configured`
* `INVALID_AUTHORIZATION` -> `https://shlink.io/api/error/missing-authentication`
* `INVALID_API_KEY` -> `https://shlink.io/api/error/invalid-api-key`
If you make a request to the API with v2 or v1, the old error types will be returned, until Shlink 4 is released, when only the new ones will be used.
Non-error responses are not affected.
* [#1513](https://github.com/shlinkio/shlink/issues/1513) Added publishing of the docker image in GHCR.
* [#1114](https://github.com/shlinkio/shlink/issues/1114) Added support to provide an initial API key via `INITIAL_API_KEY` env var, when running Shlink with openswoole or RoadRunner.
Also, the installer tool now allows to generate an initial API key that can be copy-pasted (this tool is run interactively), in case you use php-fpm or you don't want to use env vars.
* [#1528](https://github.com/shlinkio/shlink/issues/1528) Added support to delay when the GeoLite2 DB file is downloaded in docker images, speeding up its startup time.
In order to do it, pass `SKIP_INITIAL_GEOLITE_DOWNLOAD=true` when creating the container.
### Changed
* [#1339](https://github.com/shlinkio/shlink/issues/1339) Added new test suite for CLI E2E tests.
* [#1503](https://github.com/shlinkio/shlink/issues/1503) Drastically improved build time in GitHub Actions, by optimizing parallelization and adding php extensions cache.
* [#1525](https://github.com/shlinkio/shlink/issues/1525) Migrated to custom doctrine CLI entry point.
* [#1492](https://github.com/shlinkio/shlink/issues/1492) Migrated to immutable options objects, mapped with [cuyz/valinor](https://github.com/CuyZ/Valinor).
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [3.2.1] - 2022-08-08
### Added
* *Nothing*

View file

@ -102,7 +102,9 @@ In order to ensure stability and no regressions are introduced while developing
Since the app instance is run on a process different from the one running the tests, when a test fails it might not be obvious why. To help debugging that, the app will dump all its logs inside `data/log/api-tests`, where you will find the `shlink.log` and `access.log` files.
* **CLI tests**: *TBD. Once included, its purpose will be the same as API tests, but running through the command line*
* **CLI tests**: These are E2E tests too, but they test console commands instead of REST endpoints.
They use Maria DB as the database engine, and include the same fixtures as the API tests, that ensure the same data exists at the beginning of the execution.
Depending on the kind of contribution, maybe not all kinds of tests are needed, but the more you provide, the better.
@ -119,9 +121,9 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed,
For example, `test:db:postgres`.
* Run `./indocker composer test:api` to run API E2E tests. For these, the Postgres database engine is used.
* Run `./indocker composer test:cli` to run CLI E2E tests. For these, the Maria DB database engine is used.
* Run `./indocker composer infect:test` to run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/).
* Run `./indocker composer ci` to run all previous commands together. This command is run during the project's continuous integration.
* Run `./indocker composer ci:parallel` to do the same as in previous case, but parallelizing non-conflicting tasks as much as possible.
* Run `./indocker composer ci` to run all previous commands together, parallelizing non-conflicting tasks as much as possible.
## Pull request process
@ -133,7 +135,7 @@ Once everything is clear, to provide a pull request to this project, you should
The base branch should always be `develop`, and the target branch for the pull request should also be `develop`.
Before your branch can be merged, all the checks described in [Running code checks](#running-code-checks) have to be passing. You can verify that manually by running `./indocker composer ci:parallel`, or wait for the build to be run automatically after the pull request is created.
Before your branch can be merged, all the checks described in [Running code checks](#running-code-checks) have to be passing. You can verify that manually by running `./indocker composer ci`, or wait for the build to be run automatically after the pull request is created.
## Architectural Decision Records

View file

@ -2,6 +2,8 @@ FROM php:8.1.9-alpine3.16 as base
ARG SHLINK_VERSION=latest
ENV SHLINK_VERSION ${SHLINK_VERSION}
ARG SHLINK_RUNTIME=openswoole
ENV SHLINK_RUNTIME ${SHLINK_RUNTIME}
ENV OPENSWOOLE_VERSION 4.11.1
ENV PDO_SQLSRV_VERSION 5.10.1
ENV MS_ODBC_SQL_VERSION 17.5.2.2
@ -22,8 +24,10 @@ RUN \
# Install openswoole and sqlsrv driver for x86_64 builds
RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
pecl install openswoole-${OPENSWOOLE_VERSION} && \
docker-php-ext-enable openswoole && \
if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \
pecl install openswoole-${OPENSWOOLE_VERSION} && \
docker-php-ext-enable openswoole ; \
fi; \
if [ $(uname -m) == "x86_64" ]; then \
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
@ -38,7 +42,12 @@ FROM base as builder
COPY . .
COPY --from=composer:2 /usr/bin/composer ./composer.phar
RUN apk add --no-cache git && \
php composer.phar install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction && \
php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction && \
if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \
php composer.phar remove spiral/roadrunner spiral/roadrunner-jobs --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interactionc ; \
elif [ $SHLINK_RUNTIME == 'rr' ]; then \
php composer.phar remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction ; \
fi; \
php composer.phar clear-cache && \
rm -r docker composer.* && \
sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php
@ -49,9 +58,12 @@ FROM base
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
COPY --from=builder /etc/shlink .
RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink
RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink && \
if [ "$SHLINK_RUNTIME" == 'rr' ]; then \
php ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr ; \
fi;
# Expose default openswoole port
# Expose default port
EXPOSE 8080
# Copy config specific for the image

View file

@ -15,7 +15,7 @@ A PHP-based self-hosted URL shortener that can be used to serve shortened URLs u
- [Full documentation](#full-documentation)
- [Docker image](#docker-image)
- [Self hosted](#self-hosted)
- [Self-hosted](#self-hosted)
- [Download](#download)
- [Configure](#configure)
- [Using shlink](#using-shlink)

12
bin/doctrine Executable file
View file

@ -0,0 +1,12 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Tools\Console\ConsoleRunner;
use Doctrine\ORM\Tools\Console\EntityManagerProvider\SingleManagerProvider;
/** @var EntityManager $app */
$em = require __DIR__ . '/../config/entity-manager.php';
ConsoleRunner::run(new SingleManagerProvider($em));

32
bin/roadrunner-worker.php Normal file
View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use Mezzio\Application;
use Psr\Container\ContainerInterface;
use Shlinkio\Shlink\EventDispatcher\RoadRunner\RoadRunnerTaskConsumerToListener;
use Spiral\RoadRunner\Http\PSR7Worker;
use function Shlinkio\Shlink\Config\env;
(static function (): void {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/../config/container.php';
$rrMode = env('RR_MODE');
if ($rrMode === 'http') {
// This was spin-up as a web worker
$app = $container->get(Application::class);
$worker = $container->get(PSR7Worker::class);
while ($req = $worker->waitRequest()) {
try {
$worker->respond($app->handle($req));
} catch (Throwable $e) {
$worker->getWorker()->error((string) $e);
}
}
} else {
$container->get(RoadRunnerTaskConsumerToListener::class)->listenForTasks();
}
})();

View file

@ -1,25 +1,38 @@
#!/usr/bin/env sh
export APP_ENV=test
export DB_DRIVER=postgres
export TEST_ENV=api
export GENERATE_COVERAGE=${GENERATE_COVERAGE:-"no"}
export TEST_RUNTIME="${TEST_RUNTIME:-"openswoole"}"
export DB_DRIVER="${DB_DRIVER:-"postgres"}"
export GENERATE_COVERAGE="${GENERATE_COVERAGE:-"no"}"
# Reset logs
OUTPUT_LOGS=data/log/api-tests/output.log
rm -rf data/log/api-tests
mkdir data/log/api-tests
touch data/log/api-tests/output.log
touch $OUTPUT_LOGS
# Try to stop server just in case it hanged in last execution
vendor/bin/laminas mezzio:swoole:stop
[ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:stop
[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -f
echo 'Starting server...'
vendor/bin/laminas mezzio:swoole:start -d
sleep 2
[ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:start -d
[ "$TEST_RUNTIME" = 'rr' ] && bin/rr serve -p -c=config/roadrunner/.rr.dev.yml \
-o=http.address=0.0.0.0:9999 \
-o=logs.encoding=json \
-o=logs.channels.http.encoding=json \
-o=logs.channels.server.encoding=json \
-o=logs.output="${PWD}/${OUTPUT_LOGS}" \
-o=logs.channels.http.output="${PWD}/${OUTPUT_LOGS}" \
-o=logs.channels.server.output="${PWD}/${OUTPUT_LOGS}" &
sleep 2 # Let's give the server a couple of seconds to start
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always --log-junit=build/coverage-api/junit.xml $*
testsExitCode=$?
vendor/bin/laminas mezzio:swoole:stop
[ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:stop
[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -c config/roadrunner/.rr.dev.yml -o=http.address=0.0.0.0:9999
# Exit this script with the same code as the tests. If tests failed, this script has to fail
exit $testsExitCode

View file

@ -24,6 +24,7 @@ rsync -av * "${builtContent}" \
--exclude=*docker* \
--exclude=Dockerfile \
--include=.htaccess \
--include=config/roadrunner/.rr.yml \
--exclude-from=./.dockerignore
cd "${builtContent}"
@ -36,6 +37,9 @@ ${composerBin} install --no-dev --prefer-dist $composerFlags
if [[ $noSwoole ]]; then
# If generating a dist not for openswoole, uninstall mezzio-swoole
${composerBin} remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev $composerFlags
else
# If generating a dist for openswoole, uninstall RoadRunner
${composerBin} remove spiral/roadrunner spiral/roadrunner-jobs --with-all-dependencies --update-no-dev $composerFlags
fi
# Delete development files

View file

@ -13,6 +13,8 @@
],
"require": {
"php": "^8.1",
"ext-curl": "*",
"ext-gd": "*",
"ext-json": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.1",
@ -43,12 +45,14 @@
"php-middleware/request-id": "^4.1",
"pugx/shortid-php": "^1.0",
"ramsey/uuid": "^4.3",
"shlinkio/shlink-common": "^5.0",
"shlinkio/shlink-config": "^2.0",
"shlinkio/shlink-event-dispatcher": "^2.5",
"shlinkio/shlink-common": "^5.1",
"shlinkio/shlink-config": "^2.1",
"shlinkio/shlink-event-dispatcher": "^2.6",
"shlinkio/shlink-importer": "^4.0",
"shlinkio/shlink-installer": "^8.1",
"shlinkio/shlink-ip-geolocation": "^3.0",
"shlinkio/shlink-installer": "^8.2",
"shlinkio/shlink-ip-geolocation": "^3.1",
"spiral/roadrunner": "^2.11",
"spiral/roadrunner-jobs": "^2.3",
"symfony/console": "^6.1",
"symfony/filesystem": "^6.1",
"symfony/lock": "^6.1",
@ -58,8 +62,8 @@
"require-dev": {
"cebe/php-openapi": "^1.7",
"devster/ubench": "^2.1",
"dms/phpunit-arraysubset-asserts": "^0.3.0",
"infection/infection": "^0.26.5",
"dms/phpunit-arraysubset-asserts": "^0.4.0",
"infection/infection": "^0.26.15",
"openswoole/ide-helper": "~4.11.1",
"phpspec/prophecy-phpunit": "^2.0",
"phpstan/phpstan": "^1.8",
@ -69,7 +73,7 @@
"phpunit/phpunit": "^9.5",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.3.0",
"shlinkio/shlink-test-utils": "^3.1.0",
"shlinkio/shlink-test-utils": "^3.3",
"symfony/var-dumper": "^6.1",
"veewee/composer-run-parallel": "^1.1"
},
@ -87,8 +91,10 @@
"autoload-dev": {
"psr-4": {
"ShlinkioTest\\Shlink\\CLI\\": "module/CLI/test",
"ShlinkioCliTest\\Shlink\\CLI\\": "module/CLI/test-cli",
"ShlinkioTest\\Shlink\\Rest\\": "module/Rest/test",
"ShlinkioApiTest\\Shlink\\Rest\\": "module/Rest/test-api",
"ShlinkioDbTest\\Shlink\\Rest\\": "module/Rest/test-db",
"ShlinkioTest\\Shlink\\Core\\": "module/Core/test",
"ShlinkioDbTest\\Shlink\\Core\\": "module/Core/test-db"
},
@ -98,31 +104,18 @@
},
"scripts": {
"ci": [
"@cs",
"@stan",
"@swagger:validate",
"@test:ci",
"@infect:ci"
],
"ci:parallel": [
"@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"
"@parallel infect:test:api infect:test:cli infect:ci:unit infect:ci:db"
],
"cs": "phpcs",
"cs:fix": "phpcbf",
"stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/config config docker/config data/migrations --level=8",
"test": [
"@test:unit",
"@test:db",
"@test:api"
"@parallel test:unit test:db",
"@parallel test:api test:cli"
],
"test:ci": [
"@test:unit:ci",
"@test:db",
"@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": "@php vendor/bin/phpunit --order-by=random --colors=always --testdox",
"test:unit:ci": "@test:unit --coverage-php=build/coverage-unit.cov --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml",
"test:unit:pretty": "@test:unit --coverage-html build/coverage-unit/coverage-html",
"test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
"test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
@ -132,12 +125,18 @@
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
"test:api": "bin/test/run-api-tests.sh",
"test:api:rr": "TEST_RUNTIME=rr bin/test/run-api-tests.sh",
"test:api:ci": "GENERATE_COVERAGE=yes composer test:api",
"infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --only-covering-test-cases --skip-initial-tests",
"test:api:pretty": "GENERATE_COVERAGE=pretty composer test:api",
"test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml --log-junit=build/coverage-cli/junit.xml",
"test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli",
"test:cli:pretty": "GENERATE_COVERAGE=pretty composer test:cli",
"infect:ci:base": "infection --threads=max --only-covered --only-covering-test-cases --skip-initial-tests",
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=84",
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json",
"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:ci:cli": "@infect:ci:base --coverage=build/coverage-cli --min-msi=80 --configuration=infection-cli.json",
"infect:ci": "@parallel infect:ci:unit infect:ci:db infect:ci:api infect:ci:cli",
"infect:test": [
"@parallel test:unit:ci test:db:sqlite:ci test:api:ci",
"@infect:ci"
@ -150,18 +149,20 @@
"@test:api:ci",
"@infect:ci:api"
],
"infect:test:cli": [
"@test:cli:ci",
"@infect:ci:cli"
],
"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\", \"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</>",
"stan": "<fg=blue;options=bold>Inspects code with phpstan</>",
"test": "<fg=blue;options=bold>Runs all test suites</>",
"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</>",
@ -173,7 +174,11 @@
"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 Microsoft SQL Server database</>",
"test:api": "<fg=blue;options=bold>Runs API test suites</>",
"test:api:ci": "<fg=blue;options=bold>Runs API test suites, and generates code coverage reports</>",
"test:api:ci": "<fg=blue;options=bold>Runs API test suites, and generates code coverage for CI</>",
"test:api:pretty": "<fg=blue;options=bold>Runs API test suites, and generates code coverage in HTML format</>",
"test:cli": "<fg=blue;options=bold>Runs CLI test suites</>",
"test:cli:ci": "<fg=blue;options=bold>Runs CLI test suites, and generates code coverage for CI</>",
"test:cli:pretty": "<fg=blue;options=bold>Runs CLI test suites, and generates code coverage in HTML format</>",
"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</>",

View file

@ -8,8 +8,8 @@ return [
'debug' => false,
// Disabling config cache for cli, ensures it's never used for openswoole and also that console commands don't
// generate a cache file that's then used by non-openswoole web executions
// Disabling config cache for cli, ensures it's never used for openswoole/RoadRunner, and also that console
// commands don't generate a cache file that's then used by php-fpm web executions
ConfigAggregator::ENABLE_CACHE => PHP_SAPI !== 'cli',
];

View file

@ -3,12 +3,22 @@
declare(strict_types=1);
use GuzzleHttp\Client;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Mezzio\Container;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\ServerRequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\UploadedFileFactoryInterface;
use Spiral\RoadRunner\Http\PSR7Worker;
use Spiral\RoadRunner\WorkerInterface;
return [
'dependencies' => [
'factories' => [
PSR7Worker::class => ConfigAbstractFactory::class,
],
'delegators' => [
Mezzio\Application::class => [
Container\ApplicationConfigInjectionDelegator::class,
@ -26,4 +36,13 @@ return [
],
],
ConfigAbstractFactory::class => [
PSR7Worker::class => [
WorkerInterface::class,
ServerRequestFactoryInterface::class,
StreamFactoryInterface::class,
UploadedFileFactoryInterface::class,
],
],
];

View file

@ -6,12 +6,14 @@ use Laminas\Stratigility\Middleware\ErrorHandler;
use Mezzio\ProblemDetails\ProblemDetailsMiddleware;
use Shlinkio\Shlink\Common\Logger;
use function Shlinkio\Shlink\Core\toProblemDetailsType;
return [
'problem-details' => [
'default_types_map' => [
404 => 'NOT_FOUND',
500 => 'INTERNAL_SERVER_ERROR',
404 => toProblemDetailsType('not-found'),
500 => toProblemDetailsType('internal-server-error'),
],
],

View file

@ -44,6 +44,7 @@ return [
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
Option\UrlShortener\AppendExtraPathConfigOption::class,
Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class,
Option\UrlShortener\EnableTrailingSlashConfigOption::class,
Option\Tracking\IpAnonymizationConfigOption::class,
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
Option\Tracking\DisableTrackParamConfigOption::class,
@ -72,9 +73,18 @@ return [
InstallationCommand::DB_MIGRATE->value => [
'command' => 'bin/cli ' . Command\Db\MigrateDatabaseCommand::NAME,
],
InstallationCommand::ORM_PROXIES->value => [
'command' => 'bin/doctrine orm:generate-proxies',
],
InstallationCommand::ORM_CLEAR_CACHE->value => [
'command' => 'bin/doctrine orm:clear-cache:metadata',
],
InstallationCommand::GEOLITE_DOWNLOAD_DB->value => [
'command' => 'bin/cli ' . Command\Visit\DownloadGeoLiteDbCommand::NAME,
],
InstallationCommand::API_KEY_GENERATE->value => [
'command' => 'bin/cli ' . Command\Api\GenerateKeyCommand::NAME,
],
],
],

View file

@ -7,7 +7,7 @@ return [
'mercure' => [
'public_hub_url' => 'http://localhost:8001',
'internal_hub_url' => 'http://shlink_mercure_proxy',
'jwt_secret' => 'mercure_jwt_key',
'jwt_secret' => 'mercure_jwt_key_long_enough_to_avoid_error',
],
];

View file

@ -45,6 +45,7 @@ return [
'rest' => [
'path' => '/rest',
'middleware' => [
Rest\Middleware\ErrorHandler\BackwardsCompatibleProblemDetailsHandler::class,
Router\Middleware\ImplicitOptionsMiddleware::class,
Rest\Middleware\BodyParserMiddleware::class,
Rest\Middleware\AuthenticationMiddleware::class,

View file

@ -7,15 +7,19 @@ namespace Shlinkio\Shlink;
use Fig\Http\Message\RequestMethodInterface;
use RKA\Middleware\IpAddress;
use Shlinkio\Shlink\Core\Action as CoreAction;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Rest\Action;
use Shlinkio\Shlink\Rest\ConfigProvider;
use Shlinkio\Shlink\Rest\Middleware;
use Shlinkio\Shlink\Rest\Middleware\Mercure\NotConfiguredMercureErrorHandler;
use function sprintf;
return (static function (): array {
$contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class;
$dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class;
$overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class;
$shortUrlRouteSuffix = EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false) ? '[/]' : '';
return [
@ -90,7 +94,7 @@ return (static function (): array {
],
[
'name' => CoreAction\RedirectAction::class,
'path' => '/{shortCode}',
'path' => sprintf('/{shortCode}%s', $shortUrlRouteSuffix),
'middleware' => [
IpAddress::class,
CoreAction\RedirectAction::class,

View file

@ -4,33 +4,40 @@ declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
return [
return (static function (): array {
/** @var string|null $disableTrackingFrom */
$disableTrackingFrom = EnvVars::DISABLE_TRACKING_FROM->loadFromEnv();
'tracking' => [
// Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations
// This applies only if IP address tracking is enabled
'anonymize_remote_addr' => (bool) EnvVars::ANONYMIZE_REMOTE_ADDR->loadFromEnv(true),
return [
// Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence
'track_orphan_visits' => (bool) EnvVars::TRACK_ORPHAN_VISITS->loadFromEnv(true),
'tracking' => [
// Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations
// This applies only if IP address tracking is enabled
'anonymize_remote_addr' => (bool) EnvVars::ANONYMIZE_REMOTE_ADDR->loadFromEnv(true),
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence
'disable_track_param' => EnvVars::DISABLE_TRACK_PARAM->loadFromEnv(),
// Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence
'track_orphan_visits' => (bool) EnvVars::TRACK_ORPHAN_VISITS->loadFromEnv(true),
// If true, visits will not be tracked at all
'disable_tracking' => (bool) EnvVars::DISABLE_TRACKING->loadFromEnv(false),
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence
'disable_track_param' => EnvVars::DISABLE_TRACK_PARAM->loadFromEnv(),
// If true, visits will be tracked, but neither the IP address, nor the location will be resolved
'disable_ip_tracking' => (bool) EnvVars::DISABLE_IP_TRACKING->loadFromEnv(false),
// If true, visits will not be tracked at all
'disable_tracking' => (bool) EnvVars::DISABLE_TRACKING->loadFromEnv(false),
// If true, the referrer will not be tracked
'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING->loadFromEnv(false),
// If true, visits will be tracked, but neither the IP address, nor the location will be resolved
'disable_ip_tracking' => (bool) EnvVars::DISABLE_IP_TRACKING->loadFromEnv(false),
// If true, the user agent will not be tracked
'disable_ua_tracking' => (bool) EnvVars::DISABLE_UA_TRACKING->loadFromEnv(false),
// If true, the referrer will not be tracked
'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING->loadFromEnv(false),
// A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default
'disable_tracking_from' => EnvVars::DISABLE_TRACKING_FROM->loadFromEnv(),
],
// If true, the user agent will not be tracked
'disable_ua_tracking' => (bool) EnvVars::DISABLE_UA_TRACKING->loadFromEnv(false),
];
// A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default
'disable_tracking_from' => $disableTrackingFrom === null
? []
: array_map(trim(...), explode(',', $disableTrackingFrom)),
],
];
})();

View file

@ -2,14 +2,19 @@
declare(strict_types=1);
$isSwoole = extension_loaded('openswoole');
use function Shlinkio\Shlink\Config\runningInOpenswoole;
use function Shlinkio\Shlink\Config\runningInRoadRunner;
return [
'url_shortener' => [
'domain' => [
'schema' => 'http',
'hostname' => sprintf('localhost:%s', $isSwoole ? '8080' : '8000'),
'hostname' => sprintf('localhost:%s', match (true) {
runningInRoadRunner() => '8800',
runningInOpenswoole() => '8080',
default => '8000',
}),
],
'auto_resolve_titles' => true,
// 'multi_segment_slugs_enabled' => true,

View file

@ -13,11 +13,13 @@ use Shlinkio\Shlink\Config\ConfigAggregator\EnvVarLoaderProvider;
use function class_exists;
use function Shlinkio\Shlink\Config\env;
use function Shlinkio\Shlink\Config\openswooleIsInstalled;
use function Shlinkio\Shlink\Config\runningInRoadRunner;
use const PHP_SAPI;
$isCli = PHP_SAPI === 'cli';
$isTestEnv = env('APP_ENV') === 'test';
$enableSwoole = PHP_SAPI === 'cli' && openswooleIsInstalled() && ! runningInRoadRunner();
return (new ConfigAggregator\ConfigAggregator([
! $isTestEnv
@ -26,7 +28,7 @@ return (new ConfigAggregator\ConfigAggregator([
Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class,
Mezzio\Router\FastRouteRouter\ConfigProvider::class,
$isCli && class_exists(Swoole\ConfigProvider::class)
$enableSwoole && class_exists(Swoole\ConfigProvider::class)
? Swoole\ConfigProvider::class
: new ConfigAggregator\ArrayProvider([]),
ProblemDetails\ConfigProvider::class,

View file

@ -0,0 +1,49 @@
version: '2.7'
rpc:
listen: tcp://127.0.0.1:6001
server:
command: 'php ../../bin/roadrunner-worker.php'
http:
address: '0.0.0.0:8080'
middleware: ['static']
static:
dir: '../../public'
forbid: ['.php', '.htaccess']
pool:
num_workers: 1
jobs:
pool:
num_workers: 1
timeout: 300
consume: ['shlink']
pipelines:
shlink:
driver: memory
config:
priority: 10
prefetch: 10
logs:
mode: development
channels:
http:
level: debug
server:
level: debug
metrics:
level: debug
reload:
interval: 1s
patterns: ['.php']
services:
http:
dirs: ['../../bin', '../../config', '../../data/migrations', '../../module', '../../vendor']
recursive: true
jobs:
dirs: ['../../bin', '../../config', '../../data/migrations', '../../module', '../../vendor']
recursive: true

36
config/roadrunner/.rr.yml Normal file
View file

@ -0,0 +1,36 @@
version: '2.7'
rpc:
listen: tcp://127.0.0.1:6001
server:
command: 'php -dopcache.enable_cli=1 -dopcache.validate_timestamps=0 ../../bin/roadrunner-worker.php'
http:
address: '0.0.0.0:${PORT}'
middleware: ['static']
static:
dir: '../../public'
forbid: ['.php', '.htaccess']
pool:
num_workers: ${WEB_WORKER_NUM}
jobs:
timeout: 300 # 5 minutes
pool:
num_workers: ${TASK_WORKER_NUM}
consume: ['shlink']
pipelines:
shlink:
driver: memory
config:
priority: 10
prefetch: 10
logs:
mode: production
channels:
http:
level: info # Log all http requests, set to info to disable
server:
level: debug # Everything written to worker stderr is logged

View file

@ -10,8 +10,8 @@ use Psr\Container\ContainerInterface;
use function register_shutdown_function;
use function sprintf;
use const ShlinkioTest\Shlink\SWOOLE_TESTING_HOST;
use const ShlinkioTest\Shlink\SWOOLE_TESTING_PORT;
use const ShlinkioTest\Shlink\API_TESTS_HOST;
use const ShlinkioTest\Shlink\API_TESTS_PORT;
/** @var ContainerInterface $container */
$container = require __DIR__ . '/../container.php';
@ -24,10 +24,15 @@ $httpClient = $container->get('shlink_test_api_client');
register_shutdown_function(function () use ($httpClient): void {
$httpClient->request(
'GET',
sprintf('http://%s:%s/api-tests/stop-coverage', SWOOLE_TESTING_HOST, SWOOLE_TESTING_PORT),
sprintf('http://%s:%s/api-tests/stop-coverage', API_TESTS_HOST, API_TESTS_PORT),
);
});
$testHelper->createTestDb(['bin/cli', 'db:create'], ['bin/cli', 'db:migrate']);
$testHelper->createTestDb(
['bin/cli', 'db:create'],
['bin/cli', 'db:migrate'],
['bin/doctrine', 'orm:schema-tool:drop'],
['bin/doctrine', 'dbal:run-sql'],
);
ApiTest\ApiTestCase::setApiClient($httpClient);
ApiTest\ApiTestCase::setSeedFixturesCallback(fn () => $testHelper->seedFixtures($em, $config['data_fixtures'] ?? []));

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\TestUtils;
use Doctrine\ORM\EntityManager;
use Psr\Container\ContainerInterface;
use function file_exists;
use function unlink;
/** @var ContainerInterface $container */
$container = require __DIR__ . '/../container.php';
$testHelper = $container->get(Helper\TestHelper::class);
$config = $container->get('config');
$em = $container->get(EntityManager::class);
// Delete old coverage in PHP, to avoid merging older executions with current one
$covFile = __DIR__ . '/../../build/coverage-cli.cov';
if (file_exists($covFile)) {
unlink($covFile);
}
$testHelper->createTestDb(
['bin/cli', 'db:create'],
['bin/cli', 'db:migrate'],
['bin/doctrine', 'orm:schema-tool:drop'],
['bin/doctrine', 'dbal:run-sql'],
);
CliTest\CliTestCase::setSeedFixturesCallback(
static fn () => $testHelper->seedFixtures($em, $config['data_fixtures'] ?? []),
);

View file

@ -8,5 +8,10 @@ use Psr\Container\ContainerInterface;
/** @var ContainerInterface $container */
$container = require __DIR__ . '/../container.php';
$container->get(Helper\TestHelper::class)->createTestDb(['bin/cli', 'db:create'], ['bin/cli', 'db:migrate']);
$container->get(Helper\TestHelper::class)->createTestDb(
['bin/cli', 'db:create'],
['bin/cli', 'db:migrate'],
['bin/doctrine', 'orm:schema-tool:drop'],
['bin/doctrine', 'dbal:run-sql'],
);
DbTest\DatabaseTestCase::setEntityManager($container->get('em'));

View file

@ -4,5 +4,5 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink;
const SWOOLE_TESTING_HOST = '127.0.0.1';
const SWOOLE_TESTING_PORT = 9999;
const API_TESTS_HOST = '127.0.0.1';
const API_TESTS_PORT = 9999;

View file

@ -8,8 +8,10 @@ use GuzzleHttp\Client;
use Laminas\ConfigAggregator\ConfigAggregator;
use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\ServiceManager\Factory\InvokableFactory;
use League\Event\EventDispatcher;
use Monolog\Level;
use PHPUnit\Runner\Version;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
@ -20,24 +22,60 @@ use SebastianBergmann\CodeCoverage\Report\Html\Facade as Html;
use SebastianBergmann\CodeCoverage\Report\PHP;
use SebastianBergmann\CodeCoverage\Report\Xml\Facade as Xml;
use Shlinkio\Shlink\Common\Logger\LoggerType;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Event\ConsoleCommandEvent;
use Symfony\Component\Console\Event\ConsoleTerminateEvent;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use function file_exists;
use function Functional\contains;
use function Laminas\Stratigility\middleware;
use function Shlinkio\Shlink\Config\env;
use function sprintf;
use function sys_get_temp_dir;
use const ShlinkioTest\Shlink\SWOOLE_TESTING_HOST;
use const ShlinkioTest\Shlink\SWOOLE_TESTING_PORT;
use const ShlinkioTest\Shlink\API_TESTS_HOST;
use const ShlinkioTest\Shlink\API_TESTS_PORT;
$isApiTest = env('TEST_ENV') === 'api';
$generateCoverage = env('GENERATE_COVERAGE') === 'yes';
if ($isApiTest && $generateCoverage) {
$isCliTest = env('TEST_ENV') === 'cli';
$isE2eTest = $isApiTest || $isCliTest;
$coverageType = env('GENERATE_COVERAGE');
$generateCoverage = contains(['yes', 'pretty'], $coverageType);
$coverage = null;
if ($isE2eTest && $generateCoverage) {
$filter = new Filter();
$filter->includeDirectory(__DIR__ . '/../../module/Core/src');
$filter->includeDirectory(__DIR__ . '/../../module/Rest/src');
$filter->includeDirectory(__DIR__ . '/../../module/' . ($isApiTest ? 'Rest' : 'CLI') . '/src');
$coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter);
}
/**
* @param 'api'|'cli' $type
*/
$exportCoverage = static function (string $type = 'api') use (&$coverage, $coverageType): void {
if ($coverage === null) {
return;
}
$basePath = __DIR__ . '/../../build/coverage-' . $type;
$covPath = $basePath . '.cov';
// Every CLI test runs on its own process and dumps the coverage afterwards.
// Try to load it and merge it, so that we end up with the whole coverage at the end.
if ($type === 'cli' && file_exists($covPath)) {
$coverage->merge(require $covPath);
}
if ($coverageType === 'pretty') {
(new Html())->process($coverage, $basePath . '/coverage-html');
} else {
(new PHP())->process($coverage, $covPath);
(new Xml(Version::getVersionString()))->process($coverage, $basePath . '/coverage-xml');
}
};
$buildDbConnection = static function (): array {
$driver = env('DB_DRIVER', 'sqlite');
$isCi = env('CI', false);
@ -92,14 +130,13 @@ return [
'schema' => 'http',
'hostname' => 'doma.in',
],
'validate_url' => true,
],
'mezzio-swoole' => [
'enable_coroutine' => false,
'swoole-http-server' => [
'host' => SWOOLE_TESTING_HOST,
'port' => SWOOLE_TESTING_PORT,
'host' => API_TESTS_HOST,
'port' => API_TESTS_PORT,
'process-name' => 'shlink_test',
'options' => [
'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid',
@ -113,17 +150,10 @@ return [
[
'name' => 'dump_coverage',
'path' => '/api-tests/stop-coverage',
'middleware' => middleware(static function () use (&$coverage) {
'middleware' => middleware(static function () use ($exportCoverage) {
// TODO I have tried moving this block to a listener so that it's invoked automatically,
// but then the coverage is generated empty ¯\_(ツ)_/¯
if ($coverage) { // @phpstan-ignore-line
$basePath = __DIR__ . '/../../build/coverage-api';
(new PHP())->process($coverage, $basePath . '.cov');
(new Xml(Version::getVersionString()))->process($coverage, $basePath . '/coverage-xml');
(new Html())->process($coverage, $basePath . '/coverage-html');
}
$exportCoverage();
return new EmptyResponse();
}),
'allowed_methods' => ['GET'],
@ -157,13 +187,69 @@ return [
'dependencies' => [
'services' => [
'shlink_test_api_client' => new Client([
'base_uri' => sprintf('http://%s:%s/', SWOOLE_TESTING_HOST, SWOOLE_TESTING_PORT),
'base_uri' => sprintf('http://%s:%s/', API_TESTS_HOST, API_TESTS_PORT),
'http_errors' => false,
]),
],
'factories' => [
TestUtils\Helper\TestHelper::class => InvokableFactory::class,
],
'delegators' => $isCliTest ? [
Application::class => [
static function (
ContainerInterface $c,
string $serviceName,
callable $callback,
) use (
&$coverage,
$exportCoverage,
) {
/** @var Application $app */
$app = $callback();
$wrappedEventDispatcher = new EventDispatcher();
// When the command starts, start collecting coverage
$wrappedEventDispatcher->subscribeTo(
ConsoleCommandEvent::class,
static function () use (&$coverage): void {
$id = env('COVERAGE_ID');
if ($id === null) {
return;
}
$coverage?->start($id);
},
);
// When the command ends, stop collecting coverage
$wrappedEventDispatcher->subscribeTo(
ConsoleTerminateEvent::class,
static function () use (&$coverage, $exportCoverage): void {
$id = env('COVERAGE_ID');
if ($id === null) {
return;
}
$coverage?->stop();
$exportCoverage('cli');
},
);
$app->setDispatcher(new class ($wrappedEventDispatcher) implements EventDispatcherInterface {
public function __construct(private EventDispatcher $wrappedDispatcher)
{
}
public function dispatch(object $event, ?string $eventName = null): object
{
$this->wrappedDispatcher->dispatch($event);
return $event;
}
});
return $app;
},
],
] : [],
],
'entity_manager' => [
@ -172,6 +258,7 @@ return [
'data_fixtures' => [
'paths' => [
// TODO These are used for CLI tests too, so maybe should be somewhere else
__DIR__ . '/../../module/Rest/test-api/Fixtures',
],
],

View file

@ -0,0 +1,73 @@
FROM php:8.1.9-alpine3.16
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.21
ENV PDO_SQLSRV_VERSION 5.10.1
ENV MS_ODBC_SQL_VERSION 17.5.2.2
RUN apk update
# Install common php extensions
RUN docker-php-ext-install pdo_mysql
RUN docker-php-ext-install calendar
RUN apk add --no-cache oniguruma-dev
RUN docker-php-ext-install mbstring
RUN apk add --no-cache sqlite-libs
RUN apk add --no-cache sqlite-dev
RUN docker-php-ext-install pdo_sqlite
RUN apk add --no-cache icu-dev
RUN docker-php-ext-install intl
RUN apk add --no-cache libzip-dev zlib-dev
RUN docker-php-ext-install zip
RUN apk add --no-cache libpng-dev
RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
RUN 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 \
&& tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \
&& docker-php-ext-configure apcu \
&& docker-php-ext-install apcu \
&& rm /tmp/apcu.tar.gz \
&& rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \
&& echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
# Install pcov and sqlsrv 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 pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
docker-php-ext-enable pdo_sqlsrv pcov && \
apk del .phpize-deps && \
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk
# Install composer
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
# Make home directory writable by anyone
RUN chmod 777 /home
VOLUME /home/shlink
WORKDIR /home/shlink
# Expose roadrunner port
EXPOSE 8080
CMD \
# Install dependencies if the vendor dir does not exist
if [[ ! -d "./vendor" ]]; then /usr/local/bin/composer install ; fi && \
# Download roadrunner binary
if [[ ! -f "./bin/rr" ]]; then ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr ; fi && \
# This forces the app to be started every second until the exit code is 0
until ./bin/rr serve -c config/roadrunner/.rr.dev.yml; do sleep 1 ; done

View file

@ -13,6 +13,12 @@ services:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
shlink_roadrunner:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
shlink_db_mysql:
user: 1000:1000
volumes:

View file

@ -73,6 +73,30 @@ services:
extra_hosts:
- 'host.docker.internal:host-gateway'
shlink_roadrunner:
container_name: shlink_roadrunner
build:
context: .
dockerfile: ./data/infra/roadrunner.Dockerfile
ports:
- "8800:8080"
volumes:
- ./:/home/shlink
- ./data/infra/php.ini:/usr/local/etc/php/php.ini
links:
- shlink_db_mysql
- shlink_db_postgres
- shlink_db_maria
- shlink_db_ms
- shlink_redis
- shlink_mercure
- shlink_mercure_proxy
- shlink_rabbitmq
environment:
LC_ALL: C
extra_hosts:
- 'host.docker.internal:host-gateway'
shlink_db_mysql:
container_name: shlink_db_mysql
image: mysql:5.7
@ -144,8 +168,8 @@ services:
- "3080:80"
environment:
SERVER_NAME: ":80"
MERCURE_PUBLISHER_JWT_KEY: mercure_jwt_key
MERCURE_SUBSCRIBER_JWT_KEY: mercure_jwt_key
MERCURE_PUBLISHER_JWT_KEY: mercure_jwt_key_long_enough_to_avoid_error
MERCURE_SUBSCRIBER_JWT_KEY: mercure_jwt_key_long_enough_to_avoid_error
MERCURE_EXTRA_DIRECTIVES: "cors_origins https://app.shlink.io http://localhost:3000 http://127.0.0.1:3000"
shlink_rabbitmq:

View file

@ -1,25 +0,0 @@
#!/bin/bash
set -ex
PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64"
DOCKER_IMAGE="shlinkio/shlink"
# If ref is not develop, then this is a tag. Build that docker tag and also "stable"
if [[ "$GITHUB_REF" != *"develop"* ]]; then
VERSION=${GITHUB_REF#refs/tags/v}
TAGS="-t ${DOCKER_IMAGE}:${VERSION}"
# Push stable tag only if this is not an alpha or beta tag
[[ $GITHUB_REF != *"alpha"* && $GITHUB_REF != *"beta"* ]] && TAGS="${TAGS} -t ${DOCKER_IMAGE}:stable"
docker buildx build --push \
--build-arg SHLINK_VERSION=${VERSION} \
--platform ${PLATFORMS} \
${TAGS} .
# If build branch is develop, build latest
elif [[ "$GITHUB_REF" == *"develop"* ]]; then
docker buildx build --push \
--platform ${PLATFORMS} \
-t ${DOCKER_IMAGE}:latest .
fi

View file

@ -6,11 +6,14 @@ namespace Shlinkio\Shlink;
use Shlinkio\Shlink\Common\Logger\LoggerType;
use function Shlinkio\Shlink\Config\runningInRoadRunner;
return [
'logger' => [
'Shlink' => [
'type' => LoggerType::STREAM->value,
'destination' => runningInRoadRunner() ? 'php://stderr' : 'php://stdout',
],
],

View file

@ -13,24 +13,36 @@ echo "Updating database..."
php bin/cli db:migrate -n ${flags}
echo "Generating proxies..."
php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies -n ${flags}
php bin/doctrine orm:generate-proxies -n ${flags}
echo "Clearing entities cache..."
php vendor/doctrine/orm/bin/doctrine.php orm:clear-cache:metadata -n ${flags}
php bin/doctrine 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
# Try to download GeoLite2 db file only if the license key env var was defined and skipping was not explicitly set
if [ ! -z "${GEOLITE_LICENSE_KEY}" ] && [ "${SKIP_INITIAL_GEOLITE_DOWNLOAD}" != "true" ]; then
echo "Downloading GeoLite2 db file..."
php bin/cli visit:download-db -n ${flags}
fi
# Periodically run visit:locate every hour, if ENABLE_PERIODIC_VISIT_LOCATE=true was provided
if [ $ENABLE_PERIODIC_VISIT_LOCATE ]; then
if [ "${ENABLE_PERIODIC_VISIT_LOCATE}" = "true" ]; then
echo "Configuring periodic visit location..."
echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root
/usr/sbin/crond &
fi
# When restarting the container, openswoole might think it is already in execution
# This forces the app to be started every second until the exit code is 0
until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done
# RoadRunner config needs these to have been set, so falling back to default values if not set yet
if [ "$SHLINK_RUNTIME" == 'rr' ]; then
export PORT="${PORT:-"8080"}"
# Default to 0 so that RoadRunner decides the number of workers based on the amount of logical CPUs
export WEB_WORKER_NUM="${WEB_WORKER_NUM:-"0"}"
export TASK_WORKER_NUM="${TASK_WORKER_NUM:-"0"}"
fi
if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then
# When restarting the container, openswoole might think it is already in execution
# This forces the app to be started every second until the exit code is 0
until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done
elif [ "$SHLINK_RUNTIME" == 'rr' ]; then
./bin/rr serve -c config/roadrunner/.rr.yml
fi

View file

@ -0,0 +1,9 @@
{
"value": {
"title": "Invalid data",
"type": "https://shlink.io/api/error/invalid-data",
"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": "https://shlink.io/api/error/short-url-not-found",
"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": "https://shlink.io/api/error/tag-not-found",
"status": 404,
"tag": "foo"
}
}

View file

@ -6,6 +6,7 @@
"schema": {
"type": "string",
"enum": [
"3",
"2",
"1"
]

View file

@ -327,11 +327,11 @@
},
"url": {
"type": "string",
"description": "A URL that could not be verified, if the error type is INVALID_URL"
"description": "A URL that could not be verified, if the error type is https://shlink.io/api/error/invalid-url"
},
"customSlug": {
"type": "string",
"description": "Provided custom slug when the error type is INVALID_SLUG"
"description": "Provided custom slug when the error type is https://shlink.io/api/error/non-unique-slug"
},
"domain": {
"type": "string",
@ -342,10 +342,31 @@
]
},
"examples": {
"Invalid arguments": {
"$ref": "../examples/short-url-invalid-args.json"
"Invalid arguments with API v3 and newer": {
"$ref": "../examples/short-url-invalid-args-v3.json"
},
"Invalid long URL": {
"Invalid long URL with API v3 and newer": {
"value": {
"title": "Invalid URL",
"type": "https://shlink.io/api/error/invalid-url",
"detail": "Provided URL foo is invalid. Try with a different one.",
"status": 400,
"url": "https://invalid-url.com"
}
},
"Non-unique slug with API v3 and newer": {
"value": {
"title": "Invalid custom slug",
"type": "https://shlink.io/api/error/non-unique-slug",
"detail": "Provided slug \"my-slug\" is already in use.",
"status": 400,
"customSlug": "my-slug"
}
},
"Invalid arguments previous to API v3": {
"$ref": "../examples/short-url-invalid-args-v2.json"
},
"Invalid long URL previous to API v3": {
"value": {
"title": "Invalid URL",
"type": "INVALID_URL",
@ -354,7 +375,7 @@
"url": "https://invalid-url.com"
}
},
"Non-unique slug": {
"Non-unique slug previous to API v3": {
"value": {
"title": "Invalid custom slug",
"type": "INVALID_SLUG",

View file

@ -85,19 +85,39 @@
"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"
"examples": {
"API v3 and newer": {
"value": {
"title": "Invalid URL",
"type": "https://shlink.io/api/error/invalid-url",
"detail": "Provided URL foo is invalid. Try with a different one.",
"status": 400,
"url": "https://invalid-url.com"
}
},
"Previous to API v3": {
"value": {
"title": "Invalid URL",
"type": "INVALID_URL",
"detail": "Provided URL foo is invalid. Try with a different one.",
"status": 400,
"url": "https://invalid-url.com"
}
}
}
},
"text/plain": {
"schema": {
"type": "string"
},
"example": "INVALID_URL"
"examples": {
"API v3 and newer": {
"value": "https://shlink.io/api/error/invalid-url"
},
"Previous to API v3": {
"value": "INVALID_URL"
}
}
}
}
},

View file

@ -83,8 +83,11 @@
]
},
"examples": {
"Not found": {
"$ref": "../examples/short-url-not-found.json"
"API v3 and newer": {
"$ref": "../examples/short-url-not-found-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/short-url-not-found-v2.json"
}
}
}
@ -203,8 +206,11 @@
]
},
"examples": {
"Invalid arguments": {
"$ref": "../examples/short-url-invalid-args.json"
"API v3 and newer": {
"$ref": "../examples/short-url-invalid-args-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/short-url-invalid-args-v2.json"
}
}
}
@ -236,8 +242,11 @@
]
},
"examples": {
"Not found": {
"$ref": "../examples/short-url-not-found.json"
"API v3 and newer": {
"$ref": "../examples/short-url-not-found-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/short-url-not-found-v2.json"
}
}
}
@ -318,13 +327,27 @@
}
]
},
"example": {
"title": "Cannot delete short URL",
"type": "INVALID_SHORT_URL_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": {
"API v3 and newer": {
"value": {
"title": "Cannot delete short URL",
"type": "https://shlink.io/api/error/invalid-short-url-deletion",
"detail": "Impossible to delete short URL with short code \"abc123\", since it has more than \"15\" visits.",
"status": 422,
"shortCode": "abc123",
"threshold": 15
}
},
"Previous to API v3": {
"value": {
"title": "Cannot delete short URL",
"type": "INVALID_SHORT_URL_DELETION",
"detail": "Impossible to delete short URL with short code \"abc123\", since it has more than \"15\" visits.",
"status": 422,
"shortCode": "abc123",
"threshold": 15
}
}
}
}
}
@ -355,8 +378,11 @@
]
},
"examples": {
"Not found": {
"$ref": "../examples/short-url-not-found.json"
"API v3 and newer": {
"$ref": "../examples/short-url-not-found-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/short-url-not-found-v2.json"
}
}
}

View file

@ -151,8 +151,11 @@
"$ref": "../definitions/Error.json"
},
"examples": {
"Short URL not found": {
"$ref": "../examples/short-url-not-found.json"
"Short URL not found with API v3 and newer": {
"$ref": "../examples/short-url-not-found-v3.json"
},
"Short URL not found previous to API v3": {
"$ref": "../examples/short-url-not-found-v2.json"
}
}
}

View file

@ -188,12 +188,25 @@
"schema": {
"$ref": "../definitions/Error.json"
},
"example": {
"title": "Invalid data",
"type": "INVALID_ARGUMENT",
"detail": "Provided data is not valid",
"status": 400,
"invalidElements": ["oldName", "newName"]
"examples": {
"API v3 and newer": {
"value": {
"title": "Invalid data",
"type": "https://shlink.io/api/error/invalid-data",
"detail": "Provided data is not valid",
"status": 400,
"invalidElements": ["oldName", "newName"]
}
},
"Previous to API v3": {
"value": {
"title": "Invalid data",
"type": "INVALID_ARGUMENT",
"detail": "Provided data is not valid",
"status": 400,
"invalidElements": ["oldName", "newName"]
}
}
}
}
}
@ -205,11 +218,23 @@
"schema": {
"$ref": "../definitions/Error.json"
},
"example": {
"detail": "You are not allowed to rename tags",
"title": "Forbidden tag operation",
"type": "FORBIDDEN_OPERATION",
"status": 403
"examples": {
"API v3 and newer": {
"value": {
"detail": "You are not allowed to rename tags",
"title": "Forbidden tag operation",
"type": "https://shlink.io/api/error/forbidden-tag-operation",
"status": 403
}
},
"Previous to API v3": {
"value": {
"detail": "You are not allowed to rename tags",
"title": "Forbidden tag operation",
"type": "FORBIDDEN_OPERATION",
"status": 403
}
}
}
}
}
@ -222,8 +247,11 @@
"$ref": "../definitions/Error.json"
},
"examples": {
"Tag not found": {
"$ref": "../examples/tag-not-found.json"
"API v3 and newer": {
"$ref": "../examples/tag-not-found-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/tag-not-found-v2.json"
}
}
}
@ -236,13 +264,27 @@
"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"
"examples": {
"API v3 and newer": {
"value": {
"detail": "You cannot rename tag foo, because it already exists",
"title": "Tag conflict",
"type": "https://shlink.io/api/error/tag-conflict",
"status": 409,
"oldName": "bar",
"newName": "foo"
}
},
"Previous to API v3": {
"value": {
"detail": "You cannot rename tag foo, because it already exists",
"title": "Tag conflict",
"type": "TAG_CONFLICT",
"status": 409,
"oldName": "bar",
"newName": "foo"
}
}
}
}
}
@ -300,11 +342,23 @@
"schema": {
"$ref": "../definitions/Error.json"
},
"example": {
"detail": "You are not allowed to delete tags",
"title": "Forbidden tag operation",
"type": "FORBIDDEN_OPERATION",
"status": 403
"examples": {
"API v3 and newer": {
"value": {
"detail": "You are not allowed to delete tags",
"title": "Forbidden tag operation",
"type": "https://shlink.io/api/error/forbidden-tag-operation",
"status": 403
}
},
"Previous to API v3": {
"value": {
"detail": "You are not allowed to delete tags",
"title": "Forbidden tag operation",
"type": "FORBIDDEN_OPERATION",
"status": 403
}
}
}
}
}

View file

@ -94,12 +94,25 @@
}
]
},
"example": {
"title": "Invalid data",
"type": "INVALID_ARGUMENT",
"detail": "Provided data is not valid",
"status": 400,
"invalidElements": ["domain", "invalidShortUrlRedirect"]
"examples": {
"API v3 and newer": {
"value": {
"title": "Invalid data",
"type": "https://shlink.io/api/error/invalid-data",
"detail": "Provided data is not valid",
"status": 400,
"invalidElements": ["domain", "invalidShortUrlRedirect"]
}
},
"Previous to API v3": {
"value": {
"title": "Invalid data",
"type": "INVALID_ARGUMENT",
"detail": "Provided data is not valid",
"status": 400,
"invalidElements": ["domain", "invalidShortUrlRedirect"]
}
}
}
}
}

View file

@ -147,12 +147,25 @@
"schema": {
"$ref": "../definitions/Error.json"
},
"example": {
"detail": "Domain with authority \"example.com\" could not be found",
"title": "Domain not found",
"type": "DOMAIN_NOT_FOUND",
"status": 404,
"authority": "example.com"
"examples": {
"API v3 and newer": {
"value": {
"detail": "Domain with authority \"example.com\" could not be found",
"title": "Domain not found",
"type": "https://shlink.io/api/error/domain-not-found",
"status": 404,
"authority": "example.com"
}
},
"Previous to API v3": {
"value": {
"detail": "Domain with authority \"example.com\" could not be found",
"title": "Domain not found",
"type": "DOMAIN_NOT_FOUND",
"status": 404,
"authority": "example.com"
}
}
}
}
}

View file

@ -39,11 +39,23 @@
"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": {
"API v3 and newer": {
"value": {
"title": "Mercure integration not configured",
"type": "https://shlink.io/api/error/mercure-not-configured",
"detail": "This Shlink instance is not integrated with a mercure hub.",
"status": 501
}
},
"Previous to API v3": {
"value": {
"title": "Mercure integration not configured",
"type": "MERCURE_NOT_CONFIGURED",
"detail": "This Shlink instance is not integrated with a mercure hub.",
"status": 501
}
}
}
}
}

View file

@ -148,8 +148,12 @@
"$ref": "../definitions/Error.json"
},
"examples": {
"Tag not found": {
"$ref": "../examples/tag-not-found.json"
"API v3 and newer": {
"$ref": "../examples/tag-not-found-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/tag-not-found-v2.json"
}
}
}

View file

@ -3,7 +3,7 @@
"info": {
"title": "Shlink",
"description": "Shlink, the self-hosted URL shortener",
"version": "2.0"
"version": "3.0"
},
"externalDocs": {

View file

@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Run docker containers if they are not up yet
if ! [[ $(docker ps | grep shlink_swoole) ]]; then
if ! [[ $(docker ps | grep shlink) ]]; then
docker-compose up -d
fi

24
infection-cli.json Normal file
View file

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

View file

@ -19,7 +19,6 @@ use Shlinkio\Shlink\Core\Tag\TagService;
use Shlinkio\Shlink\Core\Visit;
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Symfony\Component\Console as SymfonyCli;
use Symfony\Component\Lock\LockFactory;
@ -35,7 +34,7 @@ return [
SymfonyCli\Helper\ProcessHelper::class => ProcessHelperFactory::class,
PhpExecutableFinder::class => InvokableFactory::class,
Util\GeolocationDbUpdater::class => ConfigAbstractFactory::class,
GeoLite\GeolocationDbUpdater::class => ConfigAbstractFactory::class,
Util\ProcessRunner::class => ConfigAbstractFactory::class,
ApiKey\RoleResolver::class => ConfigAbstractFactory::class,
@ -70,7 +69,7 @@ return [
],
ConfigAbstractFactory::class => [
Util\GeolocationDbUpdater::class => [
GeoLite\GeolocationDbUpdater::class => [
DbUpdater::class,
Reader::class,
LOCAL_LOCK_FACTORY,
@ -92,10 +91,10 @@ return [
Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
Command\Visit\DownloadGeoLiteDbCommand::class => [Util\GeolocationDbUpdater::class],
Command\Visit\DownloadGeoLiteDbCommand::class => [GeoLite\GeolocationDbUpdater::class],
Command\Visit\LocateVisitsCommand::class => [
Visit\VisitLocator::class,
IpLocationResolverInterface::class,
Visit\VisitToLocationHelper::class,
LockFactory::class,
],
Command\Visit\GetOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class],

View file

@ -22,7 +22,7 @@ use const Shlinkio\Shlink\MIGRATIONS_TABLE;
class CreateDatabaseCommand extends AbstractDatabaseCommand
{
public const NAME = 'db:create';
public const DOCTRINE_SCRIPT = 'vendor/doctrine/orm/bin/doctrine.php';
public const DOCTRINE_SCRIPT = 'bin/doctrine';
public const DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create';
public function __construct(

View file

@ -40,7 +40,7 @@ class CreateShortUrlCommand extends Command
private readonly UrlShortenerOptions $options,
) {
parent::__construct();
$this->defaultDomain = $this->options->domain()['hostname'] ?? '';
$this->defaultDomain = $this->options->domain['hostname'] ?? '';
}
protected function configure(): void
@ -158,7 +158,7 @@ class CreateShortUrlCommand extends Command
$tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
$customSlug = $input->getOption('custom-slug');
$maxVisits = $input->getOption('max-visits');
$shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength();
$shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength;
$doValidateUrl = $input->getOption('validate-url');
try {
@ -175,7 +175,7 @@ class CreateShortUrlCommand extends Command
ShortUrlInputFilter::TAGS => $tags,
ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'),
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $this->options->multiSegmentSlugsEnabled(),
EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $this->options->multiSegmentSlugsEnabled,
]));
$io->writeln([

View file

@ -5,8 +5,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;

View file

@ -11,11 +11,11 @@ use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
use Shlinkio\Shlink\Core\Visit\Model\UnlocatableIpType;
use Shlinkio\Shlink\Core\Visit\VisitGeolocationHelperInterface;
use Shlinkio\Shlink\Core\Visit\VisitLocatorInterface;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\Core\Visit\VisitToLocationHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
@ -34,8 +34,8 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
private SymfonyStyle $io;
public function __construct(
private VisitLocatorInterface $visitLocator,
private IpLocationResolverInterface $ipLocationResolver,
private readonly VisitLocatorInterface $visitLocator,
private readonly VisitToLocationHelperInterface $visitToLocation,
LockFactory $locker,
) {
parent::__construct($locker);
@ -132,39 +132,33 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
*/
public function geolocateVisit(Visit $visit): Location
{
if (! $visit->hasRemoteAddr()) {
$this->io->writeln(
'<comment>Ignored visit with no IP address</comment>',
OutputInterface::VERBOSITY_VERBOSE,
);
throw IpCannotBeLocatedException::forEmptyAddress();
}
$ipAddr = $visit->getRemoteAddr() ?? '';
$ipAddr = $visit->getRemoteAddr() ?? '?';
$this->io->write(sprintf('Processing IP <fg=blue>%s</>', $ipAddr));
if ($ipAddr === IpAddress::LOCALHOST) {
$this->io->writeln(' [<comment>Ignored localhost address</comment>]');
throw IpCannotBeLocatedException::forLocalhost();
}
try {
return $this->ipLocationResolver->resolveIpLocation($ipAddr);
} catch (WrongIpException $e) {
$this->io->writeln(' [<fg=red>An error occurred while locating IP. Skipped</>]');
if ($this->io->isVerbose()) {
return $this->visitToLocation->resolveVisitLocation($visit);
} catch (IpCannotBeLocatedException $e) {
$this->io->writeln(match ($e->type) {
UnlocatableIpType::EMPTY_ADDRESS => ' [<comment>Ignored visit with no IP address</comment>]',
UnlocatableIpType::LOCALHOST => ' [<comment>Ignored localhost address</comment>]',
UnlocatableIpType::ERROR => ' [<fg=red>An error occurred while locating IP. Skipped</>]',
});
if ($e->type === UnlocatableIpType::ERROR && $this->io->isVerbose()) {
$this->getApplication()?->renderThrowable($e, $this->io);
}
throw IpCannotBeLocatedException::forError($e);
throw $e;
}
}
public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void
{
$message = ! $visitLocation->isEmpty()
? sprintf(' [<info>Address located in "%s"</info>]', $visitLocation->getCountryName())
: ' [<comment>Address not found</comment>]';
$this->io->writeln($message);
if (! $visitLocation->isEmpty()) {
$this->io->writeln(sprintf(' [<info>Address located in "%s"</info>]', $visitLocation->getCountryName()));
} elseif ($visit->hasRemoteAddr() && $visit->getRemoteAddr() !== IpAddress::LOCALHOST) {
$this->io->writeln(' <comment>[Could not locate address]</comment>');
}
}
private function checkDbUpdate(): void

View file

@ -13,16 +13,15 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
{
private bool $olderDbExists;
private function __construct(string $message, int $code, ?Throwable $previous)
private function __construct(string $message, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
parent::__construct($message, 0, $previous);
}
public static function withOlderDb(?Throwable $prev = null): self
{
$e = new self(
'An error occurred while updating geolocation database, but an older DB is already present.',
0,
$prev,
);
$e->olderDbExists = true;
@ -34,7 +33,6 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
{
$e = new self(
'An error occurred while updating geolocation database, and an older version could not be found.',
0,
$prev,
);
$e->olderDbExists = false;
@ -47,7 +45,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
$e = new self(sprintf(
'Build epoch with value "%s" from existing geolocation database, could not be parsed to integer.',
$buildEpoch,
), 0, null);
));
$e->olderDbExists = true;
return $e;

View file

@ -17,7 +17,7 @@ class ApplicationFactory
$appOptions = $container->get(AppOptions::class);
$commands = $config['commands'] ?? [];
$app = new CliApp($appOptions->getName(), $appOptions->getVersion());
$app = new CliApp($appOptions->name, $appOptions->version);
$app->setCommandLoader(new ContainerCommandLoader($container, $commands));
return $app;

View file

@ -2,14 +2,16 @@
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Util;
namespace Shlinkio\Shlink\CLI\GeoLite;
use Cake\Chronos\Chronos;
use GeoIp2\Database\Reader;
use MaxMind\Db\Reader\Metadata;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException;
use Shlinkio\Shlink\IpGeolocation\Exception\DbUpdateException;
use Shlinkio\Shlink\IpGeolocation\Exception\MissingLicenseException;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Symfony\Component\Lock\LockFactory;
@ -20,27 +22,27 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
private const LOCK_NAME = 'geolocation-db-update';
public function __construct(
private DbUpdaterInterface $dbUpdater,
private Reader $geoLiteDbReader,
private LockFactory $locker,
private TrackingOptions $trackingOptions,
private readonly DbUpdaterInterface $dbUpdater,
private readonly Reader $geoLiteDbReader,
private readonly LockFactory $locker,
private readonly TrackingOptions $trackingOptions,
) {
}
/**
* @throws GeolocationDbUpdateFailedException
*/
public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): void
public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): GeolocationResult
{
if ($this->trackingOptions->disableTracking() || $this->trackingOptions->disableIpTracking()) {
return;
if ($this->trackingOptions->disableTracking || $this->trackingOptions->disableIpTracking) {
return GeolocationResult::CHECK_SKIPPED;
}
$lock = $this->locker->createLock(self::LOCK_NAME);
$lock->acquire(true); // Block until lock is released
try {
$this->downloadIfNeeded($beforeDownload, $handleProgress);
return $this->downloadIfNeeded($beforeDownload, $handleProgress);
} finally {
$lock->release();
}
@ -49,17 +51,18 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
/**
* @throws GeolocationDbUpdateFailedException
*/
private function downloadIfNeeded(?callable $beforeDownload, ?callable $handleProgress): void
private function downloadIfNeeded(?callable $beforeDownload, ?callable $handleProgress): GeolocationResult
{
if (! $this->dbUpdater->databaseFileExists()) {
$this->downloadNewDb(false, $beforeDownload, $handleProgress);
return;
return $this->downloadNewDb(false, $beforeDownload, $handleProgress);
}
$meta = $this->geoLiteDbReader->metadata();
if ($this->buildIsTooOld($meta)) {
$this->downloadNewDb(true, $beforeDownload, $handleProgress);
return $this->downloadNewDb(true, $beforeDownload, $handleProgress);
}
return GeolocationResult::DB_IS_UP_TO_DATE;
}
private function buildIsTooOld(Metadata $meta): bool
@ -92,15 +95,22 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
/**
* @throws GeolocationDbUpdateFailedException
*/
private function downloadNewDb(bool $olderDbExists, ?callable $beforeDownload, ?callable $handleProgress): void
{
private function downloadNewDb(
bool $olderDbExists,
?callable $beforeDownload,
?callable $handleProgress,
): GeolocationResult {
if ($beforeDownload !== null) {
$beforeDownload($olderDbExists);
}
try {
$this->dbUpdater->downloadFreshCopy($this->wrapHandleProgressCallback($handleProgress, $olderDbExists));
} catch (RuntimeException $e) {
return $olderDbExists ? GeolocationResult::DB_UPDATED : GeolocationResult::DB_CREATED;
} catch (MissingLicenseException) {
// If there's no license key, just ignore the error
return GeolocationResult::CHECK_SKIPPED;
} catch (DbUpdateException | WrongIpException $e) {
throw $olderDbExists
? GeolocationDbUpdateFailedException::withOlderDb($e)
: GeolocationDbUpdateFailedException::withoutOlderDb($e);
@ -113,6 +123,6 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
return null;
}
return fn (int $total, int $downloaded) => $handleProgress($total, $downloaded, $olderDbExists);
return static fn (int $total, int $downloaded) => $handleProgress($total, $downloaded, $olderDbExists);
}
}

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Util;
namespace Shlinkio\Shlink\CLI\GeoLite;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
@ -11,5 +11,8 @@ interface GeolocationDbUpdaterInterface
/**
* @throws GeolocationDbUpdateFailedException
*/
public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): void;
public function checkDbUpdate(
?callable $beforeDownload = null,
?callable $handleProgress = null,
): GeolocationResult;
}

View file

@ -0,0 +1,11 @@
<?php
namespace Shlinkio\Shlink\CLI\GeoLite;
enum GeolocationResult
{
case CHECK_SKIPPED;
case DB_CREATED;
case DB_UPDATED;
case DB_IS_UP_TO_DATE;
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace ShlinkioCliTest\Shlink\CLI\Command;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
class GenerateApiKeyTest extends CliTestCase
{
/** @test */
public function outputIsCorrect(): void
{
[$output, $exitCode] = $this->exec([GenerateKeyCommand::NAME]);
self::assertStringContainsString('[OK] Generated API key', $output);
self::assertEquals(ExitCodes::EXIT_SUCCESS, $exitCode);
}
}

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace ShlinkioCliTest\Shlink\CLI\Command;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
class ListApiKeysTest extends CliTestCase
{
/**
* @test
* @dataProvider provideFlags
*/
public function generatesExpectedOutput(array $flags, string $expectedOutput): void
{
[$output, $exitCode] = $this->exec([ListKeysCommand::NAME, ...$flags]);
self::assertEquals($expectedOutput, $output);
self::assertEquals(ExitCodes::EXIT_SUCCESS, $exitCode);
}
public function provideFlags(): iterable
{
$expiredApiKeyDate = Chronos::now()->subDay()->startOfDay()->toAtomString();
$enabledOnlyOutput = <<<OUT
+-----------------+------+---------------------------+--------------------------+
| Key | Name | Expiration date | Roles |
+-----------------+------+---------------------------+--------------------------+
| valid_api_key | - | - | Admin |
+-----------------+------+---------------------------+--------------------------+
| expired_api_key | - | {$expiredApiKeyDate} | Admin |
+-----------------+------+---------------------------+--------------------------+
| author_api_key | - | - | Author only |
+-----------------+------+---------------------------+--------------------------+
| domain_api_key | - | - | Domain only: example.com |
+-----------------+------+---------------------------+--------------------------+
OUT;
yield 'no flags' => [[], <<<OUT
+------------------+------+------------+---------------------------+--------------------------+
| Key | Name | Is enabled | Expiration date | Roles |
+------------------+------+------------+---------------------------+--------------------------+
| valid_api_key | - | +++ | - | Admin |
+------------------+------+------------+---------------------------+--------------------------+
| disabled_api_key | - | --- | - | Admin |
+------------------+------+------------+---------------------------+--------------------------+
| expired_api_key | - | --- | {$expiredApiKeyDate} | Admin |
+------------------+------+------------+---------------------------+--------------------------+
| author_api_key | - | +++ | - | Author only |
+------------------+------+------------+---------------------------+--------------------------+
| domain_api_key | - | +++ | - | Domain only: example.com |
+------------------+------+------------+---------------------------+--------------------------+
OUT];
yield '-e' => [['-e'], $enabledOnlyOutput];
yield '--enabled-only' => [['--enabled-only'], $enabledOnlyOutput];
}
}

View file

@ -19,7 +19,7 @@ class DisableKeyCommandTest extends TestCase
private CommandTester $commandTester;
private ObjectProphecy $apiKeyService;
public function setUp(): void
protected function setUp(): void
{
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DisableKeyCommand($this->apiKeyService->reveal()));

View file

@ -23,7 +23,7 @@ class GenerateKeyCommandTest extends TestCase
private CommandTester $commandTester;
private ObjectProphecy $apiKeyService;
public function setUp(): void
protected function setUp(): void
{
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
$roleResolver = $this->prophesize(RoleResolverInterface::class);

View file

@ -23,7 +23,7 @@ class ListKeysCommandTest extends TestCase
private CommandTester $commandTester;
private ObjectProphecy $apiKeyService;
public function setUp(): void
protected function setUp(): void
{
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
$this->commandTester = $this->testerForCommand(new ListKeysCommand($this->apiKeyService->reveal()));

View file

@ -33,7 +33,7 @@ class CreateDatabaseCommandTest extends TestCase
private ObjectProphecy $schemaManager;
private ObjectProphecy $driver;
public function setUp(): void
protected function setUp(): void
{
$locker = $this->prophesize(LockFactory::class);
$lock = $this->prophesize(LockInterface::class);

View file

@ -23,7 +23,7 @@ class MigrateDatabaseCommandTest extends TestCase
private CommandTester $commandTester;
private ObjectProphecy $processHelper;
public function setUp(): void
protected function setUp(): void
{
$locker = $this->prophesize(LockFactory::class);
$lock = $this->prophesize(LockInterface::class);

View file

@ -24,7 +24,7 @@ class DomainRedirectsCommandTest extends TestCase
private CommandTester $commandTester;
private ObjectProphecy $domainService;
public function setUp(): void
protected function setUp(): void
{
$this->domainService = $this->prophesize(DomainServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DomainRedirectsCommand($this->domainService->reveal()));

View file

@ -23,7 +23,7 @@ class ListDomainsCommandTest extends TestCase
private CommandTester $commandTester;
private ObjectProphecy $domainService;
public function setUp(): void
protected function setUp(): void
{
$this->domainService = $this->prophesize(DomainServiceInterface::class);
$this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService->reveal()));
@ -43,10 +43,10 @@ class ListDomainsCommandTest extends TestCase
));
$listDomains = $this->domainService->listDomains()->willReturn([
DomainItem::forDefaultDomain('foo.com', new NotFoundRedirectOptions([
'base_url' => 'https://foo.com/default/base',
'invalid_short_url' => 'https://foo.com/default/invalid',
])),
DomainItem::forDefaultDomain('foo.com', new NotFoundRedirectOptions(
invalidShortUrl: 'https://foo.com/default/invalid',
baseUrl: 'https://foo.com/default/base',
)),
DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com')),
DomainItem::forNonDefaultDomain($bazDomain),
]);

View file

@ -30,7 +30,7 @@ class CreateShortUrlCommandTest extends TestCase
private ObjectProphecy $urlShortener;
private ObjectProphecy $stringifier;
public function setUp(): void
protected function setUp(): void
{
$this->urlShortener = $this->prophesize(UrlShortener::class);
$this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class);
@ -39,7 +39,7 @@ class CreateShortUrlCommandTest extends TestCase
$command = new CreateShortUrlCommand(
$this->urlShortener->reveal(),
$this->stringifier->reveal(),
new UrlShortenerOptions(['defaultShortCodesLength' => 5, 'domain' => ['hostname' => self::DEFAULT_DOMAIN]]),
new UrlShortenerOptions(domain: ['hostname' => self::DEFAULT_DOMAIN], defaultShortCodesLength: 5),
);
$this->commandTester = $this->testerForCommand($command);
}

View file

@ -26,7 +26,7 @@ class DeleteShortUrlCommandTest extends TestCase
private CommandTester $commandTester;
private ObjectProphecy $service;
public function setUp(): void
protected function setUp(): void
{
$this->service = $this->prophesize(DeleteShortUrlServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DeleteShortUrlCommand($this->service->reveal()));

View file

@ -33,7 +33,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
private CommandTester $commandTester;
private ObjectProphecy $visitsHelper;
public function setUp(): void
protected function setUp(): void
{
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$command = new GetShortUrlVisitsCommand($this->visitsHelper->reveal());

View file

@ -33,7 +33,7 @@ class ListShortUrlsCommandTest extends TestCase
private CommandTester $commandTester;
private ObjectProphecy $shortUrlService;
public function setUp(): void
protected function setUp(): void
{
$this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class);
$command = new ListShortUrlsCommand($this->shortUrlService->reveal(), new ShortUrlDataTransformer(

View file

@ -25,7 +25,7 @@ class ResolveUrlCommandTest extends TestCase
private CommandTester $commandTester;
private ObjectProphecy $urlResolver;
public function setUp(): void
protected function setUp(): void
{
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$this->commandTester = $this->testerForCommand(new ResolveUrlCommand($this->urlResolver->reveal()));

View file

@ -18,7 +18,7 @@ class DeleteTagsCommandTest extends TestCase
private CommandTester $commandTester;
private ObjectProphecy $tagService;
public function setUp(): void
protected function setUp(): void
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DeleteTagsCommand($this->tagService->reveal()));

View file

@ -22,7 +22,7 @@ class ListTagsCommandTest extends TestCase
private CommandTester $commandTester;
private ObjectProphecy $tagService;
public function setUp(): void
protected function setUp(): void
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
$this->commandTester = $this->testerForCommand(new ListTagsCommand($this->tagService->reveal()));

View file

@ -21,7 +21,7 @@ class RenameTagCommandTest extends TestCase
private CommandTester $commandTester;
private ObjectProphecy $tagService;
public function setUp(): void
protected function setUp(): void
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
$this->commandTester = $this->testerForCommand(new RenameTagCommand($this->tagService->reveal()));

View file

@ -9,8 +9,9 @@ use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester;
@ -97,11 +98,12 @@ class DownloadGeoLiteDbCommandTest extends TestCase
public function provideSuccessParams(): iterable
{
yield 'up to date db' => [function (): void {
}, '[INFO] GeoLite2 db file is up to date.'];
yield 'outdated db' => [function (array $args): void {
yield 'up to date db' => [fn () => GeolocationResult::CHECK_SKIPPED, '[INFO] GeoLite2 db file is up to date.'];
yield 'outdated db' => [function (array $args): GeolocationResult {
[$beforeDownload] = $args;
$beforeDownload(true);
return GeolocationResult::DB_CREATED;
}, '[OK] GeoLite2 db file properly downloaded.'];
}
}

View file

@ -10,16 +10,16 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand;
use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\VisitGeolocationHelperInterface;
use Shlinkio\Shlink\Core\Visit\VisitLocator;
use Shlinkio\Shlink\Core\Visit\VisitToLocationHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Output\OutputInterface;
@ -36,14 +36,14 @@ class LocateVisitsCommandTest extends TestCase
private CommandTester $commandTester;
private ObjectProphecy $visitService;
private ObjectProphecy $ipResolver;
private ObjectProphecy $visitToLocation;
private ObjectProphecy $lock;
private ObjectProphecy $downloadDbCommand;
public function setUp(): void
protected function setUp(): void
{
$this->visitService = $this->prophesize(VisitLocator::class);
$this->ipResolver = $this->prophesize(IpLocationResolverInterface::class);
$this->visitToLocation = $this->prophesize(VisitToLocationHelperInterface::class);
$locker = $this->prophesize(Lock\LockFactory::class);
$this->lock = $this->prophesize(Lock\LockInterface::class);
@ -54,7 +54,7 @@ class LocateVisitsCommandTest extends TestCase
$command = new LocateVisitsCommand(
$this->visitService->reveal(),
$this->ipResolver->reveal(),
$this->visitToLocation->reveal(),
$locker->reveal(),
);
@ -84,7 +84,7 @@ class LocateVisitsCommandTest extends TestCase
$mockMethodBehavior,
);
$locateAllVisits = $this->visitService->locateAllVisits(Argument::cetera())->will($mockMethodBehavior);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
$resolveIpLocation = $this->visitToLocation->resolveVisitLocation(Argument::any())->willReturn(
Location::emptyInstance(),
);
@ -117,36 +117,29 @@ class LocateVisitsCommandTest extends TestCase
* @test
* @dataProvider provideIgnoredAddresses
*/
public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message): void
public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void
{
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $address, ''));
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance());
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
$this->invokeHelperMethods($visit, $location),
);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
Location::emptyInstance(),
);
$resolveIpLocation = $this->visitToLocation->resolveVisitLocation(Argument::any())->willThrow($e);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('Processing IP', $output);
self::assertStringContainsString($message, $output);
if (empty($address)) {
self::assertStringNotContainsString('Processing IP', $output);
} else {
self::assertStringContainsString('Processing IP', $output);
}
$locateVisits->shouldHaveBeenCalledOnce();
$resolveIpLocation->shouldNotHaveBeenCalled();
$resolveIpLocation->shouldHaveBeenCalledOnce();
}
public function provideIgnoredAddresses(): iterable
{
yield 'with empty address' => ['', 'Ignored visit with no IP address'];
yield 'with null address' => [null, 'Ignored visit with no IP address'];
yield 'with localhost address' => [IpAddress::LOCALHOST, 'Ignored localhost address'];
yield 'empty address' => [IpCannotBeLocatedException::forEmptyAddress(), 'Ignored visit with no IP address'];
yield 'localhost address' => [IpCannotBeLocatedException::forLocalhost(), 'Ignored localhost address'];
}
/** @test */
@ -158,7 +151,9 @@ class LocateVisitsCommandTest extends TestCase
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
$this->invokeHelperMethods($visit, $location),
);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willThrow(WrongIpException::class);
$resolveIpLocation = $this->visitToLocation->resolveVisitLocation(Argument::any())->willThrow(
IpCannotBeLocatedException::forError(WrongIpException::fromIpAddress('1.2.3.4')),
);
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
@ -187,7 +182,7 @@ class LocateVisitsCommandTest extends TestCase
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(function (): void {
});
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([]);
$resolveIpLocation = $this->visitToLocation->resolveVisitLocation(Argument::any());
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();

View file

@ -12,7 +12,7 @@ class ConfigProviderTest extends TestCase
{
private ConfigProvider $configProvider;
public function setUp(): void
protected function setUp(): void
{
$this->configProvider = new ConfigProvider();
}

View file

@ -16,7 +16,7 @@ class ApplicationFactoryTest extends TestCase
private ApplicationFactory $factory;
public function setUp(): void
protected function setUp(): void
{
$this->factory = new ApplicationFactory();
}

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Util;
namespace ShlinkioTest\Shlink\CLI\GeoLite;
use Cake\Chronos\Chronos;
use GeoIp2\Database\Reader;
@ -12,9 +12,10 @@ use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdater;
use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException;
use Shlinkio\Shlink\IpGeolocation\Exception\DbUpdateException;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Symfony\Component\Lock;
use Throwable;
@ -29,42 +30,32 @@ class GeolocationDbUpdaterTest extends TestCase
private GeolocationDbUpdater $geolocationDbUpdater;
private ObjectProphecy $dbUpdater;
private ObjectProphecy $geoLiteDbReader;
private TrackingOptions $trackingOptions;
private ObjectProphecy $lock;
public function setUp(): void
protected function setUp(): void
{
$this->dbUpdater = $this->prophesize(DbUpdaterInterface::class);
$this->geoLiteDbReader = $this->prophesize(Reader::class);
$this->trackingOptions = new TrackingOptions();
$locker = $this->prophesize(Lock\LockFactory::class);
$this->lock = $this->prophesize(Lock\LockInterface::class);
$this->lock->acquire(true)->willReturn(true);
$this->lock->release()->will(function (): void {
});
$locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal());
$this->geolocationDbUpdater = new GeolocationDbUpdater(
$this->dbUpdater->reveal(),
$this->geoLiteDbReader->reveal(),
$locker->reveal(),
$this->trackingOptions,
);
}
/** @test */
public function exceptionIsThrownWhenOlderDbDoesNotExistAndDownloadFails(): void
{
$mustBeUpdated = fn () => self::assertTrue(true);
$prev = new RuntimeException('');
$prev = new DbUpdateException('');
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(false);
$getMeta = $this->geoLiteDbReader->metadata();
$download = $this->dbUpdater->downloadFreshCopy(null)->willThrow($prev);
try {
$this->geolocationDbUpdater->checkDbUpdate($mustBeUpdated);
$this->geolocationDbUpdater()->checkDbUpdate($mustBeUpdated);
self::assertTrue(false); // If this is reached, the test will fail
} catch (Throwable $e) {
/** @var GeolocationDbUpdateFailedException $e */
@ -90,11 +81,11 @@ class GeolocationDbUpdaterTest extends TestCase
$getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch(
Chronos::now()->subDays($days)->getTimestamp(),
));
$prev = new RuntimeException('');
$prev = new DbUpdateException('');
$download = $this->dbUpdater->downloadFreshCopy(null)->willThrow($prev);
try {
$this->geolocationDbUpdater->checkDbUpdate();
$this->geolocationDbUpdater()->checkDbUpdate();
self::assertTrue(false); // If this is reached, the test will fail
} catch (Throwable $e) {
/** @var GeolocationDbUpdateFailedException $e */
@ -120,15 +111,16 @@ class GeolocationDbUpdaterTest extends TestCase
* @test
* @dataProvider provideSmallDays
*/
public function databaseIsNotUpdatedIfItIsYoungerThanOneWeek(string|int $buildEpoch): void
public function databaseIsNotUpdatedIfItIsNewEnough(string|int $buildEpoch): void
{
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true);
$getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch($buildEpoch));
$download = $this->dbUpdater->downloadFreshCopy(null)->will(function (): void {
});
$this->geolocationDbUpdater->checkDbUpdate();
$result = $this->geolocationDbUpdater()->checkDbUpdate();
self::assertEquals(GeolocationResult::DB_IS_UP_TO_DATE, $result);
$fileExists->shouldHaveBeenCalledOnce();
$getMeta->shouldHaveBeenCalledOnce();
$download->shouldNotHaveBeenCalled();
@ -160,7 +152,7 @@ class GeolocationDbUpdaterTest extends TestCase
$getMeta->shouldBeCalledOnce();
$download->shouldNotBeCalled();
$this->geolocationDbUpdater->checkDbUpdate();
$this->geolocationDbUpdater()->checkDbUpdate();
}
private function buildMetaWithBuildEpoch(string|int $buildEpoch): Metadata
@ -182,22 +174,32 @@ class GeolocationDbUpdaterTest extends TestCase
* @test
* @dataProvider provideTrackingOptions
*/
public function downloadDbIsSkippedIfTrackingIsDisabled(array $props): void
public function downloadDbIsSkippedIfTrackingIsDisabled(TrackingOptions $options): void
{
foreach ($props as $prop) {
$this->trackingOptions->{$prop} = true;
}
$this->geolocationDbUpdater->checkDbUpdate();
$result = $this->geolocationDbUpdater($options)->checkDbUpdate();
self::assertEquals(GeolocationResult::CHECK_SKIPPED, $result);
$this->dbUpdater->databaseFileExists(Argument::cetera())->shouldNotHaveBeenCalled();
$this->geoLiteDbReader->metadata(Argument::cetera())->shouldNotHaveBeenCalled();
}
public function provideTrackingOptions(): iterable
{
yield 'disableTracking' => [['disableTracking']];
yield 'disableIpTracking' => [['disableIpTracking']];
yield 'both' => [['disableTracking', 'disableIpTracking']];
yield 'disableTracking' => [new TrackingOptions(disableTracking: true)];
yield 'disableIpTracking' => [new TrackingOptions(disableIpTracking: true)];
yield 'both' => [new TrackingOptions(disableTracking: true, disableIpTracking: true)];
}
private function geolocationDbUpdater(?TrackingOptions $options = null): GeolocationDbUpdater
{
$locker = $this->prophesize(Lock\LockFactory::class);
$locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal());
return new GeolocationDbUpdater(
$this->dbUpdater->reveal(),
$this->geoLiteDbReader->reveal(),
$locker->reveal(),
$options ?? new TrackingOptions(),
);
}
}

View file

@ -21,7 +21,7 @@ class ShlinkTableTest extends TestCase
private ShlinkTable $shlinkTable;
private ObjectProphecy $baseTable;
public function setUp(): void
protected function setUp(): void
{
$this->baseTable = $this->prophesize(Table::class);
$this->shlinkTable = ShlinkTable::fromBaseTable($this->baseTable->reveal());

View file

@ -7,9 +7,11 @@ namespace Shlinkio\Shlink\Core;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Config\Factory\ValinorConfigFactory;
use Shlinkio\Shlink\Core\ErrorHandler;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
return [
@ -20,14 +22,14 @@ return [
ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class,
ErrorHandler\NotFoundTemplateHandler::class => InvokableFactory::class,
Options\AppOptions::class => ConfigAbstractFactory::class,
Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class,
Options\NotFoundRedirectOptions::class => ConfigAbstractFactory::class,
Options\RedirectOptions::class => ConfigAbstractFactory::class,
Options\UrlShortenerOptions::class => ConfigAbstractFactory::class,
Options\TrackingOptions::class => ConfigAbstractFactory::class,
Options\QrCodeOptions::class => ConfigAbstractFactory::class,
Options\RabbitMqOptions::class => ConfigAbstractFactory::class,
Options\AppOptions::class => [ValinorConfigFactory::class, 'config.app_options'],
Options\DeleteShortUrlsOptions::class => [ValinorConfigFactory::class, 'config.delete_short_urls'],
Options\NotFoundRedirectOptions::class => [ValinorConfigFactory::class, 'config.not_found_redirects'],
Options\RedirectOptions::class => [ValinorConfigFactory::class, 'config.redirects'],
Options\UrlShortenerOptions::class => [ValinorConfigFactory::class, 'config.url_shortener'],
Options\TrackingOptions::class => [ValinorConfigFactory::class, 'config.tracking'],
Options\QrCodeOptions::class => [ValinorConfigFactory::class, 'config.qr_codes'],
Options\RabbitMqOptions::class => [ValinorConfigFactory::class, 'config.rabbitmq'],
Options\WebhookOptions::class => ConfigAbstractFactory::class,
Service\UrlShortener::class => ConfigAbstractFactory::class,
@ -43,6 +45,7 @@ return [
Visit\VisitsTracker::class => ConfigAbstractFactory::class,
Visit\RequestTracker::class => ConfigAbstractFactory::class,
Visit\VisitLocator::class => ConfigAbstractFactory::class,
Visit\VisitToLocationHelper::class => ConfigAbstractFactory::class,
Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
Visit\Transformer\OrphanVisitDataTransformer::class => InvokableFactory::class,
@ -85,14 +88,6 @@ return [
Domain\DomainService::class,
],
Options\AppOptions::class => ['config.app_options'],
Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'],
Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'],
Options\RedirectOptions::class => ['config.redirects'],
Options\UrlShortenerOptions::class => ['config.url_shortener'],
Options\TrackingOptions::class => ['config.tracking'],
Options\QrCodeOptions::class => ['config.qr_codes'],
Options\RabbitMqOptions::class => ['config.rabbitmq'],
Options\WebhookOptions::class => ['config.visits_webhooks'],
Service\UrlShortener::class => [
@ -115,6 +110,7 @@ return [
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
],
Visit\VisitLocator::class => ['em'],
Visit\VisitToLocationHelper::class => [IpLocationResolverInterface::class],
Visit\VisitsStatsHelper::class => ['em'],
Tag\TagService::class => ['em'],
Service\ShortUrl\DeleteShortUrlService::class => [

View file

@ -6,10 +6,12 @@ namespace Shlinkio\Shlink\Core;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdater;
use Shlinkio\Shlink\Common\Cache\RedisPublishingHelper;
use Shlinkio\Shlink\Common\Mercure\MercureHubPublishingHelper;
use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelper;
use Shlinkio\Shlink\Core\Visit\VisitLocator;
use Shlinkio\Shlink\Core\Visit\VisitToLocationHelper;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
@ -20,6 +22,9 @@ return [
EventDispatcher\Event\UrlVisited::class => [
EventDispatcher\LocateVisit::class,
],
EventDispatcher\Event\GeoLiteDbCreated::class => [
EventDispatcher\LocateUnlocatedVisits::class,
],
],
'async' => [
EventDispatcher\Event\VisitLocated::class => [
@ -40,6 +45,7 @@ return [
'dependencies' => [
'factories' => [
EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class,
EventDispatcher\LocateUnlocatedVisits::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => ConfigAbstractFactory::class,
@ -69,6 +75,9 @@ return [
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\LocateUnlocatedVisits::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\NotifyVisitToWebHooks::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
@ -83,6 +92,7 @@ return [
DbUpdater::class,
EventDispatcherInterface::class,
],
EventDispatcher\LocateUnlocatedVisits::class => [VisitLocator::class, VisitToLocationHelper::class],
EventDispatcher\NotifyVisitToWebHooks::class => [
'httpClient',
'em',
@ -132,7 +142,11 @@ return [
'Logger_Shlink',
'config.redis.pub_sub_enabled',
],
EventDispatcher\UpdateGeoLiteDb::class => [GeolocationDbUpdater::class, 'Logger_Shlink'],
EventDispatcher\UpdateGeoLiteDb::class => [
GeolocationDbUpdater::class,
'Logger_Shlink',
EventDispatcherInterface::class,
],
],
];

View file

@ -127,3 +127,8 @@ function camelCaseToHumanFriendly(string $value): string
return ucfirst($filter->filter($value));
}
function toProblemDetailsType(string $errorCode): string
{
return sprintf('https://shlink.io/api/error/%s', $errorCode);
}

View file

@ -19,8 +19,8 @@ use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface
{
public function __construct(
private ShortUrlResolverInterface $urlResolver,
private RequestTrackerInterface $requestTracker,
private readonly ShortUrlResolverInterface $urlResolver,
private readonly RequestTrackerInterface $requestTracker,
) {
}

View file

@ -52,7 +52,7 @@ final class QrCodeParams
private static function resolveSize(array $query, QrCodeOptions $defaults): int
{
$size = (int) ($query['size'] ?? $defaults->size());
$size = (int) ($query['size'] ?? $defaults->size);
if ($size < self::MIN_SIZE) {
return self::MIN_SIZE;
}
@ -62,7 +62,7 @@ final class QrCodeParams
private static function resolveMargin(array $query, QrCodeOptions $defaults): int
{
$margin = $query['margin'] ?? (string) $defaults->margin();
$margin = $query['margin'] ?? (string) $defaults->margin;
$intMargin = (int) $margin;
if ($margin !== (string) $intMargin) {
return 0;
@ -74,7 +74,7 @@ final class QrCodeParams
private static function resolveWriter(array $query, QrCodeOptions $defaults): WriterInterface
{
$qFormat = self::normalizeParam($query['format'] ?? '');
$format = contains(self::SUPPORTED_FORMATS, $qFormat) ? $qFormat : self::normalizeParam($defaults->format());
$format = contains(self::SUPPORTED_FORMATS, $qFormat) ? $qFormat : self::normalizeParam($defaults->format);
return match ($format) {
'svg' => new SvgWriter(),
@ -84,7 +84,7 @@ final class QrCodeParams
private static function resolveErrorCorrection(array $query, QrCodeOptions $defaults): ErrorCorrectionLevelInterface
{
$errorCorrectionLevel = self::normalizeParam($query['errorCorrection'] ?? $defaults->errorCorrection());
$errorCorrectionLevel = self::normalizeParam($query['errorCorrection'] ?? $defaults->errorCorrection);
return match ($errorCorrectionLevel) {
'h' => new ErrorCorrectionLevelHigh(),
'q' => new ErrorCorrectionLevelQuartile(),
@ -97,7 +97,7 @@ final class QrCodeParams
{
$doNotRoundBlockSize = isset($query['roundBlockSize'])
? $query['roundBlockSize'] === 'false'
: ! $defaults->roundBlockSize();
: ! $defaults->roundBlockSize;
return $doNotRoundBlockSize ? new RoundBlockSizeModeNone() : new RoundBlockSizeModeMargin();
}

View file

@ -43,9 +43,11 @@ enum EnvVars: string
case REDIRECT_STATUS_CODE = 'REDIRECT_STATUS_CODE';
case REDIRECT_CACHE_LIFETIME = 'REDIRECT_CACHE_LIFETIME';
case BASE_PATH = 'BASE_PATH';
case SHORT_URL_TRAILING_SLASH = 'SHORT_URL_TRAILING_SLASH';
case PORT = 'PORT';
case TASK_WORKER_NUM = 'TASK_WORKER_NUM';
case WEB_WORKER_NUM = 'WEB_WORKER_NUM';
case INITIAL_API_KEY = 'INITIAL_API_KEY';
case ANONYMIZE_REMOTE_ADDR = 'ANONYMIZE_REMOTE_ADDR';
case TRACK_ORPHAN_VISITS = 'TRACK_ORPHAN_VISITS';
case DISABLE_TRACK_PARAM = 'DISABLE_TRACK_PARAM';

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