Compare commits

..

3 Commits
4.x ... v3.7.4

Author SHA1 Message Date
Alejandro Celaya
8888e33ccc Merge pull request #2052 from acelaya-forks/feature/fix-geolite-update-backport
Fix infinite GeoLite2 downloads - v3.x backport
2024-03-09 09:53:00 +01:00
Alejandro Celaya
250f9f2d89 Update changelog 2024-03-09 09:37:28 +01:00
Alejandro Celaya
9fd864df0b Make sure GeoLite2 db file is always read from the filesystem befor etrying to operate on it 2024-03-09 09:36:29 +01:00
737 changed files with 12958 additions and 19126 deletions

View File

@@ -1,6 +1,5 @@
bin/rr
config/autoload/*local*
config/params/shlink_dev_env.*
data/infra
data/cache/*
data/log/*
@@ -20,6 +19,7 @@ indocker
docker-*
phpstan.neon
php*xml*
infection*
**/test*
build*
**/.*

1
.gitattributes vendored
View File

@@ -13,6 +13,7 @@
.travis.yml export-ignore
build.sh export-ignore
CHANGELOG.md export-ignore
docker-compose.override.yml.dist export-ignore
docker-compose.yml export-ignore
indocker export-ignore
phpcs.xml export-ignore

View File

@@ -0,0 +1,51 @@
title: 'Help wanted'
body:
- type: input
validations:
required: true
attributes:
label: Shlink version
placeholder: x.y.z
- type: input
validations:
required: true
attributes:
label: PHP version
placeholder: x.y.z
- type: dropdown
validations:
required: true
attributes:
label: How do you serve Shlink
options:
- Self-hosted Apache
- Self-hosted nginx
- Self-hosted openswoole
- Self-hosted RoadRunner
- Openswoole Docker image
- RoadRunner Docker image
- Other (explain in summary)
- type: dropdown
validations:
required: true
attributes:
label: Database engine
options:
- MySQL
- MariaDB
- PostgreSQL
- MicrosoftSQL
- SQLite
- type: input
validations:
required: true
attributes:
label: Database version
placeholder: x.y.z
- type: textarea
validations:
required: true
attributes:
label: Summary
value: '<!-- Describe your issue, question or request here. -->'

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +1,2 @@
github: ['acelaya']
custom: ['https://slnk.to/donate']
custom: ['https://acel.me/donate']

7
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,7 @@
<!--
Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personally if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
-->

View File

@@ -22,8 +22,10 @@ body:
options:
- Self-hosted Apache
- Self-hosted nginx
- Self-hosted openswoole
- Self-hosted RoadRunner
- Docker image
- Openswoole Docker image
- RoadRunner Docker image
- Other (explain in summary)
- type: dropdown
validations:
@@ -58,14 +60,5 @@ body:
validations:
required: true
attributes:
label: Minimum steps to reproduce
value: |
<!--
Simple but detailed way to reproduce the bug:
* Avoid things like "create a kubernetes cluster", or anything related with cloud providers, as that is rarely the root cause.
* Avoid too vague steps or one-liners like "Update from v1 to v2".
* Providing the reproduction in the form of a self-contained docker-composer is desirable.
Failing in any of these will cause the issue to be closed as "not reproducible".
-->
label: How to reproduce
value: '<!-- Provide steps to reproduce the bug. -->'

View File

@@ -2,4 +2,4 @@ blank_issues_enabled: true
contact_links:
- name: Question - Support
about: Do you need help setting up or using Shlink?
url: https://github.com/orgs/shlinkio/discussions/new?category=help-wanted
url: https://github.com/shlinkio/shlink/discussions/new?category=help-wanted

View File

@@ -12,6 +12,7 @@ inputs:
php-extensions:
description: 'The PHP extensions to install'
required: false
default: ''
extensions-cache-key:
description: 'The key used to cache PHP extensions. If empty value is provided, extension caching is disabled'
required: true
@@ -20,7 +21,6 @@ runs:
using: composite
steps:
- name: Setup cache environment
if: ${{ inputs.php-extensions }}
id: extcache
uses: shivammathur/cache-extensions@v1
with:
@@ -28,8 +28,7 @@ runs:
extensions: ${{ inputs.php-extensions }}
key: ${{ inputs.extensions-cache-key }}
- name: Cache extensions
if: ${{ inputs.php-extensions }}
uses: actions/cache@v4
uses: actions/cache@v3
with:
path: ${{ steps.extcache.outputs.dir }}
key: ${{ steps.extcache.outputs.key }}
@@ -40,8 +39,9 @@ runs:
php-version: ${{ inputs.php-version }}
tools: composer
extensions: ${{ inputs.php-extensions }}
coverage: xdebug
coverage: pcov
ini-values: pcov.directory=module
- name: Install dependencies
if: ${{ inputs.install-deps == 'yes' }}
run: composer install --no-interaction --prefer-dist
run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.3' && '--ignore-platform-reqs' || '' }}
shell: bash

View File

@@ -10,10 +10,11 @@ on:
jobs:
db-tests:
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.3', '8.4', '8.5']
php-version: ['8.2', '8.3']
continue-on-error: ${{ matrix.php-version == '8.3' }}
env:
LC_ALL: C
steps:
@@ -23,20 +24,20 @@ jobs:
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 }}
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: pdo_sqlsrv-5.12.0
php-extensions: openswoole-22.1.0, pdo_sqlsrv-5.11.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-tools18/bin/sqlcmd -C -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
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@v4
if: ${{ matrix.php-version == '8.3' && inputs.platform == 'sqlite:ci' }}
if: ${{ matrix.php-version == '8.2' && inputs.platform == 'sqlite:ci' }}
with:
name: coverage-db
path: |

View File

@@ -1,4 +1,4 @@
name: Test docker image build
name: Build docker image
on:
pull_request:
@@ -7,6 +7,8 @@ on:
jobs:
build-docker-image:
uses: shlinkio/github-actions/.github/workflows/docker-image-build-ci.yml@main
with:
platforms: 'linux/arm64/v8,linux/amd64'
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v4
- run: docker build -t shlink-docker-image:temp .

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

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

View File

@@ -10,30 +10,27 @@ on:
jobs:
tests:
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.3', '8.4', '8.5']
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
php-version: ['8.2', '8.3']
continue-on-error: ${{ matrix.php-version == '8.3' }}
steps:
- uses: actions/checkout@v4
- 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
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
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-22.1.0
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
- name: Download RoadRunner binary
if: ${{ inputs.test-group == 'api' }}
run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr
- run: composer test:${{ inputs.test-group }}:ci
- uses: actions/upload-artifact@v4
if: ${{ matrix.php-version == '8.3' }}
if: ${{ matrix.php-version == '8.2' }}
with:
name: coverage-${{ inputs.test-group }}
path: |

View File

@@ -8,6 +8,7 @@ on:
- '*.md'
- '*.xml'
- '*.yml*'
- '*.json5'
- '*.neon'
push:
branches:
@@ -20,20 +21,22 @@ on:
- '*.md'
- '*.xml'
- '*.yml*'
- '*.json5'
- '*.neon'
jobs:
static-analysis:
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.3']
command: ['cs', 'stan', 'openapi:validate']
php-version: ['8.2']
command: ['cs', 'stan', 'swagger:validate']
steps:
- uses: actions/checkout@v4
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-22.1.0
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ matrix.command }}
- run: composer ${{ matrix.command }}
@@ -47,37 +50,102 @@ jobs:
with:
test-group: cli
api-tests:
openswoole-api-tests:
uses: './.github/workflows/ci-tests.yml'
with:
test-group: api
db-tests:
roadrunner-api-tests:
runs-on: ubuntu-22.04
strategy:
matrix:
platform: ['sqlite:ci', 'mysql', 'maria', 'postgres', 'ms']
php-version: ['8.2', '8.3']
continue-on-error: ${{ matrix.php-version == '8.3' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
steps:
- uses: actions/checkout@v4
- run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
- run: composer install --no-interaction --prefer-dist --ignore-platform-req=ext-openswoole ${{ matrix.php-version == '8.3' && '--ignore-platform-reqs' || '' }}
- run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr
- run: composer test:api:rr
sqlite-db-tests:
uses: './.github/workflows/ci-db-tests.yml'
with:
platform: ${{ matrix.platform }}
platform: 'sqlite:ci'
mysql-db-tests:
uses: './.github/workflows/ci-db-tests.yml'
with:
platform: 'mysql'
maria-db-tests:
uses: './.github/workflows/ci-db-tests.yml'
with:
platform: 'maria'
postgres-db-tests:
uses: './.github/workflows/ci-db-tests.yml'
with:
platform: 'postgres'
ms-db-tests:
uses: './.github/workflows/ci-db-tests.yml'
with:
platform: 'ms'
unit-mutation-tests:
needs:
- unit-tests
uses: './.github/workflows/ci-mutation-tests.yml'
with:
test-group: unit
db-mutation-tests:
needs:
- sqlite-db-tests
uses: './.github/workflows/ci-mutation-tests.yml'
with:
test-group: db
api-mutation-tests:
needs:
- openswoole-api-tests
uses: './.github/workflows/ci-mutation-tests.yml'
with:
test-group: api
cli-mutation-tests:
needs:
- cli-tests
uses: './.github/workflows/ci-mutation-tests.yml'
with:
test-group: cli
upload-coverage:
needs:
- unit-tests
- api-tests
- openswoole-api-tests
- cli-tests
- db-tests
runs-on: ubuntu-24.04
- sqlite-db-tests
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.3']
php-version: ['8.2']
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Use PHP
uses: './.github/actions/ci-setup'
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
extensions-cache-key: tests-extensions-${{ matrix.php-version }}
coverage: pcov
ini-values: pcov.directory=module
- uses: actions/download-artifact@v4
with:
path: build
@@ -85,18 +153,23 @@ jobs:
- run: mv build/coverage-db/coverage-db.cov build/coverage-db.cov
- run: mv build/coverage-api/coverage-api.cov build/coverage-api.cov
- run: mv build/coverage-cli/coverage-cli.cov build/coverage-cli.cov
- run: vendor/bin/phpcov merge build --clover build/clover.xml
- run: wget https://phar.phpunit.de/phpcov-9.0.0.phar
- run: php phpcov-9.0.0.phar merge build --clover build/clover.xml
- name: Publish coverage
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v1
with:
file: ./build/clover.xml
delete-artifacts:
needs:
- unit-mutation-tests
- db-mutation-tests
- api-mutation-tests
- cli-mutation-tests
- upload-coverage
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
steps:
- uses: geekyeggo/delete-artifact@v5
- uses: geekyeggo/delete-artifact@v2
with:
name: |
coverage-*

View File

@@ -15,7 +15,14 @@ jobs:
- runtime: 'rr'
tag-suffix: 'roadrunner'
platforms: 'linux/arm64/v8,linux/amd64'
uses: shlinkio/github-actions/.github/workflows/docker-publish-image.yml@main
- runtime: 'openswoole'
tag-suffix: 'openswoole'
platforms: 'linux/arm/v7,linux/arm64/v8,linux/amd64'
- runtime: 'rr'
tag-suffix: 'non-root'
platforms: 'linux/arm64/v8,linux/amd64'
user-id: '1001'
uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main
secrets: inherit
with:
image-name: shlinkio/shlink
@@ -24,3 +31,4 @@ jobs:
tags-suffix: ${{ matrix.tag-suffix }}
extra-build-args: |
SHLINK_RUNTIME=${{ matrix.runtime }}
SHLINK_USER_ID=${{ matrix.user-id && matrix.user-id || 'root' }}

View File

@@ -7,26 +7,31 @@ on:
jobs:
build:
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.3', '8.4', '8.5']
php-version: ['8.2', '8.3']
swoole: ['yes', 'no']
steps:
- uses: actions/checkout@v4
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-22.1.0
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
install-deps: 'no'
- run: ./build.sh ${GITHUB_REF#refs/tags/v}
- 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@v4
with:
name: dist-files-${{ matrix.php-version }}
name: dist-files-${{ matrix.php-version }}-${{ matrix.swoole }}
path: build
publish:
needs: ['build']
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
@@ -43,8 +48,8 @@ jobs:
delete-artifacts:
needs: ['publish']
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
steps:
- uses: geekyeggo/delete-artifact@v5
- uses: geekyeggo/delete-artifact@v2
with:
name: dist-files-*

View File

@@ -1,4 +1,4 @@
name: Publish openapi spec
name: Publish swagger spec
on:
push:
@@ -7,10 +7,10 @@ on:
jobs:
build:
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.3']
php-version: ['8.2']
steps:
- uses: actions/checkout@v4
- name: Determine version
@@ -20,10 +20,11 @@ jobs:
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
extensions-cache-key: publish-openapi-spec-extensions-${{ matrix.php-version }}
- run: composer openapi:inline
php-extensions: openswoole-22.1.0
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
- run: composer swagger:inline
- run: mkdir ${{ steps.determine_version.outputs.version }}
- run: mv docs/swagger/openapi-inlined.json ${{ steps.determine_version.outputs.version }}/open-api-spec.json
- run: mv docs/swagger/swagger-inlined.json ${{ steps.determine_version.outputs.version }}/open-api-spec.json
- name: Publish spec
uses: JamesIves/github-pages-deploy-action@v4
with:

6
.gitignore vendored
View File

@@ -1,6 +1,6 @@
.idea
bin/rr
.pid
config/roadrunner/.pid
build
!docker/build
composer.lock
@@ -10,6 +10,8 @@ data/database.sqlite
data/shlink-tests.db
data/GeoLite2-City.*
data/infra/matomo
docs/swagger-ui*
docs/mercure.html
docker-compose.override.yml
.phpunit.result.cache
docs/swagger/openapi-inlined.json
docs/swagger/swagger-inlined.json

File diff suppressed because it is too large Load Diff

View File

@@ -16,14 +16,11 @@ The first thing you need to do is fork the repository, and clone it in your loca
Then you will have to follow these steps:
* Copy the `config/params/shlink_dev_env.php.dist` in the same directory, but removing the `.dist` extension:
* Copy all files with `.local.php.dist` extension from `config/autoload` by removing the dist extension.
```
cp config/params/shlink_dev_env.php.dist config/params/shlink_dev_env.php
```
The `shlink_dev_env.php` file is gitignored, so you can customize it as you want. For example, by adding your own GeoLite license key.
For example the `common.local.php.dist` file should be copied as `common.local.php`.
* Copy the file `docker-compose.override.yml.dist` by also removing the `dist` extension.
* Start-up the project by running `docker compose up`.
The first time this command is run, it will create several containers that are used during development, so it may take some time.
@@ -34,7 +31,7 @@ Then you will have to follow these steps:
* Run `./indocker bin/cli db:migrate` to get database migrations up to date.
* Run `./indocker bin/cli api-key:generate` to get your first API key generated.
Once you finish this, you will have the project exposed in ports `8800` through RoadRunner and `8000` through nginx+php-fpm.
Once you finish this, you will have the project exposed in ports `8800` through RoadRunner, `8080` through openswoole and `8000` through nginx+php-fpm.
> Note: The `indocker` shell script is a helper tool used to run commands inside the main docker container.
@@ -83,7 +80,7 @@ The purposes of every folder are:
* `data`: Common git-ignored assets, like logs, caches, lock files, GeoLite DB files, etc. It's the only location where Shlink may need to write at runtime.
* `docs`: Any project documentation is stored here, like API spec definitions or architectural decision records.
* `module`: Contains a sub-folder for every module in the project. Modules contain the source code, tests and configurations for every context in the project.
* `public`: Few assets (like `favicon.ico` or `robots.txt`) and the web entry point are stored here. This web entry point is not used when serving the app with RoadRunner.
* `public`: Few assets (like `favicon.ico` or `robots.txt`) and the web entry point are stored here. This web entry point is not used when serving the app with RoadRunner or openswoole.
## Project tests
@@ -99,7 +96,7 @@ In order to ensure stability and no regressions are introduced while developing
The project provides some tooling to run them against any of the supported database engines.
* **API tests**: These are E2E tests that spin up an instance of the app with RoadRunner, and test it from the outside by interacting with the REST API.
* **API tests**: These are E2E tests that spin up an instance of the app with RoadRunner or openswoole, and test it from the outside by interacting with the REST API.
These are the best tests to catch regressions, and to verify everything behaves as expected.
@@ -127,6 +124,7 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed,
* Run `./indocker composer test:api` to run API E2E tests. For these, the Postgres database engine is used.
* Run `./indocker composer test:cli` to run CLI E2E tests. For these, the Maria DB database engine is used.
* Run `./indocker composer infect:test` to run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/).
* Run `./indocker composer ci` to run all previous commands together, parallelizing non-conflicting tasks as much as possible.
## Testing endpoints

View File

@@ -1,22 +1,23 @@
FROM php:8.4-alpine3.21 AS base
FROM php:8.2-alpine3.17 as base
ARG SHLINK_VERSION=latest
ENV SHLINK_VERSION=${SHLINK_VERSION}
ENV SHLINK_VERSION ${SHLINK_VERSION}
ARG SHLINK_RUNTIME=rr
ENV SHLINK_RUNTIME=${SHLINK_RUNTIME}
ENV SHLINK_RUNTIME ${SHLINK_RUNTIME}
ARG SHLINK_USER_ID='root'
ENV SHLINK_USER_ID ${SHLINK_USER_ID}
ENV USER_ID='1001'
ENV PDO_SQLSRV_VERSION='5.12.0'
ENV MS_ODBC_DOWNLOAD='7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
ENV MS_ODBC_SQL_VERSION='18_18.4.1.1'
ENV LC_ALL='C'
ENV OPENSWOOLE_VERSION 22.1.0
ENV PDO_SQLSRV_VERSION 5.11.1
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
ENV LC_ALL 'C'
WORKDIR /etc/shlink
# Install required PHP extensions
RUN \
# Temp install dev dependencies needed to compile the extensions
# FIXME Deprecated image-related extensions. They can be removed with QR-code support
apk add --no-cache --virtual .dev-deps sqlite-dev postgresql-dev icu-dev libzip-dev zlib-dev libpng-dev linux-headers && \
docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql intl calendar sockets bcmath zip gd && \
apk add --no-cache sqlite-libs && \
@@ -25,8 +26,13 @@ RUN \
apk del .dev-deps && \
apk add --no-cache postgresql icu libzip libpng
# Install sqlsrv driver for x86_64 builds
# Install openswoole and sqlsrv driver for x86_64 builds
RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \
# Openswoole is deprecated. Remove in v4.0.0
pecl install openswoole-${OPENSWOOLE_VERSION} && \
docker-php-ext-enable openswoole ; \
fi; \
if [ $(uname -m) == "x86_64" ]; then \
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
@@ -37,21 +43,28 @@ RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
apk del .phpize-deps
# Install shlink
FROM base AS builder
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 --prefer-dist --optimize-autoloader --no-progress --no-interaction && \
# FIXME Ignoring ext-openswoole platform req, as it makes install fail with roadrunner, even though it's a dev dependency and we are passing --no-dev
php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction --ignore-platform-req=ext-openswoole && \
if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \
# Openswoole is deprecated. Remove in v4.0.0
php composer.phar remove spiral/roadrunner spiral/roadrunner-jobs spiral/roadrunner-cli spiral/roadrunner-http --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction ; \
elif [ "$SHLINK_RUNTIME" == 'rr' ]; then \
php composer.phar remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction --ignore-platform-req=ext-openswoole ; \
fi; \
php composer.phar clear-cache && \
rm -r docker composer.* && \
sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" module/Core/src/Config/Options/AppOptions.php
sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php
# Prepare final image
FROM base
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
COPY --from=builder --chown=${USER_ID} /etc/shlink .
COPY --from=builder --chown=${SHLINK_USER_ID} /etc/shlink .
RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink && \
if [ "$SHLINK_RUNTIME" == 'rr' ]; then \
php ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr ; \
@@ -62,8 +75,9 @@ EXPOSE 8080
# Copy config specific for the image
COPY docker/docker-entrypoint.sh docker-entrypoint.sh
COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php
COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/
USER ${USER_ID}
USER ${SHLINK_USER_ID}
ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"]

View File

@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2016-2024 Alejandro Celaya
Copyright (c) 2016-2023 Alejandro Celaya
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -2,12 +2,12 @@
[![Build Status](https://img.shields.io/github/actions/workflow/status/shlinkio/shlink/ci.yml?branch=develop&logo=github&style=flat-square)](https://github.com/shlinkio/shlink/actions/workflows/ci.yml?query=workflow%3A%22Continuous+integration%22)
[![Code Coverage](https://img.shields.io/codecov/c/gh/shlinkio/shlink/develop?style=flat-square)](https://app.codecov.io/gh/shlinkio/shlink)
[![Infection MSI](https://img.shields.io/endpoint?style=flat-square&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fshlinkio%2Fshlink%2Fdevelop)](https://dashboard.stryker-mutator.io/reports/github.com/shlinkio/shlink/develop)
[![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
[![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/main/LICENSE)
[![Twitter](https://img.shields.io/badge/follow-shlinkio-blue.svg?style=flat-square&logo=x&color=black)](https://twitter.com/shlinkio)
[![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio)
[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlink.io)
[![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate)
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own domain.
@@ -36,13 +36,14 @@ The idea is that you can just generate a container using the image and provide t
First, make sure the host where you are going to run shlink fulfills these requirements:
* PHP 8.3 or 8.4
* PHP 8.2 or 8.3
* The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath.
* apcu extension is recommended if you don't plan to use RoadRunner.
* apcu extension is recommended if you don't plan to use openswoole.
* xml extension is required if you want to generate QR codes in svg format.
* sockets and bcmath extensions are required if you want to integrate with a RabbitMQ instance.
* MySQL, MariaDB, PostgreSQL, MicrosoftSQL or SQLite.
* You will also need the corresponding pdo variation for the database you are planning to use: `pdo_mysql`, `pdo_pgsql`, `pdo_sqlsrv` or `pdo_sqlite`.
* The [openswoole](https://openswoole.com/) PHP extension (if you plan to serve Shlink with openswoole) or the web server of your choice with PHP integration (like Apache or Nginx).
### Download
@@ -52,7 +53,7 @@ In order to run Shlink, you will need a built version of the project. There are
The easiest way to install shlink is by using one of the pre-bundled distributable packages.
Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink*_dist.zip` file that suits your needs. You will find one for every supported PHP version.
Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink*_dist.zip` file that suits your needs. You will find one for every supported PHP version and with/without openswoole integration.
Finally, decompress the file in the location of your choice.
@@ -99,12 +100,6 @@ Both the API and CLI allow you to do mostly the same operations, except for API
If you are trying to find out how to run the project in development mode or how to provide contributions, read the [CONTRIBUTING](CONTRIBUTING.md) doc.
## Powered by
Thanks to [JetBrains](https://www.jetbrains.com/) for their continuous support to this project in the form of IDE licenses.
![JetBrains logo](https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg)
---
> This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com)

View File

@@ -1,55 +1,5 @@
# Upgrading
## From v3.x to v4.x
### General
* Swoole and Openswoole are no longer officially supported runtimes. The recommended alternative is RoadRunner.
* Dist files for swoole/openswoole are no longer published.
* Webhooks are no longer supported. Migrate to one of the other [real-time updates](https://shlink.io/documentation/advanced/real-time-updates/) mechanisms.
* When using RoadRunner, the amount of web workers, task workers and the port number can no longer be provided via config options. Use `WEB_WORKER_NUM`, `TASK_WORKER_NUM` and `PORT` env vars instead.
### Changes in URL shortener
* The short URLs `loosely` mode is no longer supported, as it was a typo. Use `loose` mode instead.
* QR codes URLs now work by default, even for short URLs that cannot be visited due to max visits or date range limitations.
If you want to keep previous behavior, pass `QR_CODE_FOR_DISABLED_SHORT_URLS=false` or the equivalent configuration option.
* Long URL title resolution is now enabled by default. You can still disable it by passing `AUTO_RESOLVE_TITLES=false` or the equivalent configuration option.
* Shlink no longer allows to opt-in for long URL verification. Long URLs are unconditionally considered correct during short URL creation/edition.
* Device long URLs have been migrated to the new Dynamic rule-based redirects system and will continue to work as expected, but the API surface has changed.
If you use shlink-web-client and rely on this feature when creating/updating short URLs, **DO NOT UPDATE YET**. Support for dynamic rule-based redirects will be added to shlink-web-client soon, in v4.1.0
### Changes in REST API
* REST API v1/v2 now behave like v3. This only affects error codes, which are now proper URIs.
* `INVALID_ARGUMENT` -> `https://shlink.io/api/error/invalid-data`
* `INVALID_SHORT_URL_DELETION` -> `https://shlink.io/api/error/invalid-short-url-deletion`
* `DOMAIN_NOT_FOUND` -> `https://shlink.io/api/error/domain-not-found`
* `FORBIDDEN_OPERATION` -> `https://shlink.io/api/error/forbidden-tag-operation`
* `INVALID_SLUG` -> `https://shlink.io/api/error/non-unique-slug`
* `INVALID_SHORTCODE` -> `https://shlink.io/api/error/short-url-not-found`
* `TAG_CONFLICT` -> `https://shlink.io/api/error/tag-conflict`
* `TAG_NOT_FOUND` -> `https://shlink.io/api/error/tag-not-found`
* `MERCURE_NOT_CONFIGURED` -> `https://shlink.io/api/error/mercure-not-configured`
* `INVALID_AUTHORIZATION` -> `https://shlink.io/api/error/missing-authentication`
* `INVALID_API_KEY` -> `https://shlink.io/api/error/invalid-api-key`
* Endpoints previously returning props like `"visitsCount": {number}` no longer do it. There should be an alternative `"visitsSummary": {}` object with the amount nested on it.
* It is no longer possible to order the short URLs list with `orderBy=visitsCount-ASC`/`orderBy=visitsCount-DESC`. Use `orderBy=visits-ASC`/`orderBy=visits-DESC` instead.
* It is no longer possible to get tags with stats using `GET /tags?withStats=true`. Use `GET /tags/stats` endpoint instead.
* The `deviceLongUrls` are ignored when calling `POST /short-urls` or `PATCH /short-urls/{shortCode}`. These should now be configured as dynamic rule-based redirects via `POST /short-urls/{shortCode}/redirect-rules`.
### Changes in Docker image
* Since openswoole is no longer supported, there are no longer image tags suffixed with `openswoole`. You should migrate to the default or `roadrunner` ones.
* The `non-root` docker tag is no longer published, as all docker images are now running without super-user permissions.
* Due to previous point, it is no longer possible to pass `ENABLE_PERIODIC_VISIT_LOCATE=true` in order to configure a cron job that locates visits periodically.
This was not really needed in the docker image, as visits are located on the fly.
### Changes in integrations
* Credentials in redis URLs should now be URL-encoded, as they are unconditionally url-decoded before being used. Previously, it was possible to customize this behavior via `REDIS_DECODE_CREDENTIALS=true|false`.
* Providing redis URIs in the form of `tcp://password@6.6.6.6:6379` is no longer supported. If you want to provide password with no username, do `tcp://:password@6.6.6.6:6379` instead.
## From v2.x to v3.x
### Changes in REST API

View File

@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Laminas\Diactoros\ServerRequestFactory;
use Laminas\HttpHandlerRunner\Emitter\EmitterInterface;
use Mezzio\Application;
use Psr\Container\ContainerInterface;
use function frankenphp_handle_request;
use function gc_collect_cycles;
(static function (): void {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/../config/container.php';
$app = $container->get(Application::class);
$responseEmitter = $container->get(EmitterInterface::class);
$handler = static function () use ($app, $responseEmitter): void {
$response = $app->handle(ServerRequestFactory::fromGlobals());
$responseEmitter->emit($response);
};
$maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 0);
for ($nbRequests = 0; !$maxRequests || $nbRequests < $maxRequests; ++$nbRequests) {
$keepRunning = frankenphp_handle_request($handler);
// Call the garbage collector to reduce the chances of it being triggered in the middle of a page generation
gc_collect_cycles();
if (! $keepRunning) {
break;
}
}
})();

View File

@@ -2,22 +2,17 @@
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Mezzio\Application;
use Psr\Container\ContainerInterface;
use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware;
use Shlinkio\Shlink\EventDispatcher\RoadRunner\RoadRunnerTaskConsumerToListener;
use Spiral\RoadRunner\Http\PSR7Worker;
use function gc_collect_cycles;
use function Shlinkio\Shlink\Config\env;
(static function (): void {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/../config/container.php';
$rrMode = env('RR_MODE');
$gcCollectCycles = env('GC_COLLECT_CYCLES', default: false);
if ($rrMode === 'http') {
// This was spin-up as a web worker
@@ -29,16 +24,9 @@ use function Shlinkio\Shlink\Config\env;
$worker->respond($app->handle($req));
} catch (Throwable $e) {
$worker->getWorker()->error((string) $e);
} finally {
if ($gcCollectCycles) {
gc_collect_cycles();
}
}
}
} else {
$requestIdMiddleware = $container->get(RequestIdMiddleware::class);
$container->get(RoadRunnerTaskConsumerToListener::class)->listenForTasks(
fn (string $requestId) => $requestIdMiddleware->setCurrentRequestId($requestId),
);
$container->get(RoadRunnerTaskConsumerToListener::class)->listenForTasks();
}
})();

View File

@@ -2,12 +2,10 @@
export APP_ENV=test
export TEST_ENV=api
export TEST_RUNTIME="${TEST_RUNTIME:-"rr"}" # rr is the only runtime currently supported
export TEST_RUNTIME="${TEST_RUNTIME:-"openswoole"}" # Openswoole is deprecated. Remove in v4.0.0
export DB_DRIVER="${DB_DRIVER:-"postgres"}"
export GENERATE_COVERAGE="${GENERATE_COVERAGE:-"no"}"
[ "$GENERATE_COVERAGE" != 'no' ] && export XDEBUG_MODE=coverage
# Reset logs
OUTPUT_LOGS=data/log/api-tests/output.log
rm -rf data/log/api-tests
@@ -15,19 +13,26 @@ mkdir data/log/api-tests
touch $OUTPUT_LOGS
# Try to stop server just in case it hanged in last execution
[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -f -w .
[ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:stop
[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -f
echo 'Starting server...'
[ "$TEST_RUNTIME" = 'rr' ] && bin/rr serve -p -w . -c=config/roadrunner/.rr.test.yml \
[ "$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 --testdox-summary $*
TESTS_EXIT_CODE=$?
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always --log-junit=build/coverage-api/junit.xml $*
testsExitCode=$?
[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -w .
[ "$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 $TESTS_EXIT_CODE
exit $testsExitCode

View File

@@ -1,14 +0,0 @@
#!/usr/bin/env sh
export APP_ENV=test
export TEST_ENV=cli
export DB_DRIVER="${DB_DRIVER:-"maria"}"
export GENERATE_COVERAGE="${GENERATE_COVERAGE:-"no"}"
[ "$GENERATE_COVERAGE" != 'no' ] && export XDEBUG_MODE=coverage
vendor/bin/phpunit --order-by=random --testdox --testdox-summary -c phpunit-cli.xml $*
TESTS_EXIT_CODE=$?
# Exit this script with the same code as the tests. If tests failed, this script has to fail
exit $TESTS_EXIT_CODE

View File

@@ -1,15 +1,18 @@
#!/usr/bin/env bash
set -e
if [ "$#" -lt 1 ]; then
if [ "$#" -lt 1 ] || [ "$#" -gt 2 ] || ([ "$#" == 2 ] && [ "$2" != "--no-swoole" ]); then
echo "Usage:" >&2
echo " $0 {version}" >&2
echo " $0 {version} [--no-swoole]" >&2
exit 1
fi
version=$1
noSwoole=$2
phpVersion=$(php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;')
distId="shlink${version}_php${phpVersion}_dist"
# Openswoole is deprecated. Remove in v4.0.0
[[ $noSwoole ]] && swooleSuffix="" || swooleSuffix="_openswoole"
distId="shlink${version}_php${phpVersion}${swooleSuffix}_dist"
builtContent="./build/${distId}"
projectdir=$(pwd)
[[ -f ./composer.phar ]] && composerBin='./composer.phar' || composerBin='composer'
@@ -28,15 +31,26 @@ cd "${builtContent}"
# Install dependencies
echo "Installing dependencies with $composerBin..."
# Deprecated. Do not ignore PHP platform req for Shlink v4.0.0
composerFlags="--optimize-autoloader --no-progress --no-interaction --ignore-platform-req=php+"
${composerBin} self-update
${composerBin} install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction
${composerBin} install --no-dev --prefer-dist $composerFlags
if [[ $noSwoole ]]; then
# If generating a dist not for openswoole, uninstall mezzio-swoole
${composerBin} remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev $composerFlags
else
# Deprecated. Remove in Shlink v4.0.0
# If generating a dist for openswoole, uninstall RoadRunner
${composerBin} remove spiral/roadrunner spiral/roadrunner-jobs spiral/roadrunner-cli spiral/roadrunner-http --with-all-dependencies --update-no-dev $composerFlags
fi
# Delete development files
echo 'Deleting dev files...'
rm composer.*
# Update Shlink version
sed -i "s/%SHLINK_VERSION%/${version}/g" module/Core/src/Config/Options/AppOptions.php
# Update Shlink version in config
sed -i "s/%SHLINK_VERSION%/${version}/g" config/autoload/app_options.global.php
# Compressing file
echo 'Compressing files...'

View File

@@ -12,69 +12,72 @@
}
],
"require": {
"php": "^8.3",
"php": "^8.2",
"ext-curl": "*",
"ext-gd": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.6",
"cakephp/chronos": "^3.1",
"doctrine/dbal": "^4.3",
"doctrine/migrations": "^3.9",
"doctrine/orm": "^3.5",
"donatj/phpuseragentparser": "^1.10",
"endroid/qr-code": "^6.0.5",
"akrabat/ip-address-middleware": "^2.1",
"cakephp/chronos": "^3.0.2",
"doctrine/migrations": "^3.6",
"doctrine/orm": "^2.16",
"endroid/qr-code": "^4.8",
"friendsofphp/proxy-manager-lts": "^1.0",
"geoip2/geoip2": "^3.1",
"guzzlehttp/guzzle": "^7.9",
"hidehalo/nanoid-php": "^2.0",
"jaybizzle/crawler-detect": "^1.3",
"laminas/laminas-config-aggregator": "^1.17",
"laminas/laminas-diactoros": "^3.5",
"laminas/laminas-inputfilter": "^2.31",
"laminas/laminas-servicemanager": "^3.23",
"laminas/laminas-stdlib": "^3.20",
"matomo/matomo-php-tracker": "^3.3",
"mezzio/mezzio": "^3.20",
"mezzio/mezzio-fastroute": "^3.12",
"mezzio/mezzio-problem-details": "^1.15",
"mlocati/ip-lib": "^1.18.1",
"geoip2/geoip2": "^3.0",
"guzzlehttp/guzzle": "^7.5",
"happyr/doctrine-specification": "^2.0",
"jaybizzle/crawler-detect": "^1.2.116",
"laminas/laminas-config": "^3.8",
"laminas/laminas-config-aggregator": "^1.13",
"laminas/laminas-diactoros": "^3.3",
"laminas/laminas-inputfilter": "^2.27",
"laminas/laminas-servicemanager": "^3.21",
"laminas/laminas-stdlib": "^3.17",
"league/uri": "^6.8",
"matomo/matomo-php-tracker": "^3.2",
"mezzio/mezzio": "^3.17",
"mezzio/mezzio-fastroute": "^3.10",
"mezzio/mezzio-problem-details": "^1.13",
"mezzio/mezzio-swoole": "^4.7",
"mlocati/ip-lib": "^1.18",
"mobiledetect/mobiledetectlib": "^4.8",
"pagerfanta/core": "^3.8",
"php-middleware/request-id": "^4.1",
"pugx/shortid-php": "^1.1",
"ramsey/uuid": "^4.7",
"shlinkio/doctrine-specification": "^2.2",
"shlinkio/shlink-common": "^7.2",
"shlinkio/shlink-config": "^4.0",
"shlinkio/shlink-event-dispatcher": "^4.3",
"shlinkio/shlink-importer": "^5.6",
"shlinkio/shlink-installer": "^9.7",
"shlinkio/shlink-ip-geolocation": "^4.4",
"shlinkio/shlink-json": "^1.2",
"spiral/roadrunner": "^2025.1",
"spiral/roadrunner-cli": "^2.7",
"spiral/roadrunner-http": "^3.5",
"spiral/roadrunner-jobs": "^4.6",
"symfony/console": "^7.3",
"symfony/filesystem": "^7.3",
"symfony/lock": "^7.3.2",
"symfony/process": "^7.3",
"symfony/string": "^7.3"
"shlinkio/shlink-common": "^5.7.1",
"shlinkio/shlink-config": "^2.5",
"shlinkio/shlink-event-dispatcher": "^3.1",
"shlinkio/shlink-importer": "^5.2.1",
"shlinkio/shlink-installer": "^8.7",
"shlinkio/shlink-ip-geolocation": "^4.0",
"shlinkio/shlink-json": "^1.1",
"spiral/roadrunner": "^2023.2",
"spiral/roadrunner-cli": "^2.5",
"spiral/roadrunner-http": "^3.1",
"spiral/roadrunner-jobs": "^4.0",
"symfony/console": "^6.3",
"symfony/filesystem": "^6.3",
"symfony/lock": "^6.3",
"symfony/process": "^6.3",
"symfony/string": "^6.3"
},
"require-dev": {
"devizzent/cebe-php-openapi": "^1.1.2",
"devizzent/cebe-php-openapi": "^1.0.1",
"devster/ubench": "^2.1",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-phpunit": "^2.0.5",
"phpstan/phpstan-symfony": "^2.0",
"phpunit/php-code-coverage": "^12.0",
"phpunit/phpcov": "^11.0",
"phpunit/phpunit": "^12.0.10",
"infection/infection": "^0.27",
"openswoole/ide-helper": "~22.0.0",
"phpstan/phpstan": "^1.10",
"phpstan/phpstan-doctrine": "^1.3",
"phpstan/phpstan-phpunit": "^1.3",
"phpstan/phpstan-symfony": "^1.3",
"phpunit/php-code-coverage": "^10.1",
"phpunit/phpunit": "^10.4",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.5.0",
"shlinkio/shlink-test-utils": "^4.3.1",
"symfony/var-dumper": "^7.3",
"veewee/composer-run-parallel": "^1.4"
"shlinkio/php-coding-standard": "~2.3.0",
"shlinkio/shlink-test-utils": "^3.8.1",
"symfony/var-dumper": "^6.3",
"veewee/composer-run-parallel": "^1.3"
},
"conflict": {
"symfony/var-exporter": ">=6.3.9,<=6.4.0"
@@ -108,62 +111,100 @@
},
"scripts": {
"ci": [
"@parallel cs stan openapi:validate test:unit:ci test:db:sqlite:ci test:db:postgres test:db:mysql test:db:maria test:db:ms",
"@parallel test:api:ci test:cli:ci"
"@parallel cs stan swagger:validate test:unit:ci test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
"@parallel infect:test:api infect:test:cli infect:ci:unit infect:ci:db"
],
"cs": "phpcs -s",
"cs:fix": "phpcbf",
"stan": ["@putenv APP_ENV=test", "phpstan analyse"],
"stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/test* module/*/config module/*/migrations config docker/config --level=8",
"test": [
"@parallel test:unit test:db",
"@parallel test:api test:cli"
],
"test:unit": ["@putenv COLUMNS=120", "phpunit --order-by=random --testdox --testdox-summary"],
"test:unit:ci": ["@putenv XDEBUG_MODE=coverage", "@test:unit --coverage-php=build/coverage-unit.cov"],
"test:unit:pretty": ["@putenv XDEBUG_MODE=coverage", "@test:unit --coverage-html build/coverage-unit/coverage-html"],
"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": ["@putenv APP_ENV=test", "phpunit --order-by=random --testdox --testdox-summary -c phpunit-db.xml"],
"test:db:sqlite:ci": ["@putenv XDEBUG_MODE=coverage", "@test:db:sqlite --coverage-php build/coverage-db.cov"],
"test:db:mysql": ["@putenv DB_DRIVER=mysql", "@test:db:sqlite"],
"test:db:maria": ["@putenv DB_DRIVER=maria", "@test:db:sqlite"],
"test:db:postgres": ["@putenv DB_DRIVER=postgres", "@test:db:sqlite"],
"test:db:ms": ["@putenv DB_DRIVER=mssql", "@test:db:sqlite"],
"test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
"test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml",
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite",
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
"test:api": "bin/test/run-api-tests.sh",
"test:api:sqlite": ["@putenv DB_DRIVER=sqlite", "@test:api"],
"test:api:mysql": ["@putenv DB_DRIVER=mysql", "@test:api"],
"test:api:maria": ["@putenv DB_DRIVER=maria", "@test:api"],
"test:api:mssql": ["@putenv DB_DRIVER=mssql", "@test:api"],
"test:api:ci": [
"@putenv GENERATE_COVERAGE=yes",
"@test:api",
"phpcov merge build/coverage-api --php build/coverage-api.cov && rm build/coverage-api/*.cov"
"test:api:rr": "TEST_RUNTIME=rr bin/test/run-api-tests.sh",
"test:api:ci": "GENERATE_COVERAGE=yes composer test:api",
"test:api:pretty": "GENERATE_COVERAGE=pretty composer test:api",
"test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml --log-junit=build/coverage-cli/junit.xml",
"test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli",
"test:cli:pretty": "GENERATE_COVERAGE=pretty composer test:cli",
"infect:ci:base": "infection --threads=max --only-covered --skip-initial-tests",
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80",
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json5",
"infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=95 --configuration=infection-api.json5",
"infect:ci:cli": "@infect:ci:base --coverage=build/coverage-cli --min-msi=90 --configuration=infection-cli.json5",
"infect:ci": "@parallel infect:ci:unit infect:ci:db infect:ci:api infect:ci:cli",
"infect:test": [
"@parallel test:unit:ci test:db:sqlite:ci test:api:ci",
"@infect:ci"
],
"test:api:pretty": [
"@putenv GENERATE_COVERAGE=yes",
"@test:api",
"phpcov merge build/coverage-api --html build/coverage-api/coverage-html && rm build/coverage-api/*.cov"
"infect:test:unit": [
"@test:unit:ci",
"@infect:ci:unit"
],
"test:cli": "bin/test/run-cli-tests.sh",
"test:cli:ci": [
"@putenv GENERATE_COVERAGE=yes",
"@test:cli",
"vendor/bin/phpcov merge build/coverage-cli --php build/coverage-cli.cov && rm build/coverage-cli/*.cov"
"infect:test:db": [
"@test:db:sqlite:ci",
"@infect:ci:db"
],
"test:cli:pretty": [
"@putenv GENERATE_COVERAGE=yes",
"@test:cli",
"phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov"
"infect:test:api": [
"@test:api:ci",
"@infect:ci:api"
],
"openapi:validate": "php-openapi validate docs/swagger/swagger.json",
"openapi:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/openapi-inlined.json",
"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\"</>",
"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:unit": "<fg=blue;options=bold>Runs unit test suites</>",
"test:unit:ci": "<fg=blue;options=bold>Runs unit test suites, generating all needed reports and logs for CI envs</>",
"test:unit:pretty": "<fg=blue;options=bold>Runs unit test suites and generates an HTML code coverage report</>",
"test:db": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL, MariaDB, PostgreSQL and MsSQL</>",
"test:db:sqlite": "<fg=blue;options=bold>Runs database test suites on a SQLite database</>",
"test:db:sqlite:ci": "<fg=blue;options=bold>Runs database test suites on a SQLite database, generating all needed reports and logs for CI envs</>",
"test:db:mysql": "<fg=blue;options=bold>Runs database test suites on a MySQL database</>",
"test:db:maria": "<fg=blue;options=bold>Runs database test suites on a MariaDB database</>",
"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 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</>",
"infect:test": "<fg=blue;options=bold>Runs unit and db tests, then checks tests quality applying mutation testing</>",
"swagger:validate": "<fg=blue;options=bold>Validates the swagger docs, making sure they fulfil the spec</>",
"swagger:inline": "<fg=blue;options=bold>Inlines swagger docs in a single file</>",
"clean:dev": "<fg=blue;options=bold>Deletes artifacts which are gitignored and could affect dev env</>"
},
"config": {
"sort-packages": true,
"platform-check": false,
"allow-plugins": {
"composer/package-versions-deprecated": true,
"dealerdirect/phpcodesniffer-composer-installer": true,
"infection/extension-installer": true,
"veewee/composer-run-parallel": true
}
}

2
config/autoload/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
local.php
*.local.php

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
return [
'app_options' => [
'name' => 'Shlink',
'version' => '%SHLINK_VERSION%',
],
];

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
return [
'app_options' => [
'version' => 'latest',
],
];

View File

@@ -6,19 +6,18 @@ use Shlinkio\Shlink\Core\Config\EnvVars;
return (static function (): array {
$redisServers = EnvVars::REDIS_SERVERS->loadFromEnv();
$redis = ['pub_sub_enabled' => $redisServers !== null && EnvVars::REDIS_PUB_SUB_ENABLED->loadFromEnv()];
$redis = ['pub_sub_enabled' => $redisServers !== null && EnvVars::REDIS_PUB_SUB_ENABLED->loadFromEnv(false)];
$cacheRedisBlock = $redisServers === null ? [] : [
'redis' => [
'servers' => $redisServers,
'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(),
'username' => EnvVars::REDIS_SERVERS_USER->loadFromEnv(),
'password' => EnvVars::REDIS_SERVERS_PASSWORD->loadFromEnv(),
'decode_credentials' => (bool) EnvVars::REDIS_DECODE_CREDENTIALS->loadFromEnv(false),
],
];
return [
'cache' => [
'namespace' => EnvVars::CACHE_NAMESPACE->loadFromEnv(),
'namespace' => EnvVars::CACHE_NAMESPACE->loadFromEnv('Shlink'),
...$cacheRedisBlock,
],
'redis' => $redis,

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
return [
'ip_address_resolution' => [
'headers_to_inspect' => [
'CF-Connecting-IP',
'X-Forwarded-For',
'X-Forwarded',
'Forwarded',
'True-Client-IP',
'X-Real-IP',
'X-Cluster-Client-Ip',
'Client-Ip',
],
],
];

View File

@@ -3,18 +3,13 @@
declare(strict_types=1);
use Laminas\ConfigAggregator\ConfigAggregator;
use Shlinkio\Shlink\Core\Config\EnvVars;
return (function () {
$isDev = EnvVars::isDevEnv();
return [
return [
'debug' => false,
'debug' => $isDev,
// 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',
// Disabling config cache for cli, ensures it's never used for RoadRunner, and also that console
// commands don't generate a cache file that's then used by php-fpm web executions
ConfigAggregator::ENABLE_CACHE => ! $isDev && PHP_SAPI !== 'cli',
];
})();
];

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
use Laminas\ConfigAggregator\ConfigAggregator;
return [
'debug' => true,
ConfigAggregator::ENABLE_CACHE => false,
];

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
return [
'cors' => [
'max_age' => 3600,
],
];

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Shlinkio\Shlink\Core\Config\EnvVars;
return (static function (): array {
$threshold = EnvVars::DELETE_SHORT_URL_THRESHOLD->loadFromEnv();
return [
'delete_short_urls' => [
'check_visits_threshold' => $threshold !== null,
'visits_threshold' => (int) ($threshold ?? DEFAULT_DELETE_SHORT_URL_THRESHOLD),
],
];
})();

View File

@@ -4,24 +4,20 @@ declare(strict_types=1);
use GuzzleHttp\Client;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Mezzio\Application;
use Mezzio\Container;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\ServerRequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\UploadedFileFactoryInterface;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Spiral\RoadRunner\Http\PSR7Worker;
use Spiral\RoadRunner\WorkerInterface;
use Symfony\Component\Filesystem\Filesystem;
return [
'dependencies' => [
'factories' => [
PSR7Worker::class => ConfigAbstractFactory::class,
Filesystem::class => InvokableFactory::class,
],
'delegators' => [
@@ -37,7 +33,7 @@ return [
'lazy_services' => [
'proxies_target_dir' => 'data/proxies',
'proxies_namespace' => 'ShlinkProxy',
'write_proxy_files' => EnvVars::isProdEnv(),
'write_proxy_files' => true,
],
],

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Psr\Container\ContainerInterface;
use Psr\Log;
return [
'dependencies' => [
'lazy_services' => [
'write_proxy_files' => false,
],
'initializers' => [
function (ContainerInterface $container, $instance): void {
if ($instance instanceof Log\LoggerAwareInterface) {
$instance->setLogger($container->get(Log\LoggerInterface::class));
}
},
],
],
];

View File

@@ -2,62 +2,49 @@
declare(strict_types=1);
use Doctrine\ORM\Events;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\Visit\Listener\OrphanVisitsCountTracker;
use Shlinkio\Shlink\Core\Visit\Listener\ShortUrlVisitsCountTracker;
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
return (static function (): array {
$driver = EnvVars::DB_DRIVER->loadFromEnv();
$useEncryption = (bool) EnvVars::DB_USE_ENCRYPTION->loadFromEnv();
$isMysqlCompatible = contains($driver, ['maria', 'mysql']);
$doctrineDriver = match ($driver) {
$resolveDriver = static fn () => match ($driver) {
'postgres' => 'pdo_pgsql',
'mssql' => 'pdo_sqlsrv',
default => 'pdo_mysql',
};
$readCredentialAsString = static function (EnvVars $envVar): string|null {
$value = $envVar->loadFromEnv();
return $value === null ? null : (string) $value;
$resolveDefaultPort = static fn () => match ($driver) {
'postgres' => '5432',
'mssql' => '1433',
default => '3306',
};
$charset = match ($driver) {
$resolveCharset = static fn () => match ($driver) {
// This does not determine charsets or collations in tables or columns, but the charset used in the data
// flowing in the connection, so it has to match what has been set in the database.
'maria', 'mysql' => 'utf8mb4',
'postgres' => 'utf8',
default => null,
};
$driverOptions = match ($driver) {
'mssql' => ['TrustServerCertificate' => 'true'],
'maria', 'mysql' => ! $useEncryption ? [] : [
1007 => true, // PDO::MYSQL_ATTR_SSL_KEY: Require using SSL
1014 => false, // PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT: Trust any certificate
],
'postgres' => ! $useEncryption ? [] : [
'sslmode' => 'require', // Require connections to be encrypted
'sslrootcert' => '', // Allow any certificate
],
default => [],
};
$connection = match ($driver) {
$resolveConnection = static fn () => match ($driver) {
null, 'sqlite' => [
'driver' => 'pdo_sqlite',
'path' => 'data/database.sqlite',
],
default => [
'driver' => $doctrineDriver,
'dbname' => EnvVars::DB_NAME->loadFromEnv(),
'user' => $readCredentialAsString(EnvVars::DB_USER),
'password' => $readCredentialAsString(EnvVars::DB_PASSWORD),
'host' => EnvVars::DB_HOST->loadFromEnv(),
'port' => EnvVars::DB_PORT->loadFromEnv(),
'driver' => $resolveDriver(),
'dbname' => EnvVars::DB_NAME->loadFromEnv('shlink'),
'user' => EnvVars::DB_USER->loadFromEnv(),
'password' => EnvVars::DB_PASSWORD->loadFromEnv(),
'host' => EnvVars::DB_HOST->loadFromEnv(EnvVars::DB_UNIX_SOCKET->loadFromEnv()),
'port' => EnvVars::DB_PORT->loadFromEnv($resolveDefaultPort()),
'unix_socket' => $isMysqlCompatible ? EnvVars::DB_UNIX_SOCKET->loadFromEnv() : null,
'charset' => $charset,
'driverOptions' => $driverOptions,
'charset' => $resolveCharset(),
'driverOptions' => $driver !== 'mssql' ? [] : [
'TrustServerCertificate' => 'true',
],
],
};
@@ -68,12 +55,8 @@ return (static function (): array {
'proxies_dir' => 'data/proxies',
'load_mappings_using_functional_style' => true,
'default_repository_classname' => EntitySpecificationRepository::class,
'listeners' => [
Events::onFlush => [ShortUrlVisitsCountTracker::class, OrphanVisitsCountTracker::class],
Events::postFlush => [ShortUrlVisitsCountTracker::class, OrphanVisitsCountTracker::class],
],
],
'connection' => $connection,
'connection' => $resolveConnection(),
],
];

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
return [
'entity_manager' => [
'connection' => [
// MySQL
'user' => 'root',
'password' => 'root',
'driver' => 'pdo_mysql',
'host' => 'shlink_db_mysql',
'dbname' => 'shlink',
// 'dbname' => 'shlink_foo',
'charset' => 'utf8mb4',
// MariaDB
// 'user' => 'root',
// 'password' => 'root',
// 'driver' => 'pdo_mysql',
// 'host' => 'shlink_db_maria',
// 'dbname' => 'shlink_foo',
// 'charset' => 'utf8mb4',
// Postgres
// 'user' => 'postgres',
// 'password' => 'root',
// 'driver' => 'pdo_pgsql',
// 'host' => 'shlink_db_postgres',
// 'dbname' => 'shlink_foo',
// 'charset' => 'utf8',
// MSSQL
// 'user' => 'sa',
// 'password' => 'Passw0rd!',
// 'driver' => 'pdo_sqlsrv',
// 'host' => 'shlink_db_ms',
// 'dbname' => 'shlink_foo',
// 'driverOptions' => [
// 'TrustServerCertificate' => 'true',
// ],
],
],
];

View File

@@ -8,7 +8,7 @@ return [
'geolite2' => [
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
'temp_dir' => __DIR__ . '/../../data/temp-geolite',
'temp_dir' => __DIR__ . '/../../data',
'license_key' => EnvVars::GEOLITE_LICENSE_KEY->loadFromEnv(),
],

View File

@@ -12,8 +12,6 @@ return [
'installer' => [
'enabled_options' => [
Option\Server\RuntimeConfigOption::class,
Option\Server\MemoryLimitConfigOption::class,
Option\Server\LogsFormatConfigOption::class,
Option\Database\DatabaseDriverConfigOption::class,
Option\Database\DatabaseNameConfigOption::class,
Option\Database\DatabaseHostConfigOption::class,
@@ -21,9 +19,10 @@ return [
Option\Database\DatabaseUserConfigOption::class,
Option\Database\DatabasePasswordConfigOption::class,
Option\Database\DatabaseUnixSocketConfigOption::class,
Option\Database\DatabaseUseEncryptionConfigOption::class,
Option\UrlShortener\ShortDomainHostConfigOption::class,
Option\UrlShortener\ShortDomainSchemaConfigOption::class,
Option\Visit\VisitsWebhooksConfigOption::class,
Option\Visit\OrphanVisitsWebhooksConfigOption::class,
Option\Redirect\BaseUrlRedirectConfigOption::class,
Option\Redirect\InvalidShortUrlRedirectConfigOption::class,
Option\Redirect\Regular404RedirectConfigOption::class,
@@ -31,10 +30,11 @@ return [
Option\BasePathConfigOption::class,
Option\TimezoneConfigOption::class,
Option\Cache\CacheNamespaceConfigOption::class,
Option\Worker\TaskWorkerNumConfigOption::class,
Option\Worker\WebWorkerNumConfigOption::class,
Option\Redis\RedisServersConfigOption::class,
Option\Redis\RedisDecodeCredentialsConfigOption::class,
Option\Redis\RedisSentinelServiceConfigOption::class,
Option\Redis\RedisServersUserConfigOption::class,
Option\Redis\RedisServersPasswordConfigOption::class,
Option\Redis\RedisPubSubConfigOption::class,
Option\UrlShortener\ShortCodeLengthOption::class,
Option\Mercure\EnableMercureConfigOption::class,
@@ -44,14 +44,11 @@ return [
Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class,
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
Option\UrlShortener\RedirectCacheVisibilityConfigOption::class,
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
Option\UrlShortener\ExtraPathModeConfigOption::class,
Option\UrlShortener\AppendExtraPathConfigOption::class,
Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class,
Option\UrlShortener\EnableTrailingSlashConfigOption::class,
Option\UrlShortener\ShortUrlModeConfigOption::class,
Option\Robots\RobotsAllowAllShortUrlsConfigOption::class,
Option\Robots\RobotsUserAgentsConfigOption::class,
Option\Tracking\IpAnonymizationConfigOption::class,
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
Option\Tracking\DisableTrackParamConfigOption::class,
@@ -65,9 +62,6 @@ return [
Option\QrCode\DefaultFormatConfigOption::class,
Option\QrCode\DefaultErrorCorrectionConfigOption::class,
Option\QrCode\DefaultRoundBlockSizeConfigOption::class,
Option\QrCode\DefaultColorConfigOption::class,
Option\QrCode\DefaultBgColorConfigOption::class,
Option\QrCode\DefaultLogoUrlConfigOption::class,
Option\QrCode\EnabledForDisabledShortUrlsConfigOption::class,
Option\RabbitMq\RabbitMqEnabledConfigOption::class,
Option\RabbitMq\RabbitMqHostConfigOption::class,
@@ -80,11 +74,6 @@ return [
Option\Matomo\MatomoBaseUrlConfigOption::class,
Option\Matomo\MatomoSiteIdConfigOption::class,
Option\Matomo\MatomoApiTokenConfigOption::class,
Option\RealTimeUpdates\RealTimeUpdatesTopicsConfigOption::class,
Option\Cors\CorsAllowOriginConfigOption::class,
Option\Cors\CorsAllowCredentialsConfigOption::class,
Option\Cors\CorsMaxAgeConfigOption::class,
Option\TrustedProxiesConfigOption::class,
],
'installation_commands' => [

View File

@@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
use RKA\Middleware\IpAddress;
use RKA\Middleware\Mezzio\IpAddressFactory;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\Middleware\ReverseForwardedAddressesMiddlewareDecorator;
use function Shlinkio\Shlink\Core\splitByComma;
use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE;
return (static function (): array {
$trustedProxies = EnvVars::TRUSTED_PROXIES->loadFromEnv();
$proxiesIsHopCount = is_numeric($trustedProxies);
return [
// Configuration for RKA\Middleware\IpAddress
'rka' => [
'ip_address' => [
'attribute_name' => IP_ADDRESS_REQUEST_ATTRIBUTE,
'check_proxy_headers' => true,
// List of trusted proxies
'trusted_proxies' => $proxiesIsHopCount ? [] : splitByComma($trustedProxies),
// Amount of addresses to skip from the right, before finding the visitor IP address
'hop_count' => $proxiesIsHopCount ? (int) $trustedProxies : 0,
'headers_to_inspect' => [
'CF-Connecting-IP',
'X-Forwarded-For',
'X-Forwarded',
'Forwarded',
'True-Client-IP',
'X-Real-IP',
'X-Cluster-Client-Ip',
'Client-Ip',
],
],
],
'dependencies' => [
'factories' => [
IpAddress::class => IpAddressFactory::class,
],
'delegators' => [
// Make middleware decoration transparent to other parts of the code
IpAddress::class => [
fn ($c, $n, callable $callback) =>
// If trusted proxies have been provided, use original middleware verbatim, otherwise decorate
// with workaround
$trustedProxies !== null
? $callback()
: new ReverseForwardedAddressesMiddlewareDecorator($callback()),
],
],
],
];
})();

View File

@@ -4,55 +4,37 @@ declare(strict_types=1);
namespace Shlinkio\Shlink;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Monolog\Level;
use Monolog\Logger;
use PhpMiddleware\RequestId;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Logger\LoggerFactory;
use Shlinkio\Shlink\Common\Logger\LoggerType;
use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware;
use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\EventDispatcher\Helper\RequestIdProvider;
use Shlinkio\Shlink\EventDispatcher\Util\RequestIdProviderInterface;
use function Shlinkio\Shlink\Config\env;
use function Shlinkio\Shlink\Config\runningInRoadRunner;
return (static function (): array {
$isDev = EnvVars::isDevEnv();
$format = EnvVars::LOGS_FORMAT->loadFromEnv();
$buildCommonConfig = static fn (bool $addNewLine = false) => [
'level' => $isDev ? Level::Debug->value : Level::Info->value,
'processors' => [RequestIdMiddleware::class],
'formatter' => [
'type' => $format,
'add_new_line' => $addNewLine,
'line_format' =>
'[%datetime%] [%extra.' . RequestIdMiddleware::ATTRIBUTE . '%] %channel%.%level_name% - %message%',
],
$common = [
'level' => Level::Info->value,
'processors' => [RequestId\MonologProcessor::class],
'line_format' => '[%datetime%] [%extra.request_id%] %channel%.%level_name% - %message%',
];
// In dev env or the docker container, stream Shlink logs to stderr, otherwise send them to a file
$useStreamForShlinkLogger = $isDev || env('SHLINK_RUNTIME') !== null;
return [
'logger' => [
'Shlink' => $useStreamForShlinkLogger ? [
'type' => LoggerType::STREAM->value,
'destination' => 'php://stderr',
...$buildCommonConfig(),
] : [
'Shlink' => [
'type' => LoggerType::FILE->value,
...$buildCommonConfig(),
...$common,
],
'Access' => [
'type' => LoggerType::STREAM->value,
'destination' => 'php://stderr',
...$buildCommonConfig(! runningInRoadRunner()),
'add_new_line' => ! runningInRoadRunner(),
...$common,
],
],
@@ -61,19 +43,24 @@ return (static function (): array {
'Logger_Shlink' => [LoggerFactory::class, 'Shlink'],
'Logger_Access' => [LoggerFactory::class, 'Access'],
NullLogger::class => InvokableFactory::class,
RequestIdProvider::class => ConfigAbstractFactory::class,
],
'aliases' => [
'logger' => 'Logger_Shlink',
Logger::class => 'Logger_Shlink',
LoggerInterface::class => 'Logger_Shlink',
AccessLogMiddleware::LOGGER_SERVICE_NAME => 'Logger_Access',
RequestIdProviderInterface::class => RequestIdProvider::class,
],
],
ConfigAbstractFactory::class => [
RequestIdProvider::class => [RequestIdMiddleware::class],
// Deprecated. Remove in Shlink 4.0.0
'mezzio-swoole' => [
'swoole-http-server' => [
'logger' => [
// Let's disable mezio-swoole access logging, so that we can provide our own implementation,
// consistent for roadrunner and openswoole
'logger-name' => NullLogger::class,
],
],
],
];

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
use Monolog\Level;
use Shlinkio\Shlink\Common\Logger\LoggerType;
return [
'logger' => [
'Shlink' => [
'type' => LoggerType::STREAM->value,
'destination' => 'php://stderr',
'level' => Level::Debug->value,
],
],
];

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
return [
'matomo' => [
'enabled' => (bool) EnvVars::MATOMO_ENABLED->loadFromEnv(false),
'base_url' => EnvVars::MATOMO_BASE_URL->loadFromEnv(),
'site_id' => EnvVars::MATOMO_SITE_ID->loadFromEnv(),
'api_token' => EnvVars::MATOMO_API_TOKEN->loadFromEnv(),
],
];

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/*
* Dev matomo instance needs to be manually configured once before enabling the configuration below.
*
* 1. Go to http://localhost:8003 and follow the installation instructions.
* 2. Open data/infra/matomo/config/config.ini.php and replace `trusted_hosts[] = "localhost"` with
* `trusted_hosts[] = "localhost:8003"` (see https://github.com/matomo-org/matomo/issues/9549)
* 3. Go to http://localhost:8003/index.php?module=SitesManager&action=index and paste the ID for the site you just
* created into the `site_id` field below.
* 4. Go to http://localhost:8003/index.php?module=UsersManager&action=userSecurity, scroll down, click
* "Create new token" and once generated, paste the token into the `api_token` field below.
*/
return [
'matomo' => [
// 'enabled' => true,
// 'base_url' => 'http://shlink_matomo',
// 'site_id' => '...',
// 'api_token' => '...',
],
];

View File

@@ -8,32 +8,34 @@ use Shlinkio\Shlink\Core\Config\EnvVars;
use Symfony\Component\Mercure\Hub;
use Symfony\Component\Mercure\HubInterface;
return [
return (static function (): array {
$publicUrl = EnvVars::MERCURE_PUBLIC_HUB_URL->loadFromEnv();
// This config is used by shlink-common. Do not delete
'mercure' => [
'enabled' => EnvVars::MERCURE_ENABLED->loadFromEnv(),
'public_hub_url' => EnvVars::MERCURE_PUBLIC_HUB_URL->loadFromEnv(),
'internal_hub_url' => EnvVars::MERCURE_INTERNAL_HUB_URL->loadFromEnv(),
'jwt_secret' => EnvVars::MERCURE_JWT_SECRET->loadFromEnv(),
'jwt_issuer' => 'Shlink',
],
return [
'dependencies' => [
'delegators' => [
LcobucciJwtProvider::class => [
LazyServiceFactory::class,
'mercure' => [
'public_hub_url' => $publicUrl,
'internal_hub_url' => EnvVars::MERCURE_INTERNAL_HUB_URL->loadFromEnv($publicUrl),
'jwt_secret' => EnvVars::MERCURE_JWT_SECRET->loadFromEnv(),
'jwt_issuer' => 'Shlink',
],
'dependencies' => [
'delegators' => [
LcobucciJwtProvider::class => [
LazyServiceFactory::class,
],
Hub::class => [
LazyServiceFactory::class,
],
],
Hub::class => [
LazyServiceFactory::class,
'lazy_services' => [
'class_map' => [
LcobucciJwtProvider::class => LcobucciJwtProvider::class,
Hub::class => HubInterface::class,
],
],
],
'lazy_services' => [
'class_map' => [
LcobucciJwtProvider::class => LcobucciJwtProvider::class,
Hub::class => HubInterface::class,
],
],
],
];
];
})();

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
return [
'mercure' => [
'public_hub_url' => 'http://localhost:8001',
'internal_hub_url' => 'http://shlink_mercure_proxy',
'jwt_secret' => 'mercure_jwt_key_long_enough_to_avoid_error',
],
];

View File

@@ -7,11 +7,10 @@ namespace Shlinkio\Shlink;
use Laminas\Stratigility\Middleware\ErrorHandler;
use Mezzio\ProblemDetails;
use Mezzio\Router;
use PhpMiddleware\RequestId\RequestIdMiddleware;
use RKA\Middleware\IpAddress;
use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware;
use Shlinkio\Shlink\Common\Middleware\ContentLengthMiddleware;
use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware;
use Shlinkio\Shlink\Core\Geolocation\Middleware\IpGeolocationMiddleware;
return [
@@ -48,6 +47,7 @@ return [
'rest' => [
'path' => '/rest',
'middleware' => [
Rest\Middleware\ErrorHandler\BackwardsCompatibleProblemDetailsHandler::class,
Router\Middleware\ImplicitOptionsMiddleware::class,
Rest\Middleware\BodyParserMiddleware::class,
Rest\Middleware\AuthenticationMiddleware::class,
@@ -68,11 +68,8 @@ return [
],
'not-found' => [
'middleware' => [
// These two middlewares are in front of other tracking actions.
// Putting them here for orphan visits tracking
// This middleware is in front of tracking actions explicitly. Putting here for orphan visits tracking
IpAddress::class,
IpGeolocationMiddleware::class,
Core\ErrorHandler\NotFoundTypeResolverMiddleware::class,
Core\ShortUrl\Middleware\ExtraPathRedirectMiddleware::class,
Core\ErrorHandler\NotFoundTrackerMiddleware::class,

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ROUND_BLOCK_SIZE;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE;
return [
'qr_codes' => [
'size' => (int) EnvVars::DEFAULT_QR_CODE_SIZE->loadFromEnv(DEFAULT_QR_CODE_SIZE),
'margin' => (int) EnvVars::DEFAULT_QR_CODE_MARGIN->loadFromEnv(DEFAULT_QR_CODE_MARGIN),
'format' => EnvVars::DEFAULT_QR_CODE_FORMAT->loadFromEnv(DEFAULT_QR_CODE_FORMAT),
'error_correction' => EnvVars::DEFAULT_QR_CODE_ERROR_CORRECTION->loadFromEnv(
DEFAULT_QR_CODE_ERROR_CORRECTION,
),
'round_block_size' => (bool) EnvVars::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE->loadFromEnv(
DEFAULT_QR_CODE_ROUND_BLOCK_SIZE,
),
'enabled_for_disabled_short_urls' => (bool) EnvVars::QR_CODE_FOR_DISABLED_SHORT_URLS->loadFromEnv(
DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS,
),
],
];

View File

@@ -6,15 +6,17 @@ use Shlinkio\Shlink\Core\Config\EnvVars;
return [
// This config is used by shlink-common. Do not delete
'rabbitmq' => [
'enabled' => (bool) EnvVars::RABBITMQ_ENABLED->loadFromEnv(),
'enabled' => (bool) EnvVars::RABBITMQ_ENABLED->loadFromEnv(false),
'host' => EnvVars::RABBITMQ_HOST->loadFromEnv(),
'use_ssl' => (bool) EnvVars::RABBITMQ_USE_SSL->loadFromEnv(),
'port' => (int) EnvVars::RABBITMQ_PORT->loadFromEnv(),
'use_ssl' => (bool) EnvVars::RABBITMQ_USE_SSL->loadFromEnv(false),
'port' => (int) EnvVars::RABBITMQ_PORT->loadFromEnv('5672'),
'user' => EnvVars::RABBITMQ_USER->loadFromEnv(),
'password' => EnvVars::RABBITMQ_PASSWORD->loadFromEnv(),
'vhost' => EnvVars::RABBITMQ_VHOST->loadFromEnv(),
'vhost' => EnvVars::RABBITMQ_VHOST->loadFromEnv('/'),
// Deprecated
'legacy_visits_publishing' => (bool) EnvVars::RABBITMQ_LEGACY_VISITS_PUBLISHING->loadFromEnv(false),
],
];

View File

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

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME;
use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
return [
'not_found_redirects' => [
'invalid_short_url' => EnvVars::DEFAULT_INVALID_SHORT_URL_REDIRECT->loadFromEnv(),
'regular_404' => EnvVars::DEFAULT_REGULAR_404_REDIRECT->loadFromEnv(),
'base_url' => EnvVars::DEFAULT_BASE_URL_REDIRECT->loadFromEnv(),
],
'redirects' => [
'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE->loadFromEnv(DEFAULT_REDIRECT_STATUS_CODE->value),
'redirect_cache_lifetime' => (int) EnvVars::REDIRECT_CACHE_LIFETIME->loadFromEnv(
DEFAULT_REDIRECT_CACHE_LIFETIME,
),
],
];

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
return [
'cache' => [
'redis' => [
'servers' => 'tcp://shlink_redis:6379',
// 'servers' => 'tcp://barbar@shlink_redis_acl:6379',
// 'servers' => 'tcp://foo:bar@shlink_redis_acl:6379',
],
],
'redis' => [
'pub_sub_enabled' => true,
],
'dependencies' => [
'aliases' => [
// With this config, a user could alias 'lock_store' => 'redis_lock_store' to override the default
// 'lock_store' => 'redis_lock_store',
],
],
];

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use PhpMiddleware\RequestId;
use Shlinkio\Shlink\Common\Logger\Processor\BackwardsCompatibleMonologProcessorDelegator;
return [
'request_id' => [
'allow_override' => true,
'header_name' => 'X-Request-Id',
],
'dependencies' => [
'factories' => [
RequestId\Generator\RamseyUuid4StaticGenerator::class => InvokableFactory::class,
RequestId\RequestIdProviderFactory::class => ConfigAbstractFactory::class,
RequestId\RequestIdMiddleware::class => ConfigAbstractFactory::class,
RequestId\MonologProcessor::class => ConfigAbstractFactory::class,
],
'delegators' => [
RequestId\MonologProcessor::class => [
BackwardsCompatibleMonologProcessorDelegator::class,
],
],
],
ConfigAbstractFactory::class => [
RequestId\RequestIdProviderFactory::class => [
RequestId\Generator\RamseyUuid4StaticGenerator::class,
'config.request_id.allow_override',
'config.request_id.header_name',
],
RequestId\RequestIdMiddleware::class => [
RequestId\RequestIdProviderFactory::class,
'config.request_id.header_name',
],
RequestId\MonologProcessor::class => [RequestId\RequestIdMiddleware::class],
],
];

View File

@@ -8,12 +8,12 @@ use Shlinkio\Shlink\Core\Config\EnvVars;
return [
'router' => [
'base_path' => EnvVars::BASE_PATH->loadFromEnv(),
'base_path' => EnvVars::BASE_PATH->loadFromEnv(''),
'fastroute' => [
// Disabling config cache for cli, ensures it's never used for RoadRunner, and also that console
// 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
FastRouteRouter::CONFIG_CACHE_ENABLED => EnvVars::isProdEnv() && PHP_SAPI !== 'cli',
FastRouteRouter::CONFIG_CACHE_ENABLED => PHP_SAPI !== 'cli',
FastRouteRouter::CONFIG_CACHE_FILE => 'data/cache/fastroute_cached_routes.php',
],
],

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
use Mezzio\Router\FastRouteRouter;
return [
'router' => [
// 'base_path' => '',
'fastroute' => [
FastRouteRouter::CONFIG_CACHE_ENABLED => false,
],
],
];

View File

@@ -8,7 +8,6 @@ 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\Core\Geolocation\Middleware\IpGeolocationMiddleware;
use Shlinkio\Shlink\Core\ShortUrl\Middleware\TrimTrailingSlashMiddleware;
use Shlinkio\Shlink\Rest\Action;
use Shlinkio\Shlink\Rest\ConfigProvider;
@@ -18,9 +17,12 @@ 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() ? '[/]' : '';
// TODO This should be based on config, not the env var
$shortUrlRouteSuffix = EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false) ? '[/]' : '';
return [
@@ -30,10 +32,9 @@ return (static function (): array {
...ConfigProvider::applyRoutesPrefix([
Action\HealthAction::getRouteDef(),
// Visits and rules routes must go first, as they have a more specific path, otherwise, when
// multi-segment slugs are enabled, routes with a less-specific path might match first
// Visits.
// These routes must go first, as they have a more specific path, otherwise, when multi-segment slugs
// are enabled, routes with a less-specific path might match first
Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\DeleteShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
Action\Visit\TagVisitsAction::getRouteDef(),
@@ -43,18 +44,15 @@ return (static function (): array {
Action\Visit\DeleteOrphanVisitsAction::getRouteDef(),
Action\Visit\NonOrphanVisitsAction::getRouteDef(),
//Redirect rules
Action\RedirectRule\ListRedirectRulesAction::getRouteDef([$dropDomainMiddleware]),
Action\RedirectRule\SetRedirectRulesAction::getRouteDef([$dropDomainMiddleware]),
// Short URLs
Action\ShortUrl\CreateShortUrlAction::getRouteDef([
$contentNegotiationMiddleware,
$dropDomainMiddleware,
$overrideDomainMiddleware,
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class,
]),
Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class,
$contentNegotiationMiddleware,
$overrideDomainMiddleware,
]),
Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]),
@@ -89,7 +87,6 @@ return (static function (): array {
'path' => '/{shortCode}/track',
'middleware' => [
IpAddress::class,
IpGeolocationMiddleware::class,
CoreAction\PixelAction::class,
],
'allowed_methods' => [RequestMethodInterface::METHOD_GET],
@@ -107,7 +104,6 @@ return (static function (): array {
'path' => sprintf('/{shortCode}%s', $shortUrlRouteSuffix),
'middleware' => [
IpAddress::class,
IpGeolocationMiddleware::class,
TrimTrailingSlashMiddleware::class,
CoreAction\RedirectAction::class,
],

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
use function Shlinkio\Shlink\Config\getOpenswooleConfigFromEnv;
use const Shlinkio\Shlink\MIN_TASK_WORKERS;
return (static function (): array {
$taskWorkers = (int) EnvVars::TASK_WORKER_NUM->loadFromEnv(16);
return [
'mezzio-swoole' => [
// Setting this to true can have unexpected behaviors when running several concurrent slow DB queries
'enable_coroutine' => false,
'swoole-http-server' => [
'host' => '0.0.0.0',
'port' => (int) EnvVars::PORT->loadFromEnv(8080),
'process-name' => 'shlink',
'options' => [
...getOpenswooleConfigFromEnv(),
'worker_num' => (int) EnvVars::WEB_WORKER_NUM->loadFromEnv(16),
'task_worker_num' => max($taskWorkers, MIN_TASK_WORKERS),
],
],
],
];
})();

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
return [
'mezzio-swoole' => [
'hot-code-reload' => [
'enable' => true,
],
],
];

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
return (static function (): array {
/** @var string|null $disableTrackingFrom */
$disableTrackingFrom = EnvVars::DISABLE_TRACKING_FROM->loadFromEnv();
return [
'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),
// 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),
// 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 not be tracked at all
'disable_tracking' => (bool) EnvVars::DISABLE_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 referrer will not be tracked
'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING->loadFromEnv(false),
// 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

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
return (static function (): array {
$shortCodesLength = max(
(int) EnvVars::DEFAULT_SHORT_CODES_LENGTH->loadFromEnv(DEFAULT_SHORT_CODES_LENGTH),
MIN_SHORT_CODES_LENGTH,
);
$modeFromEnv = EnvVars::SHORT_URL_MODE->loadFromEnv(ShortUrlMode::STRICT->value);
$mode = ShortUrlMode::tryDeprecated($modeFromEnv) ?? ShortUrlMode::STRICT;
return [
'url_shortener' => [
'domain' => [ // TODO Refactor this structure to url_shortener.schema and url_shortener.default_domain
'schema' => ((bool) EnvVars::IS_HTTPS_ENABLED->loadFromEnv(true)) ? 'https' : 'http',
'hostname' => EnvVars::DEFAULT_DOMAIN->loadFromEnv(''),
],
'default_short_codes_length' => $shortCodesLength,
'auto_resolve_titles' => (bool) EnvVars::AUTO_RESOLVE_TITLES->loadFromEnv(false),
'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(false),
'multi_segment_slugs_enabled' => (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false),
'trailing_slash_enabled' => (bool) EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false),
'mode' => $mode,
],
];
})();

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use function Shlinkio\Shlink\Config\runningInOpenswoole;
use function Shlinkio\Shlink\Config\runningInRoadRunner;
return [
'url_shortener' => [
'domain' => [
'schema' => 'http',
'hostname' => sprintf('localhost:%s', match (true) {
runningInRoadRunner() => '8800',
runningInOpenswoole() => '8080',
default => '8000',
}),
],
'auto_resolve_titles' => true,
// 'multi_segment_slugs_enabled' => true,
// 'trailing_slash_enabled' => true,
],
];

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
// Deprecated. Webhooks are no longer supported. To be removed in Shlink 4.0.0
return (static function (): array {
$webhooks = EnvVars::VISITS_WEBHOOKS->loadFromEnv();
return [
'visits_webhooks' => [
'webhooks' => $webhooks === null ? [] : explode(',', $webhooks),
'notify_orphan_visits_to_webhooks' =>
(bool) EnvVars::NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS->loadFromEnv(false),
],
];
})();

View File

@@ -8,13 +8,31 @@ use Laminas\ConfigAggregator;
use Laminas\Diactoros;
use Mezzio;
use Mezzio\ProblemDetails;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Mezzio\Swoole;
use Shlinkio\Shlink\Config\ConfigAggregator\EnvVarLoaderProvider;
use function class_exists;
use function Shlinkio\Shlink\Config\env;
use function Shlinkio\Shlink\Config\openswooleIsInstalled;
use function Shlinkio\Shlink\Config\runningInRoadRunner;
use function Shlinkio\Shlink\Core\enumValues;
use const PHP_SAPI;
$isTestEnv = env('APP_ENV') === 'test';
$enableSwoole = PHP_SAPI === 'cli' && openswooleIsInstalled() && ! runningInRoadRunner();
return (new ConfigAggregator\ConfigAggregator(
providers: [
! $isTestEnv
? new EnvVarLoaderProvider('config/params/generated_config.php', enumValues(Core\Config\EnvVars::class))
: new ConfigAggregator\ArrayProvider([]),
Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class,
Mezzio\Router\FastRouteRouter\ConfigProvider::class,
$enableSwoole && class_exists(Swoole\ConfigProvider::class)
? Swoole\ConfigProvider::class
: new ConfigAggregator\ArrayProvider([]),
ProblemDetails\ConfigProvider::class,
Diactoros\ConfigProvider::class,
Common\ConfigProvider::class,
@@ -26,10 +44,10 @@ return (new ConfigAggregator\ConfigAggregator(
CLI\ConfigProvider::class,
Rest\ConfigProvider::class,
new ConfigAggregator\PhpFileProvider('config/autoload/{,*.}global.php'),
// Test config should be loaded ONLY during tests
EnvVars::isTestEnv()
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')
: new ConfigAggregator\ArrayProvider([]),
// Local config should not be loaded during tests, whereas test config should be loaded ONLY during tests
new ConfigAggregator\PhpFileProvider(
$isTestEnv ? 'config/test/*.global.php' : 'config/autoload/{,*.}local.php',
),
// Routes have to be loaded last
new ConfigAggregator\PhpFileProvider('config/autoload/routes.config.php'),
],

View File

@@ -9,49 +9,16 @@ use Shlinkio\Shlink\Core\Util\RedirectStatus;
const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15;
const DEFAULT_SHORT_CODES_LENGTH = 5;
const MIN_SHORT_CODES_LENGTH = 4;
const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302;
const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; // Deprecated. Default to 307 for Shlink v4
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
const DEFAULT_REDIRECT_CACHE_VISIBILITY = 'private';
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside a html title tag
const LOOSE_URI_MATCHER = '/(.+)\:(.+)/i'; // Matches anything starting with a schema.
const IP_ADDRESS_REQUEST_ATTRIBUTE = 'remote_address';
const REDIRECT_URL_REQUEST_ATTRIBUTE = 'redirect_url';
/**
* List of ISO 3166-1 alpha-2 two-letter country codes https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
*/
const ISO_COUNTRY_CODES = [
'AF', 'AX', 'AL', 'DZ', 'AS', 'AD', 'AO', 'AI', 'AQ', 'AG', 'AR', 'AM', 'AW', 'AU', 'AT', 'AZ',
'BS', 'BH', 'BD', 'BB', 'BY', 'BE', 'BZ', 'BJ', 'BM', 'BT', 'BO', 'BQ', 'BA', 'BW', 'BV', 'BR',
'IO', 'BN', 'BG', 'BF', 'BI', 'CV', 'KH', 'CM', 'CA', 'KY', 'CF', 'TD', 'CL', 'CN', 'CX', 'CC',
'CO', 'KM', 'CG', 'CD', 'CK', 'CR', 'CI', 'HR', 'CU', 'CW', 'CY', 'CZ', 'DK', 'DJ', 'DM', 'DO',
'EC', 'EG', 'SV', 'GQ', 'ER', 'EE', 'SZ', 'ET', 'FK', 'FO', 'FJ', 'FI', 'FR', 'GF', 'PF', 'TF',
'GA', 'GM', 'GE', 'DE', 'GH', 'GI', 'GR', 'GL', 'GD', 'GP', 'GU', 'GT', 'GG', 'GN', 'GW', 'GY',
'HT', 'HM', 'VA', 'HN', 'HK', 'HU', 'IS', 'IN', 'ID', 'IR', 'IQ', 'IE', 'IM', 'IL', 'IT', 'JM',
'JP', 'JE', 'JO', 'KZ', 'KE', 'KI', 'KP', 'KR', 'KW', 'KG', 'LA', 'LV', 'LB', 'LS', 'LR', 'LY',
'LI', 'LT', 'LU', 'MO', 'MG', 'MW', 'MY', 'MV', 'ML', 'MT', 'MH', 'MQ', 'MR', 'MU', 'YT', 'MX',
'FM', 'MD', 'MC', 'MN', 'ME', 'MS', 'MA', 'MZ', 'MM', 'NA', 'NR', 'NP', 'NL', 'NC', 'NZ', 'NI',
'NE', 'NG', 'NU', 'NF', 'MK', 'MP', 'NO', 'OM', 'PK', 'PW', 'PS', 'PA', 'PG', 'PY', 'PE', 'PH',
'PN', 'PL', 'PT', 'PR', 'QA', 'RE', 'RO', 'RU', 'RW', 'BL', 'SH', 'KN', 'LC', 'MF', 'PM', 'VC',
'WS', 'SM', 'ST', 'SA', 'SN', 'RS', 'SC', 'SL', 'SG', 'SX', 'SK', 'SI', 'SB', 'SO', 'ZA', 'GS',
'SS', 'ES', 'LK', 'SD', 'SR', 'SJ', 'SE', 'CH', 'SY', 'TW', 'TJ', 'TZ', 'TH', 'TL', 'TG', 'TK',
'TO', 'TT', 'TN', 'TR', 'TM', 'TC', 'TV', 'UG', 'UA', 'AE', 'GB', 'US', 'UM', 'UY', 'UZ', 'VU',
'VE', 'VN', 'VG', 'VI', 'WF', 'EH', 'YE', 'ZM', 'ZW',
];
/** @deprecated */
const DEFAULT_QR_CODE_SIZE = 300;
/** @deprecated */
const DEFAULT_QR_CODE_MARGIN = 0;
/** @deprecated */
const DEFAULT_QR_CODE_FORMAT = 'png';
/** @deprecated */
const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
/** @deprecated */
const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true;
/** @deprecated */
const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true;
/** @deprecated */
const DEFAULT_QR_CODE_COLOR = '#000000'; // Black
/** @deprecated */
const DEFAULT_QR_CODE_BG_COLOR = '#ffffff'; // White
// Deprecated. Shlink 4.0.0 should change default value to `true`
const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = false;
const MIN_TASK_WORKERS = 4;

View File

@@ -6,29 +6,25 @@ use Laminas\ServiceManager\ServiceManager;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Symfony\Component\Lock;
use function Shlinkio\Shlink\Config\loadEnvVarsFromConfig;
use function Shlinkio\Shlink\Core\enumValues;
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
// Set current directory to the project's root directory
chdir(dirname(__DIR__));
require 'vendor/autoload.php';
// Promote env vars from installer, dev config or test config
loadEnvVarsFromConfig(
EnvVars::isTestEnv() ? 'config/test/shlink_test_env.php' : 'config/params/*.php',
enumValues(EnvVars::class),
);
// Workaround to make this compatible with both openswoole 22 and earlier versions.
// Openswoole support is deprecated. Remove in v4.0.0
if (! function_exists('swoole_set_process_name')) {
// phpcs:disable
function swoole_set_process_name(string $name): void
{
OpenSwoole\Util::setProcessName($name);
}
// phpcs:enable
}
// This is one of the first files loaded. Set global configuration here
error_reporting(
// Set a less strict error reporting for prod, where deprecation warnings should be ignored
EnvVars::isProdEnv() ? E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED : E_ALL,
);
ini_set('memory_limit', EnvVars::MEMORY_LIMIT->loadFromEnv());
date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv());
// This is one of the first files loaded. Configure the timezone here
date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv(date_default_timezone_get()));
// This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name
// It needs to be placed here as individual config files will not be loaded once config is cached

View File

@@ -1,3 +1,2 @@
*
!.gitignore
!*.dist

View File

@@ -1,79 +0,0 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
use function Shlinkio\Shlink\Config\runningInRoadRunner;
return [
EnvVars::APP_ENV->value => 'dev',
// EnvVars::GEOLITE_LICENSE_KEY->value => '',
// URL shortener
EnvVars::DEFAULT_DOMAIN->value => runningInRoadRunner() ? 'localhost:8800' : 'localhost:8008',
EnvVars::IS_HTTPS_ENABLED->value => false,
// Database - MySQL
EnvVars::DB_DRIVER->value => 'mysql',
EnvVars::DB_USER->value => 'root',
EnvVars::DB_PASSWORD->value => 'root',
EnvVars::DB_NAME->value => 'shlink',
// EnvVars::DB_NAME->value => 'shlink_foo',
EnvVars::DB_HOST->value => 'shlink_db_mysql',
// Database - Maria
// EnvVars::DB_DRIVER->value => 'maria',
// EnvVars::DB_USER->value => 'root',
// EnvVars::DB_PASSWORD->value => 'root',
// EnvVars::DB_NAME->value => 'shlink_foo',
// EnvVars::DB_HOST->value => 'shlink_db_maria',
// Database - Postgres
// EnvVars::DB_DRIVER->value => 'postgres',
// EnvVars::DB_USER->value => 'postgres',
// EnvVars::DB_PASSWORD->value => 'root',
// EnvVars::DB_NAME->value => 'shlink_foo',
// EnvVars::DB_HOST->value => 'shlink_db_postgres',
// Database - MSSQL
// EnvVars::DB_DRIVER->value => 'mssql',
// EnvVars::DB_USER->value => 'sa',
// EnvVars::DB_PASSWORD->value => 'Passw0rd!',
// EnvVars::DB_NAME->value => 'shlink_foo',
// EnvVars::DB_HOST->value => 'shlink_db_ms',
// Matomo
// Dev matomo instance needs to be manually configured once before enabling the configuration below:
// 1. Go to http://localhost:8003 and follow the installation instructions.
// 2. Open data/infra/matomo/config/config.ini.php and replace `trusted_hosts[] = "localhost"` with
// `trusted_hosts[] = "localhost:8003"` (see https://github.com/matomo-org/matomo/issues/9549)
// 3. Go to http://localhost:8003/index.php?module=SitesManager&action=index and paste the ID for the site you just
// created into the `MATOMO_SITE_ID` var below.
// 4. Go to http://localhost:8003/index.php?module=UsersManager&action=userSecurity, scroll down, click
// "Create new token" and once generated, paste the token into the `MATOMO_API_TOKEN` var below.
// 5. Copy the config below and paste it in a new shlink-dev.local.env file.
EnvVars::MATOMO_ENABLED->value => false,
EnvVars::MATOMO_BASE_URL->value => 'http://shlink_matomo',
// EnvVars::MATOMO_SITE_ID->value => ,
// EnvVars::MATOMO_API_TOKEN->value => ,
// Mercure
EnvVars::MERCURE_ENABLED->value => true,
EnvVars::MERCURE_PUBLIC_HUB_URL->value => 'http://localhost:8002',
EnvVars::MERCURE_INTERNAL_HUB_URL->value => 'http://shlink_mercure_proxy',
EnvVars::MERCURE_JWT_SECRET->value => 'mercure_jwt_key_long_enough_to_avoid_error',
// RabbitMQ
EnvVars::RABBITMQ_ENABLED->value => true,
EnvVars::RABBITMQ_HOST->value => 'shlink_rabbitmq',
EnvVars::RABBITMQ_PORT->value => 5672,
EnvVars::RABBITMQ_USER->value => 'rabbit',
EnvVars::RABBITMQ_PASSWORD->value => 'rabbit',
// Redis
EnvVars::REDIS_PUB_SUB_ENABLED->value => true,
EnvVars::REDIS_SERVERS->value => 'tcp://shlink_redis:6379',
];

View File

@@ -30,17 +30,13 @@ jobs:
prefetch: 10
logs:
encoding: console
mode: development
channels:
http:
mode: 'off' # Disable logging as Shlink handles it internally
server:
encoding: console
level: info
metrics:
encoding: console
level: debug
jobs:
encoding: console
level: debug

View File

@@ -1,50 +0,0 @@
version: '3'
############################################################################################
# Routes here need to be relative to the project root, as API tests are run with `-w .` #
# See https://github.com/orgs/roadrunner-server/discussions/1440#discussioncomment-8486186 #
############################################################################################
rpc:
listen: tcp://127.0.0.1:6001
server:
command: 'php ./bin/roadrunner-worker.php'
http:
address: '0.0.0.0:9999'
middleware: ['static']
static:
dir: './public'
forbid: ['.php', '.htaccess']
pool:
num_workers: 1
debug: false
jobs:
pool:
num_workers: 1
debug: false
timeout: 300
consume: ['shlink']
pipelines:
shlink:
driver: memory
config:
priority: 10
prefetch: 10
logs:
encoding: console
mode: development
channels:
http:
mode: 'off' # Disable logging as Shlink handles it internally
server:
encoding: console
level: info
metrics:
level: panic
jobs:
encoding: console
level: panic

View File

@@ -7,20 +7,18 @@ server:
command: 'php -dopcache.enable_cli=1 -dopcache.validate_timestamps=0 ../../bin/roadrunner-worker.php'
http:
address: '${ADDRESS:-0.0.0.0}:${PORT:-8080}'
address: '0.0.0.0:${PORT:-8080}'
middleware: ['static']
static:
dir: '../../public'
forbid: ['.php', '.htaccess']
pool:
num_workers: ${WEB_WORKER_NUM:-0}
max_jobs: 250 # Restart worker after processing this amount of requests to mitigate memory leaks
jobs:
timeout: 300 # 5 minutes
pool:
num_workers: ${TASK_WORKER_NUM:-0}
max_jobs: 250 # Restart worker after processing this amount of jobs to mitigate memory leaks
consume: ['shlink']
pipelines:
shlink:
@@ -30,14 +28,11 @@ jobs:
prefetch: 10
logs:
encoding: ${LOGS_FORMAT:-console}
mode: production
channels:
http:
mode: 'off' # Disable logging as Shlink handles it internally
server:
encoding: ${LOGS_FORMAT:-console}
level: info
jobs:
encoding: ${LOGS_FORMAT:-console}
level: debug

View File

@@ -7,6 +7,12 @@ namespace Shlinkio\Shlink\TestUtils;
use Doctrine\ORM\EntityManager;
use Psr\Container\ContainerInterface;
use function register_shutdown_function;
use function sprintf;
use const ShlinkioTest\Shlink\API_TESTS_HOST;
use const ShlinkioTest\Shlink\API_TESTS_PORT;
/** @var ContainerInterface $container */
$container = require __DIR__ . '/../container.php';
$testHelper = $container->get(Helper\TestHelper::class);
@@ -14,6 +20,14 @@ $config = $container->get('config');
$em = $container->get(EntityManager::class);
$httpClient = $container->get('shlink_test_api_client');
// Dump code coverage when process shuts down
register_shutdown_function(function () use ($httpClient): void {
$httpClient->request(
'GET',
sprintf('http://%s:%s/api-tests/stop-coverage', API_TESTS_HOST, API_TESTS_PORT),
);
});
$testHelper->createTestDb(
createDbCommand: ['bin/cli', 'db:create'],
migrateDbCommand: ['bin/cli', 'db:migrate'],

View File

@@ -11,11 +11,5 @@ const ANDROID_USER_AGENT = 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (
. 'Chrome/109.0.5414.86 Mobile Safari/537.36';
const IOS_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 '
. '(KHTML, like Gecko) FxiOS/109.0 Mobile/15E148 Safari/605.1.15';
const WINDOWS_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'Chrome/138.0.0.0 Safari/537.36 Edg/138.0.3351.95';
const LINUX_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'HeadlessChrome/81.0.4044.113 Safari/537.36';
const MACOS_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 15_5) AppleWebKit/605.1.15 (KHTML, like Gecko) '
. 'Version/18.4 Safari/605.1.15';
const CHROMEOS_USER_AGENT = 'Mozilla/5.0 (X11; CrOS x86_64 16181.61.0) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'Chrome/134.0.6998.198 Safari/537.36';
const DESKTOP_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like '
. 'Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/109.0.1518.61';

View File

@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
return [
EnvVars::APP_ENV->value => 'test',
// URL shortener
EnvVars::DEFAULT_DOMAIN->value => 's.test',
EnvVars::IS_HTTPS_ENABLED->value => false,
];

View File

@@ -6,38 +6,75 @@ namespace Shlinkio\Shlink;
use GuzzleHttp\Client;
use Laminas\ConfigAggregator\ConfigAggregator;
use Laminas\Diactoros\Response\HtmlResponse;
use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Mezzio\Router\FastRouteRouter;
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;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Driver\Selector;
use SebastianBergmann\CodeCoverage\Filter;
use SebastianBergmann\CodeCoverage\Report\Html\Facade as Html;
use SebastianBergmann\CodeCoverage\Report\PHP;
use SebastianBergmann\CodeCoverage\Report\Xml\Facade as Xml;
use Shlinkio\Shlink\Common\Logger\LoggerType;
use Shlinkio\Shlink\TestUtils\ApiTest\CoverageMiddleware;
use Shlinkio\Shlink\TestUtils\CliTest\CliCoverageDelegator;
use Shlinkio\Shlink\TestUtils\Helper\CoverageHelper;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Event\ConsoleCommandEvent;
use Symfony\Component\Console\Event\ConsoleTerminateEvent;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use function file_exists;
use function Laminas\Stratigility\middleware;
use function Shlinkio\Shlink\Config\env;
use function sleep;
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function sprintf;
use function sys_get_temp_dir;
use const ShlinkioTest\Shlink\API_TESTS_HOST;
use const ShlinkioTest\Shlink\API_TESTS_PORT;
$testEnv = env('TEST_ENV');
$isApiTest = $testEnv === 'api';
$isCliTest = $testEnv === 'cli';
$isApiTest = env('TEST_ENV') === 'api';
$isCliTest = env('TEST_ENV') === 'cli';
$isE2eTest = $isApiTest || $isCliTest;
$coverageType = env('GENERATE_COVERAGE');
$generateCoverage = $coverageType === 'yes';
$coverage = $isE2eTest && $generateCoverage ? CoverageHelper::createCoverageForDirectories(
[
__DIR__ . '/../../module/Core/src',
__DIR__ . '/../../module/' . ($isApiTest ? 'Rest' : 'CLI') . '/src',
],
__DIR__ . '/../../build/coverage-' . $testEnv,
) : null;
$generateCoverage = contains($coverageType, ['yes', 'pretty']);
$coverage = null;
if ($isE2eTest && $generateCoverage) {
$filter = new Filter();
$filter->includeDirectory(__DIR__ . '/../../module/Core/src');
$filter->includeDirectory(__DIR__ . '/../../module/' . ($isApiTest ? 'Rest' : 'CLI') . '/src');
$coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter);
}
/**
* @param 'api'|'cli' $type
*/
$exportCoverage = static function (string $type = 'api') use (&$coverage, $coverageType): void {
if ($coverage === null) {
return;
}
$basePath = __DIR__ . '/../../build/coverage-' . $type;
$covPath = $basePath . '.cov';
// Every CLI test runs on its own process and dumps the coverage afterwards.
// Try to load it and merge it, so that we end up with the whole coverage at the end.
if ($type === 'cli' && file_exists($covPath)) {
$coverage->merge(require $covPath);
}
if ($coverageType === 'pretty') {
(new Html())->process($coverage, $basePath . '/coverage-html');
} else {
(new PHP())->process($coverage, $covPath);
(new Xml(Version::getVersionString()))->process($coverage, $basePath . '/coverage-xml');
}
};
$buildDbConnection = static function (): array {
$driver = env('DB_DRIVER', 'sqlite');
@@ -52,7 +89,7 @@ $buildDbConnection = static function (): array {
'postgres' => [
'driver' => 'pdo_pgsql',
'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres',
'port' => $isCi ? '5434' : '5432',
'port' => $isCi ? '5433' : '5432',
'user' => 'postgres',
'password' => 'root',
'dbname' => 'shlink_test',
@@ -91,28 +128,66 @@ return [
'debug' => true,
ConfigAggregator::ENABLE_CACHE => false,
FastRouteRouter::CONFIG_CACHE_ENABLED => false,
'routes' => [
// This route is used to test that title resolution is skipped if the long URL times out
'url_shortener' => [
'domain' => [
'schema' => 'http',
'hostname' => 's.test',
],
],
'mezzio-swoole' => [
'enable_coroutine' => false,
'swoole-http-server' => [
'host' => API_TESTS_HOST,
'port' => API_TESTS_PORT,
'process-name' => 'shlink_test',
'options' => [
'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid',
'log_file' => __DIR__ . '/../../data/log/api-tests/output.log',
'enable_coroutine' => false,
],
],
],
'routes' => !$isApiTest ? [] : [
[
'name' => 'long_url_with_timeout',
'path' => '/api-tests/long-url-with-timeout',
'allowed_methods' => ['GET'],
'middleware' => middleware(static function () {
sleep(5); // Title resolution times out at 3 seconds
return new HtmlResponse('<title>The title</title>');
'name' => 'dump_coverage',
'path' => '/api-tests/stop-coverage',
'middleware' => middleware(static function () use ($exportCoverage) {
// TODO I have tried moving this block to a listener so that it's invoked automatically,
// but then the coverage is generated empty ¯\_(ツ)_/¯
$exportCoverage();
return new EmptyResponse();
}),
'allowed_methods' => ['GET'],
],
],
'middleware_pipeline' => !$isApiTest ? [] : [
'capture_code_coverage' => [
'middleware' => new CoverageMiddleware($coverage),
'middleware' => middleware(static function (
ServerRequestInterface $req,
RequestHandlerInterface $handler,
) use (&$coverage): ResponseInterface {
$coverage?->start($req->getHeaderLine('x-coverage-id'));
try {
return $handler->handle($req);
} finally {
$coverage?->stop();
}
}),
'priority' => 9999,
],
],
'mercure' => [
'public_hub_url' => null,
'internal_hub_url' => null,
'jwt_secret' => null,
],
'dependencies' => [
'services' => [
'shlink_test_api_client' => new Client([
@@ -125,7 +200,58 @@ return [
],
'delegators' => $isCliTest ? [
Application::class => [
new CliCoverageDelegator($coverage),
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;
},
],
] : [],
],
@@ -136,7 +262,7 @@ return [
'data_fixtures' => [
'paths' => [
// TODO These are used for other module's tests, so maybe should be somewhere else
// TODO These are used for CLI tests too, so maybe should be somewhere else
__DIR__ . '/../../module/Rest/test-api/Fixtures',
],
],

View File

@@ -3,7 +3,7 @@
set -ex
curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add -
curl https://packages.microsoft.com/config/ubuntu/24.04/prod.list > /etc/apt/sources.list.d/mssql-release.list
curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list > /etc/apt/sources.list.d/mssql-release.list
apt-get update
ACCEPT_EULA=Y apt-get install msodbcsql18
# apt-get install unixodbc-dev

View File

@@ -11,7 +11,7 @@ server {
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
}

View File

@@ -0,0 +1,13 @@
/var/log/shlink/shlink_openswoole.log {
su root root
daily
missingok
rotate 120
compress
delaycompress
notifempty
create 0640 root root
postrotate
/etc/init.d/shlink_openswoole restart
endscript
}

View File

@@ -0,0 +1,54 @@
#!/bin/bash
### BEGIN INIT INFO
# Provides: shlink_openswoole
# Required-Start: $local_fs $network $named $time $syslog
# Required-Stop: $local_fs $network $named $time $syslog
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Description: Shlink non-blocking server with openswoole
### END INIT INFO
SCRIPT=/path/to/shlink/vendor/bin/laminas\ mezzio:swoole:start
RUNAS=root
PIDFILE=/var/run/shlink_openswoole.pid
LOGDIR=/var/log/shlink
LOGFILE=${LOGDIR}/shlink_openswoole.log
start() {
if [[ -f "$PIDFILE" ]] && kill -0 $(cat "$PIDFILE"); then
echo 'Shlink with openswoole already running' >&2
return 1
fi
echo 'Starting shlink with openswoole' >&2
mkdir -p "$LOGDIR"
touch "$LOGFILE"
local CMD="$SCRIPT &> \"$LOGFILE\" & echo \$!"
su -c "$CMD" $RUNAS > "$PIDFILE"
echo 'Shlink started' >&2
}
stop() {
if [[ ! -f "$PIDFILE" ]] || ! kill -0 $(cat "$PIDFILE"); then
echo 'Shlink with openswoole not running' >&2
return 1
fi
echo 'Stopping shlink with openswoole' >&2
kill -15 $(cat "$PIDFILE") && rm -f "$PIDFILE"
echo 'Shlink stopped' >&2
}
case "$1" in
start)
start
;;
stop)
stop
;;
restart)
stop
start
;;
*)
echo "Usage: $0 {start|stop|restart}"
esac

View File

@@ -1,55 +0,0 @@
FROM dunglas/frankenphp:1-php8.4-alpine
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV PDO_SQLSRV_VERSION='5.12.0'
ENV MS_ODBC_DOWNLOAD='7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
ENV MS_ODBC_SQL_VERSION='18_18.4.1.1'
RUN apk update
# Install common php extensions
RUN docker-php-ext-install pdo_mysql
RUN docker-php-ext-install calendar
RUN apk add --no-cache oniguruma-dev
RUN docker-php-ext-install mbstring
RUN apk add --no-cache sqlite-libs
RUN apk add --no-cache sqlite-dev
RUN docker-php-ext-install pdo_sqlite
RUN apk add --no-cache icu-dev
RUN docker-php-ext-install intl
RUN apk add --no-cache libzip-dev zlib-dev
RUN docker-php-ext-install zip
RUN apk add --no-cache libpng-dev
RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
docker-php-ext-install sockets && \
apk del .phpize-deps
RUN docker-php-ext-install bcmath
# Install xdebug and sqlsrv driver
RUN apk add --update linux-headers && \
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} xdebug && \
docker-php-ext-enable pdo_sqlsrv xdebug && \
apk del .phpize-deps && \
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk
# Install composer
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
# Make home directory writable by anyone
RUN chmod 777 /home
VOLUME /home/shlink
WORKDIR /home/shlink

View File

@@ -1,2 +0,0 @@
*
!.gitignore

View File

@@ -1,2 +0,0 @@
*
!.gitignore

View File

@@ -1,10 +1,10 @@
FROM php:8.4-fpm-alpine3.21
FROM php:8.2-fpm-alpine3.17
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION='5.1.24'
ENV PDO_SQLSRV_VERSION='5.12.0'
ENV MS_ODBC_DOWNLOAD='7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
ENV MS_ODBC_SQL_VERSION='18_18.4.1.1'
ENV APCU_VERSION 5.1.21
ENV PDO_SQLSRV_VERSION 5.11.1
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
RUN apk update
@@ -46,13 +46,12 @@ RUN mkdir -p /usr/src/php/ext/apcu \
&& 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 xdebug and sqlsrv driver
RUN apk add --update linux-headers && \
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
# Install pcov and sqlsrv driver
RUN wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} xdebug && \
docker-php-ext-enable pdo_sqlsrv xdebug && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
docker-php-ext-enable pdo_sqlsrv pcov && \
apk del .phpize-deps && \
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk

View File

@@ -1,4 +1,8 @@
display_errors=On
error_reporting=-1
memory_limit=-1
log_errors_max_len=0
zend.assertions=1
assert.exception=1
pcov.enabled=1
pcov.directory=module

View File

@@ -1,9 +1,10 @@
FROM php:8.4-alpine3.21
FROM php:8.2-alpine3.17
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV PDO_SQLSRV_VERSION='5.12.0'
ENV MS_ODBC_DOWNLOAD='7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
ENV MS_ODBC_SQL_VERSION='18_18.4.1.1'
ENV APCU_VERSION 5.1.21
ENV PDO_SQLSRV_VERSION 5.11.1
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
RUN apk update
@@ -35,13 +36,22 @@ RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
apk del .phpize-deps
RUN docker-php-ext-install bcmath
# Install xdebug and sqlsrv driver
RUN apk add --update linux-headers && \
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
# 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/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} xdebug && \
docker-php-ext-enable pdo_sqlsrv xdebug && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
docker-php-ext-enable pdo_sqlsrv pcov && \
apk del .phpize-deps && \
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk
@@ -62,7 +72,5 @@ CMD \
if [[ ! -d "./vendor" ]]; then /usr/local/bin/composer install ; fi && \
# Download roadrunner binary
if [[ ! -f "./bin/rr" ]]; then ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr ; fi && \
# Create env file if it does not exist yet
if [[ ! -f "./config/params/shlink_dev_env.php" ]]; then cp ./config/params/shlink_dev_env.php.dist ./config/params/shlink_dev_env.php ; fi && \
# Run with `exec` so that signals are properly handled
exec ./bin/rr serve -c config/roadrunner/.rr.dev.yml
# 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

@@ -0,0 +1,85 @@
FROM php:8.2-alpine3.17
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.21
ENV INOTIFY_VERSION 3.0.0
ENV OPENSWOOLE_VERSION 22.1.0
ENV PDO_SQLSRV_VERSION 5.11.1
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
RUN apk update
# Install common php extensions
RUN docker-php-ext-install pdo_mysql
RUN docker-php-ext-install calendar
RUN apk add --no-cache oniguruma-dev
RUN docker-php-ext-install mbstring
RUN apk add --no-cache sqlite-libs
RUN apk add --no-cache sqlite-dev
RUN docker-php-ext-install pdo_sqlite
RUN apk add --no-cache icu-dev
RUN docker-php-ext-install intl
RUN apk add --no-cache libzip-dev zlib-dev
RUN docker-php-ext-install zip
RUN apk add --no-cache libpng-dev
RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
docker-php-ext-install sockets && \
apk del .phpize-deps
RUN docker-php-ext-install bcmath
# Install APCu extension
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu \
&& tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \
&& docker-php-ext-configure apcu \
&& docker-php-ext-install apcu \
&& rm /tmp/apcu.tar.gz \
&& rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \
&& echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
# Install inotify extension
ADD https://pecl.php.net/get/inotify-$INOTIFY_VERSION.tgz /tmp/inotify.tar.gz
RUN mkdir -p /usr/src/php/ext/inotify \
&& tar xf /tmp/inotify.tar.gz -C /usr/src/php/ext/inotify --strip-components=1 \
&& docker-php-ext-configure inotify \
&& docker-php-ext-install inotify \
&& rm /tmp/inotify.tar.gz
# Install openswoole, pcov and mssql driver
RUN wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install openswoole-${OPENSWOOLE_VERSION} pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
docker-php-ext-enable openswoole pdo_sqlsrv pcov && \
apk del .phpize-deps && \
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk
# Install composer
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
# Make home directory writable by anyone
RUN chmod 777 /home
VOLUME /home/shlink
WORKDIR /home/shlink
# Expose openswoole port
EXPOSE 8080
CMD \
# Install dependencies if the vendor dir does not exist
if [[ ! -d "./vendor" ]]; then /usr/local/bin/composer install ; fi && \
# When restarting the container, openswoole might think it is already in execution
# This forces the app to be started every second until the exit code is 0
until php ./vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done

View File

@@ -0,0 +1,14 @@
server {
listen 80 default_server;
error_log /home/shlink/www/data/infra/nginx/swoole_proxy.error.log;
location / {
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://shlink_swoole:8080;
proxy_read_timeout 90s;
}
}

View File

@@ -1,2 +0,0 @@
*
!.gitignore

View File

@@ -1,15 +1,14 @@
version: '3'
services:
shlink_db_mysql:
user: '0'
environment:
MYSQL_DATABASE: shlink_test
shlink_db_postgres:
user: '0'
environment:
POSTGRES_DB: shlink_test
shlink_db_maria:
user: '0'
environment:
MYSQL_DATABASE: shlink_test

View File

@@ -0,0 +1,38 @@
version: '3'
services:
shlink_php:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
shlink_swoole:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
shlink_roadrunner:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
shlink_db_mysql:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
shlink_db_postgres:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
shlink_db_maria:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro

View File

@@ -1,3 +1,5 @@
version: '3'
services:
shlink_nginx:
container_name: shlink_nginx
@@ -13,7 +15,6 @@ services:
shlink_php:
container_name: shlink_php
user: 1000:1000
build:
context: .
dockerfile: ./data/infra/php.Dockerfile
@@ -35,13 +36,49 @@ services:
- shlink_matomo
environment:
LC_ALL: C
DEFAULT_DOMAIN: localhost:8000
extra_hosts:
- 'host.docker.internal:host-gateway'
shlink_swoole_proxy:
container_name: shlink_swoole_proxy
image: nginx:1.25-alpine
ports:
- "8002:80"
volumes:
- ./:/home/shlink/www
- ./data/infra/swoole_proxy_vhost.conf:/etc/nginx/conf.d/default.conf
links:
- shlink_swoole
shlink_swoole:
container_name: shlink_swoole
build:
context: .
dockerfile: ./data/infra/swoole.Dockerfile
ports:
- "8080:8080"
- "9001:9001"
volumes:
- ./:/home/shlink
- ./data/infra/php.ini:/usr/local/etc/php/php.ini
links:
- shlink_db_mysql
- shlink_db_postgres
- shlink_db_maria
- shlink_db_ms
- shlink_redis
- shlink_redis_acl
- shlink_mercure
- shlink_mercure_proxy
- shlink_rabbitmq
- shlink_matomo
environment:
LC_ALL: C
extra_hosts:
- 'host.docker.internal:host-gateway'
shlink_roadrunner:
container_name: shlink_roadrunner
user: 1000:1000
build:
context: .
dockerfile: ./data/infra/roadrunner.Dockerfile
@@ -66,40 +103,8 @@ services:
extra_hosts:
- 'host.docker.internal:host-gateway'
shlink_frankenphp:
container_name: shlink_frankenphp
user: 1000:1000
build:
context: .
dockerfile: ./data/infra/frankenphp.Dockerfile
ports:
- "8008:8008"
volumes:
- ./:/home/shlink
- ./data/infra/php.ini:/usr/local/etc/php/php.ini
- ./data/infra/frankenphp_caddy_data:/data
- ./data/infra/frankenphp_caddy_config:/config
links:
- shlink_db_mysql
- shlink_db_postgres
- shlink_db_maria
- shlink_db_ms
- shlink_redis
- shlink_redis_acl
- shlink_mercure
- shlink_mercure_proxy
- shlink_rabbitmq
- shlink_matomo
environment:
FRANKENPHP_CONFIG: 'worker /home/shlink/bin/frankenphp-worker.php'
SERVER_NAME: ':8008 https:8009'
extra_hosts:
- 'host.docker.internal:host-gateway'
tty: true
shlink_db_mysql:
container_name: shlink_db_mysql
user: 1000:1000
image: mysql:8.0
ports:
- "3307:3306"
@@ -112,10 +117,9 @@ services:
shlink_db_postgres:
container_name: shlink_db_postgres
user: 1000:1000
image: postgres:16.3-alpine
image: postgres:12.2-alpine
ports:
- "5434:5432"
- "5433:5432"
volumes:
- ./:/home/shlink/www
- ./data/infra/database_pg:/var/lib/postgresql/data
@@ -126,7 +130,6 @@ services:
shlink_db_maria:
container_name: shlink_db_maria
user: 1000:1000
image: mariadb:10.7
ports:
- "3308:3306"
@@ -140,7 +143,7 @@ services:
shlink_db_ms:
container_name: shlink_db_ms
image: mcr.microsoft.com/mssql/server:2022-latest
image: mcr.microsoft.com/mssql/server:2019-latest
ports:
- "1433:1433"
environment:
@@ -149,13 +152,13 @@ services:
shlink_redis:
container_name: shlink_redis
image: redis:7.4-alpine
image: redis:6.2-alpine
ports:
- "6380:6379"
shlink_redis_acl:
container_name: shlink_redis_acl
image: redis:7.4-alpine
image: redis:6.2-alpine
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
ports:
- "6382:6379"
@@ -166,7 +169,7 @@ services:
container_name: shlink_mercure_proxy
image: nginx:1.25-alpine
ports:
- "8002:80"
- "8001:80"
volumes:
- ./:/home/shlink/www
- ./data/infra/mercure_proxy_vhost.conf:/etc/nginx/conf.d/default.conf
@@ -175,28 +178,28 @@ services:
shlink_mercure:
container_name: shlink_mercure
image: dunglas/mercure:v0.18
image: dunglas/mercure:v0.15
ports:
- "3080:80"
environment:
SERVER_NAME: ":80"
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 http://localhost:3002 http://127.0.0.1:3002 http://localhost:3005 http://127.0.0.1:3005"
MERCURE_EXTRA_DIRECTIVES: "cors_origins https://app.shlink.io http://localhost:3000 http://127.0.0.1:3000"
shlink_rabbitmq:
container_name: shlink_rabbitmq
image: rabbitmq:3.11-management-alpine
ports:
- "15673:15672"
- "5673:5672"
- "15672:15672"
- "5672:5672"
environment:
RABBITMQ_DEFAULT_USER: "rabbit"
RABBITMQ_DEFAULT_PASS: "rabbit"
shlink_swagger_ui:
container_name: shlink_swagger_ui
image: swaggerapi/swagger-ui:v5.11.3
image: swaggerapi/swagger-ui:v5.10.3
ports:
- "8005:8080"
volumes:
@@ -204,7 +207,7 @@ services:
shlink_matomo:
container_name: shlink_matomo
image: matomo:5.0-apache
image: matomo:4.15-apache
ports:
- "8003:80"
volumes:

View File

@@ -5,7 +5,7 @@
This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime.
It exposes a shlink instance served with [RoadRunner](https://roadrunner.dev), which can be linked to external databases to persist data.
It exposes a shlink instance served with [RoadRunner](https://roadrunner.dev) or [openswoole](https://openswoole.com/), which can be linked to external databases to persist data.
## Usage

View File

@@ -1,3 +1,4 @@
log_errors_max_len=0
zend.assertions=1
assert.exception=1
memory_limit=512M

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Shlinkio\Shlink\Common\Logger\LoggerType;
return [
'logger' => [
'Shlink' => [
'type' => LoggerType::STREAM->value,
'destination' => 'php://stderr',
],
],
];

View File

@@ -4,28 +4,35 @@ set -e
cd /etc/shlink
# Create data directories if they do not exist. This allows data dir to be mounted as an empty dir if needed
mkdir -p data/cache data/locks data/log data/proxies data/temp-geolite
mkdir -p data/cache data/locks data/log data/proxies
flags="--no-interaction --clear-db-cache"
# Read env vars through Shlink command, so that it applies the `_FILE` env var fallback logic
geolite_license_key=$(bin/cli env-var:read GEOLITE_LICENSE_KEY)
skip_initial_geolite_download=$(bin/cli env-var:read SKIP_INITIAL_GEOLITE_DOWNLOAD)
initial_api_key=$(bin/cli env-var:read INITIAL_API_KEY)
# Skip downloading GeoLite2 db file if the license key env var was not defined or skipping was explicitly set
if [ -z "${geolite_license_key}" ] || [ "${skip_initial_geolite_download}" = "true" ]; then
if [ -z "${GEOLITE_LICENSE_KEY}" ] || [ "${SKIP_INITIAL_GEOLITE_DOWNLOAD}" = "true" ]; then
flags="${flags} --skip-download-geolite"
fi
# If INITIAL_API_KEY was provided, create an initial API key
if [ -n "${initial_api_key}" ]; then
flags="${flags} --initial-api-key=${initial_api_key}"
if [ -n "${INITIAL_API_KEY}" ]; then
flags="${flags} --initial-api-key=${INITIAL_API_KEY}"
fi
php vendor/bin/shlink-installer init ${flags}
if [ "$SHLINK_RUNTIME" = 'rr' ]; then
# Run with `exec` so that signals are properly handled
exec ./bin/rr serve -c config/roadrunner/.rr.yml
# Periodically run visit:locate every hour, if ENABLE_PERIODIC_VISIT_LOCATE=true was provided and running as root
# FIXME: ENABLE_PERIODIC_VISIT_LOCATE is deprecated. Remove cron support in Shlink 4.0.0
if [ "${ENABLE_PERIODIC_VISIT_LOCATE}" = "true" ] && [ "${SHLINK_USER_ID}" = "root" ]; then
echo "Configuring periodic visit location..."
echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root
/usr/sbin/crond &
fi
if [ "$SHLINK_RUNTIME" = 'openswoole' ]; then
# Openswoole is deprecated. Remove in Shlink 4.0.0
# When restarting the container, openswoole might think it is already in execution
# This forces the app to be started every second until the exit code is 0
until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done
elif [ "$SHLINK_RUNTIME" = 'rr' ]; then
./bin/rr serve -c config/roadrunner/.rr.yml
fi

View File

@@ -1,61 +0,0 @@
# Handle dev and tests config via env vars instead of local config files
* Status: Accepted
* Date: 2024-10-24
## Context and problem statement
Due to the tools used by Shlink (Zend Expressive first and Mezzio later), configuration has always been handled via the config aggregator, which is a package that continues with Zend Framework 2 config management philosophy:
1. Define multiple config files, scoped to their own context, that are merge at runtime.
2. Overwrite with so-called "local" config files, which define values used only during development, and should not be shipped to production.
However, since Shlink started to support other runtimes and added an official docker image, env vars have started to become a central part of the config definition system.
That has evolved into a system where production config can be read from env vars, but dev config is expected to be defined via local config files, forcing to maintain two approaches to load config that need to coexist.
On top of that, keeping dev configs in multiple files makes it harder to keep track of everything.
Because of that, I'm proposing to switch to an env-var-based approach for dev custom configs, and get rid of local config files.
## Considered options
1. Define dev env vars in a single `.env` file which is loaded to containers via docker compose `env-file` option.
2. Define dev env vars in a single `.env` file which is loaded via RoadRunner config.
3. Define dev env vars in a single PHP file returning a map that's then loaded with `loadEnvVarsFromConfig`.
4. Keep local config files and don't change anything.
## Decision outcome
Defining env vars in a PHP file has the benefit that any change will take effect immediately, so the decision is to go with option 3.
## Pros and Cons of the Options
### 1 - .env file via docker compose
* Good: because it does not require any special mechanism to feed the env vars into the app.
* Good: because it's a standard format known by many.
* Bad: because dev config gets leaked to tests when run inside the container, breaking some existing ones, and forcing to remember this for future tests.
* Bad: because any change to the env file requires the containers to be manually restarted, or putting some new mechanism in place to restart them automatically.
### 2 - .env file via RoadRunner
* Good: because it does not require any special mechanism to feed the env vars into the app.
* Good: because it's a standard format known by many.
* Good: because dev config does not get leaked into tests.
* Bad: because any change to the env file requires the containers to be manually restarted, or putting some new mechanism in place to restart them automatically.
### 3 - PHP file via `loadEnvVarsFromConfig`
* Good: because the existing call to `loadEnvVarsFromConfig` can be reused by tweaking a bit the glob pattern, so no new dependencies are needed.
* Good: because dev config does not get leaked into tests, and test-specific env vars can be fed using the same mechanism.
* Good: because changes are picked up instantly by both RoadRunner and php-fpm.
* Good: because env vars can be imported from `EnvVars` class, removing the chances of human mistakes and typos.
* Bad: because people not familiar with the project may not expect env vars to be defined in that format.
### 4 - keep local config
* Good: because no changes are needed in the project.
* Bad: because managing multiple local config files makes things harder to maintain.
* Bad: because setting-up the project from scratch requires more steps, or an external package to handle config files.
* Bad: because the project needs to keep two ways to load dev configs, and reading an env var does not warranty you are getting the single source of truth.

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