Compare commits

..

34 Commits

Author SHA1 Message Date
Alejandro Celaya
361d987f47 Merge pull request #1970 from shlinkio/develop
Release 3.7.3
2024-01-04 14:07:53 +01:00
Alejandro Celaya
6017db260a Add v3.7.3 to changelog 2024-01-04 14:02:00 +01:00
Alejandro Celaya
f9c9b3d981 Merge pull request #1969 from acelaya-forks/feature/mountable-data-dir
Feature/mountable data dir
2024-01-04 08:42:41 +01:00
Alejandro Celaya
e7b876f4e6 Update changelog 2024-01-03 19:42:33 +01:00
Alejandro Celaya
554b948775 Create data directories in docker entry point if they don't exist 2024-01-03 19:22:33 +01:00
Alejandro Celaya
9bdbb59401 Update shlinkio/shlink-testing-utils 2024-01-03 10:08:03 +01:00
Alejandro Celaya
377861c5f1 Move migrations to module/Core 2024-01-02 17:55:23 +01:00
Alejandro Celaya
26c2aaf567 Merge pull request #1963 from shlinkio/develop
Release 3.7.2
2023-12-26 16:23:49 +01:00
Alejandro Celaya
62b54ceaaf Add v3.7.2 to changelog 2023-12-26 16:16:10 +01:00
Alejandro Celaya
625eba76c7 Merge pull request #1962 from acelaya-forks/feature/disabled-qr-codes
Allow QR codes to be generated for disabled short URLs
2023-12-24 16:55:52 +01:00
Alejandro Celaya
e12bda3f42 Add API test to verify QR codes return a 404 for disabled short URLs 2023-12-24 10:37:09 +01:00
Alejandro Celaya
0f0301ae5c Update changelog 2023-12-24 10:27:25 +01:00
Alejandro Celaya
8d1776af98 Test error when short URLs cannot be resolved 2023-12-24 10:25:58 +01:00
Alejandro Celaya
c597738915 Test how URLs are resolved in QrCodeAction 2023-12-24 10:13:19 +01:00
Alejandro Celaya
639329dbe4 Update installer 2023-12-24 09:48:44 +01:00
Alejandro Celaya
92b0525b6e Update Twitter badge 2023-12-23 11:14:12 +01:00
Alejandro Celaya
06306aabd5 Allow QR codes to be generated for disabled short URLs 2023-12-22 13:29:22 +01:00
Alejandro Celaya
225905fcdb update changelog 2023-12-19 11:22:40 +01:00
Alejandro Celaya
8ca2b3c641 Merge pull request #1955 from acelaya-forks/feature/artifact-actions
Update artifact GitHub actions
2023-12-19 11:19:34 +01:00
Alejandro Celaya
ac1737492b Update artifact GitHub actions 2023-12-19 11:13:13 +01:00
Alejandro Celaya
a63075eb4c Merge pull request #1953 from shlinkio/develop
Release 3.7.1
2023-12-17 20:06:25 +01:00
Alejandro Celaya
97e9dfad67 Merge pull request #1952 from shlinkio/feature/rr-logs-improvement
Feature/rr logs improvement
2023-12-17 19:59:21 +01:00
Alejandro Celaya
17c4f13568 Set fixed versions for Shlink dependencies 2023-12-17 19:49:50 +01:00
Alejandro Celaya
3b5243689b Fine-tune RoadRunner logs to avoid too many useless info 2023-12-17 19:26:28 +01:00
Alejandro Celaya
4d28adf4a7 Merge pull request #1948 from acelaya-forks/feature/fix-postgres-import
Fix error when importing short URLs while using Postgres
2023-12-16 20:38:46 +01:00
Alejandro Celaya
1b14bb07b1 Fix error when importing short URLs while using Postgres 2023-12-16 20:22:39 +01:00
Alejandro Celaya
3a43aa4d41 Merge pull request #1942 from acelaya-forks/feature/geoip-3
Update to geolite2 v3
2023-12-07 07:52:58 +01:00
Alejandro Celaya
2340b4f601 Update to geolite2 v3 2023-12-06 21:48:54 +01:00
Alejandro Celaya
664886eddf Support laminas-diactoros 3 2023-11-30 22:10:41 +01:00
Alejandro Celaya
d3570dac0b Merge pull request #1937 from acelaya-forks/feature/remove-functional
Feature/remove functional
2023-11-30 18:53:21 +01:00
Alejandro Celaya
1854cc2f19 Remove last references to functional-php 2023-11-30 18:39:27 +01:00
Alejandro Celaya
bff4bd12ae Removed more functional-php usages 2023-11-30 14:34:44 +01:00
Alejandro Celaya
549c6605f0 Replaced usage of Functional\contians 2023-11-30 09:13:29 +01:00
Alejandro Celaya
f50263d2d9 Remove usage of Functional\map function 2023-11-29 12:34:13 +01:00
121 changed files with 682 additions and 326 deletions

View File

@@ -36,7 +36,7 @@ jobs:
- name: Run tests - name: Run tests
run: composer test:db:${{ inputs.platform }} run: composer test:db:${{ inputs.platform }}
- name: Upload code coverage - name: Upload code coverage
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
if: ${{ matrix.php-version == '8.2' && inputs.platform == 'sqlite:ci' }} if: ${{ matrix.php-version == '8.2' && inputs.platform == 'sqlite:ci' }}
with: with:
name: coverage-db name: coverage-db

View File

@@ -22,7 +22,7 @@ jobs:
php-version: ${{ matrix.php-version }} php-version: ${{ matrix.php-version }}
php-extensions: openswoole-22.1.0 php-extensions: openswoole-22.1.0
extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v4
with: with:
name: coverage-${{ inputs.test-group }} name: coverage-${{ inputs.test-group }}
path: build path: build

View File

@@ -29,7 +29,7 @@ jobs:
php-extensions: openswoole-22.1.0 php-extensions: openswoole-22.1.0
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
- run: composer test:${{ inputs.test-group }}:ci - run: composer test:${{ inputs.test-group }}:ci
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v4
if: ${{ matrix.php-version == '8.2' }} if: ${{ matrix.php-version == '8.2' }}
with: with:
name: coverage-${{ inputs.test-group }} name: coverage-${{ inputs.test-group }}

View File

@@ -146,7 +146,7 @@ jobs:
php-version: ${{ matrix.php-version }} php-version: ${{ matrix.php-version }}
coverage: pcov coverage: pcov
ini-values: pcov.directory=module ini-values: pcov.directory=module
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v4
with: with:
path: build path: build
- run: mv build/coverage-unit/coverage-unit.cov build/coverage-unit.cov - run: mv build/coverage-unit/coverage-unit.cov build/coverage-unit.cov

View File

@@ -24,7 +24,7 @@ jobs:
run: ./build.sh ${GITHUB_REF#refs/tags/v} run: ./build.sh ${GITHUB_REF#refs/tags/v}
- if: ${{ matrix.swoole == 'no' }} - if: ${{ matrix.swoole == 'no' }}
run: ./build.sh ${GITHUB_REF#refs/tags/v} --no-swoole run: ./build.sh ${GITHUB_REF#refs/tags/v} --no-swoole
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v4
with: with:
name: dist-files-${{ matrix.php-version }}-${{ matrix.swoole }} name: dist-files-${{ matrix.php-version }}-${{ matrix.swoole }}
path: build path: build
@@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v4
with: with:
path: build path: build
- name: Publish release with assets - name: Publish release with assets

View File

@@ -4,6 +4,59 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [3.7.3] - 2024-01-04
### Added
* *Nothing*
### Changed
* [#1968](https://github.com/shlinkio/shlink/issues/1968) Move migrations from `data` to `module/Core`.
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1967](https://github.com/shlinkio/shlink/issues/1967) Allow an empty dir to be mounted in `data` when using the docker image.
## [3.7.2] - 2023-12-26
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1960](https://github.com/shlinkio/shlink/issues/1960) Allow QR codes to be optionally resolved even when corresponding short URL is not enabled.
## [3.7.1] - 2023-12-17
### Added
* *Nothing*
### Changed
* Remove dependency on functional-php library
* [#1939](https://github.com/shlinkio/shlink/issues/1939) Fine-tune RoadRunner logs to avoid too many useless info.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1947](https://github.com/shlinkio/shlink/issues/1947) Fix error when importing short URLs while using Postgres.
## [3.7.0] - 2023-11-25 ## [3.7.0] - 2023-11-25
### Added ### Added
* [#1798](https://github.com/shlinkio/shlink/issues/1798) Experimental support to send visits to an external Matomo instance. * [#1798](https://github.com/shlinkio/shlink/issues/1798) Experimental support to send visits to an external Matomo instance.

View File

@@ -46,17 +46,18 @@ This is a simplified version of the project structure:
``` ```
shlink shlink
├── bin ├── bin
── cli ── cli
│ └── [...]
├── config ├── config
│ ├── autoload │ ├── autoload
│ ├── params │ ├── params
│ ├── config.php │ ├── config.php
── container.php ── container.php
│ └── [...]
├── data ├── data
│ ├── cache │ ├── cache
│ ├── locks │ ├── locks
│ ├── log │ ├── log
│ ├── migrations
│ └── proxies │ └── proxies
├── docs ├── docs
│ ├── adr │ ├── adr
@@ -67,6 +68,7 @@ shlink
│ ├── Core │ ├── Core
│ └── Rest │ └── Rest
├── public ├── public
│ └── [...]
├── composer.json ├── composer.json
└── README.md └── README.md
``` ```
@@ -75,7 +77,7 @@ The purposes of every folder are:
* `bin`: It contains the CLI tools. The `cli` one is the main entry point to run Shlink from the command line. * `bin`: It contains the CLI tools. The `cli` one is the main entry point to run Shlink from the command line.
* `config`: Contains application-wide configurations, which are later merged with the ones provided by every module. * `config`: Contains application-wide configurations, which are later merged with the ones provided by every module.
* `data`: Common runtime-generated git-ignored assets, like logs, caches, etc. * `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. * `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. * `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 or openswoole. * `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.

View File

@@ -6,7 +6,7 @@
[![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink) [![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/) [![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
[![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/main/LICENSE) [![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=twitter&color=blue)](https://twitter.com/shlinkio) [![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) [![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)
[![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate) [![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate)

View File

@@ -23,18 +23,17 @@
"doctrine/orm": "^2.16", "doctrine/orm": "^2.16",
"endroid/qr-code": "^4.8", "endroid/qr-code": "^4.8",
"friendsofphp/proxy-manager-lts": "^1.0", "friendsofphp/proxy-manager-lts": "^1.0",
"geoip2/geoip2": "^2.13", "geoip2/geoip2": "^3.0",
"guzzlehttp/guzzle": "^7.5", "guzzlehttp/guzzle": "^7.5",
"happyr/doctrine-specification": "^2.0", "happyr/doctrine-specification": "^2.0",
"jaybizzle/crawler-detect": "^1.2.116", "jaybizzle/crawler-detect": "^1.2.116",
"laminas/laminas-config": "^3.8", "laminas/laminas-config": "^3.8",
"laminas/laminas-config-aggregator": "^1.13", "laminas/laminas-config-aggregator": "^1.13",
"laminas/laminas-diactoros": "^2.25", "laminas/laminas-diactoros": "^3.3",
"laminas/laminas-inputfilter": "^2.27", "laminas/laminas-inputfilter": "^2.27",
"laminas/laminas-servicemanager": "^3.21", "laminas/laminas-servicemanager": "^3.21",
"laminas/laminas-stdlib": "^3.17", "laminas/laminas-stdlib": "^3.17",
"league/uri": "^6.8", "league/uri": "^6.8",
"lstrojny/functional-php": "^1.17",
"matomo/matomo-php-tracker": "^3.2", "matomo/matomo-php-tracker": "^3.2",
"mezzio/mezzio": "^3.17", "mezzio/mezzio": "^3.17",
"mezzio/mezzio-fastroute": "^3.10", "mezzio/mezzio-fastroute": "^3.10",
@@ -46,12 +45,12 @@
"php-middleware/request-id": "^4.1", "php-middleware/request-id": "^4.1",
"pugx/shortid-php": "^1.1", "pugx/shortid-php": "^1.1",
"ramsey/uuid": "^4.7", "ramsey/uuid": "^4.7",
"shlinkio/shlink-common": "^5.7", "shlinkio/shlink-common": "^5.7.1",
"shlinkio/shlink-config": "^2.5", "shlinkio/shlink-config": "^2.5",
"shlinkio/shlink-event-dispatcher": "^3.1", "shlinkio/shlink-event-dispatcher": "^3.1",
"shlinkio/shlink-importer": "^5.2", "shlinkio/shlink-importer": "^5.2.1",
"shlinkio/shlink-installer": "^8.6", "shlinkio/shlink-installer": "^8.7",
"shlinkio/shlink-ip-geolocation": "^3.3", "shlinkio/shlink-ip-geolocation": "^3.4",
"shlinkio/shlink-json": "^1.1", "shlinkio/shlink-json": "^1.1",
"spiral/roadrunner": "^2023.2", "spiral/roadrunner": "^2023.2",
"spiral/roadrunner-cli": "^2.5", "spiral/roadrunner-cli": "^2.5",
@@ -76,10 +75,13 @@
"phpunit/phpunit": "^10.4", "phpunit/phpunit": "^10.4",
"roave/security-advisories": "dev-master", "roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.3.0", "shlinkio/php-coding-standard": "~2.3.0",
"shlinkio/shlink-test-utils": "^3.8", "shlinkio/shlink-test-utils": "^3.8.1",
"symfony/var-dumper": "^6.3", "symfony/var-dumper": "^6.3",
"veewee/composer-run-parallel": "^1.3" "veewee/composer-run-parallel": "^1.3"
}, },
"conflict": {
"symfony/var-exporter": ">=6.3.9,<=6.4.0"
},
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Shlinkio\\Shlink\\CLI\\": "module/CLI/src", "Shlinkio\\Shlink\\CLI\\": "module/CLI/src",
@@ -88,6 +90,7 @@
}, },
"files": [ "files": [
"config/constants.php", "config/constants.php",
"module/Core/functions/array-utils.php",
"module/Core/functions/functions.php" "module/Core/functions/functions.php"
] ]
}, },
@@ -113,7 +116,7 @@
], ],
"cs": "phpcs -s", "cs": "phpcs -s",
"cs:fix": "phpcbf", "cs:fix": "phpcbf",
"stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/test* module/*/config config docker/config data/migrations --level=8", "stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/test* module/*/config module/*/migrations config docker/config --level=8",
"test": [ "test": [
"@parallel test:unit test:db", "@parallel test:unit test:db",
"@parallel test:api test:cli" "@parallel test:api test:cli"

View File

@@ -5,11 +5,11 @@ declare(strict_types=1);
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Config\EnvVars;
use function Functional\contains; use function Shlinkio\Shlink\Core\ArrayUtils\contains;
return (static function (): array { return (static function (): array {
$driver = EnvVars::DB_DRIVER->loadFromEnv(); $driver = EnvVars::DB_DRIVER->loadFromEnv();
$isMysqlCompatible = contains(['maria', 'mysql'], $driver); $isMysqlCompatible = contains($driver, ['maria', 'mysql']);
$resolveDriver = static fn () => match ($driver) { $resolveDriver = static fn () => match ($driver) {
'postgres' => 'pdo_pgsql', 'postgres' => 'pdo_pgsql',

View File

@@ -62,6 +62,7 @@ return [
Option\QrCode\DefaultFormatConfigOption::class, Option\QrCode\DefaultFormatConfigOption::class,
Option\QrCode\DefaultErrorCorrectionConfigOption::class, Option\QrCode\DefaultErrorCorrectionConfigOption::class,
Option\QrCode\DefaultRoundBlockSizeConfigOption::class, Option\QrCode\DefaultRoundBlockSizeConfigOption::class,
Option\QrCode\EnabledForDisabledShortUrlsConfigOption::class,
Option\RabbitMq\RabbitMqEnabledConfigOption::class, Option\RabbitMq\RabbitMqEnabledConfigOption::class,
Option\RabbitMq\RabbitMqHostConfigOption::class, Option\RabbitMq\RabbitMqHostConfigOption::class,
Option\RabbitMq\RabbitMqUseSslConfigOption::class, Option\RabbitMq\RabbitMqUseSslConfigOption::class,

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars; 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_ERROR_CORRECTION;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT; use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN; use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN;
@@ -22,6 +23,9 @@ return [
'round_block_size' => (bool) EnvVars::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE->loadFromEnv( 'round_block_size' => (bool) EnvVars::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE->loadFromEnv(
DEFAULT_QR_CODE_ROUND_BLOCK_SIZE, 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

@@ -11,7 +11,9 @@ return [
'base_path' => EnvVars::BASE_PATH->loadFromEnv(''), 'base_path' => EnvVars::BASE_PATH->loadFromEnv(''),
'fastroute' => [ 'fastroute' => [
FastRouteRouter::CONFIG_CACHE_ENABLED => true, // 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 => PHP_SAPI !== 'cli',
FastRouteRouter::CONFIG_CACHE_FILE => 'data/cache/fastroute_cached_routes.php', FastRouteRouter::CONFIG_CACHE_FILE => 'data/cache/fastroute_cached_routes.php',
], ],
], ],

View File

@@ -11,7 +11,7 @@ use Doctrine\Migrations\DependencyFactory;
return (static function () { return (static function () {
$migrationsConfig = [ $migrationsConfig = [
'migrations_paths' => [ 'migrations_paths' => [
'ShlinkMigrations' => 'data/migrations', 'ShlinkMigrations' => 'module/Core/migrations',
], ],
'table_storage' => [ 'table_storage' => [
'table_name' => 'migrations', 'table_name' => 'migrations',

View File

@@ -19,4 +19,6 @@ const DEFAULT_QR_CODE_MARGIN = 0;
const DEFAULT_QR_CODE_FORMAT = 'png'; const DEFAULT_QR_CODE_FORMAT = 'png';
const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l'; const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true; const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true;
// 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; const MIN_TASK_WORKERS = 4;

View File

@@ -1,4 +1,4 @@
version: '3.0' version: '3'
rpc: rpc:
listen: tcp://127.0.0.1:6001 listen: tcp://127.0.0.1:6001
@@ -35,6 +35,8 @@ logs:
http: http:
mode: 'off' # Disable logging as Shlink handles it internally mode: 'off' # Disable logging as Shlink handles it internally
server: server:
level: debug level: info
metrics: metrics:
level: debug level: debug
jobs:
level: debug

View File

@@ -1,4 +1,4 @@
version: '3.0' version: '3'
rpc: rpc:
listen: tcp://127.0.0.1:6001 listen: tcp://127.0.0.1:6001
@@ -33,4 +33,6 @@ logs:
http: http:
mode: 'off' # Disable logging as Shlink handles it internally mode: 'off' # Disable logging as Shlink handles it internally
server: server:
level: debug # Everything written to worker stderr is logged level: info
jobs:
level: debug

View File

@@ -29,10 +29,10 @@ register_shutdown_function(function () use ($httpClient): void {
}); });
$testHelper->createTestDb( $testHelper->createTestDb(
['bin/cli', 'db:create'], createDbCommand: ['bin/cli', 'db:create'],
['bin/cli', 'db:migrate'], migrateDbCommand: ['bin/cli', 'db:migrate'],
['bin/doctrine', 'orm:schema-tool:drop'], dropSchemaCommand: ['bin/doctrine', 'orm:schema-tool:drop'],
['bin/doctrine', 'dbal:run-sql'], runSqlCommand: ['bin/doctrine', 'dbal:run-sql'],
); );
ApiTest\ApiTestCase::setApiClient($httpClient); ApiTest\ApiTestCase::setApiClient($httpClient);
ApiTest\ApiTestCase::setSeedFixturesCallback(fn () => $testHelper->seedFixtures($em, $config['data_fixtures'] ?? [])); ApiTest\ApiTestCase::setSeedFixturesCallback(fn () => $testHelper->seedFixtures($em, $config['data_fixtures'] ?? []));

View File

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

View File

@@ -28,9 +28,9 @@ use Symfony\Component\Console\Event\ConsoleTerminateEvent;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use function file_exists; use function file_exists;
use function Functional\contains;
use function Laminas\Stratigility\middleware; use function Laminas\Stratigility\middleware;
use function Shlinkio\Shlink\Config\env; use function Shlinkio\Shlink\Config\env;
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function sprintf; use function sprintf;
use function sys_get_temp_dir; use function sys_get_temp_dir;
@@ -41,7 +41,7 @@ $isApiTest = env('TEST_ENV') === 'api';
$isCliTest = env('TEST_ENV') === 'cli'; $isCliTest = env('TEST_ENV') === 'cli';
$isE2eTest = $isApiTest || $isCliTest; $isE2eTest = $isApiTest || $isCliTest;
$coverageType = env('GENERATE_COVERAGE'); $coverageType = env('GENERATE_COVERAGE');
$generateCoverage = contains(['yes', 'pretty'], $coverageType); $generateCoverage = contains($coverageType, ['yes', 'pretty']);
$coverage = null; $coverage = null;
if ($isE2eTest && $generateCoverage) { if ($isE2eTest && $generateCoverage) {

View File

@@ -199,7 +199,7 @@ services:
shlink_swagger_ui: shlink_swagger_ui:
container_name: shlink_swagger_ui container_name: shlink_swagger_ui
image: swaggerapi/swagger-ui:v5.9.1 image: swaggerapi/swagger-ui:v5.10.3
ports: ports:
- "8005:8080" - "8005:8080"
volumes: volumes:

View File

@@ -3,6 +3,9 @@ set -e
cd /etc/shlink 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
flags="--no-interaction --clear-db-cache" flags="--no-interaction --clear-db-cache"
# Skip downloading GeoLite2 db file if the license key env var was not defined or skipping was explicitly set # Skip downloading GeoLite2 db file if the license key env var was not defined or skipping was explicitly set

View File

@@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use function array_filter; use function array_filter;
use function Functional\map; use function array_map;
use function implode; use function implode;
use function sprintf; use function sprintf;
@@ -49,7 +49,7 @@ class ListKeysCommand extends Command
{ {
$enabledOnly = $input->getOption('enabled-only'); $enabledOnly = $input->getOption('enabled-only');
$rows = map($this->apiKeyService->listKeys($enabledOnly), function (ApiKey $apiKey) use ($enabledOnly) { $rows = array_map(function (ApiKey $apiKey) use ($enabledOnly) {
$expiration = $apiKey->getExpirationDate(); $expiration = $apiKey->getExpirationDate();
$messagePattern = $this->determineMessagePattern($apiKey); $messagePattern = $this->determineMessagePattern($apiKey);
@@ -64,7 +64,7 @@ class ListKeysCommand extends Command
)); ));
return $rowData; return $rowData;
}); }, $this->apiKeyService->listKeys($enabledOnly));
ShlinkTable::withRowSeparators($output)->render(array_filter([ ShlinkTable::withRowSeparators($output)->render(array_filter([
'Key', 'Key',

View File

@@ -16,9 +16,9 @@ use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\PhpExecutableFinder;
use Throwable; use Throwable;
use function Functional\contains; use function array_map;
use function Functional\map; use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function Functional\some; use function Shlinkio\Shlink\Core\ArrayUtils\some;
class CreateDatabaseCommand extends AbstractDatabaseCommand class CreateDatabaseCommand extends AbstractDatabaseCommand
{ {
@@ -70,11 +70,11 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
{ {
$existingTables = $this->ensureDatabaseExistsAndGetTables(); $existingTables = $this->ensureDatabaseExistsAndGetTables();
$allMetadata = $this->em->getMetadataFactory()->getAllMetadata(); $allMetadata = $this->em->getMetadataFactory()->getAllMetadata();
$shlinkTables = map($allMetadata, static fn (ClassMetadata $metadata) => $metadata->getTableName()); $shlinkTables = array_map(static fn (ClassMetadata $metadata) => $metadata->getTableName(), $allMetadata);
// If at least one of the shlink tables exist, we will consider the database exists somehow. // If at least one of the shlink tables exist, we will consider the database exists somehow.
// Any other inconsistency will be taken care of by the migrations. // Any other inconsistency will be taken care of by the migrations.
return some($shlinkTables, static fn (string $shlinkTable) => contains($existingTables, $shlinkTable)); return some($shlinkTables, static fn (string $shlinkTable) => contains($shlinkTable, $existingTables));
} }
private function ensureDatabaseExistsAndGetTables(): array private function ensureDatabaseExistsAndGetTables(): array

View File

@@ -14,8 +14,8 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use function Functional\filter; use function array_filter;
use function Functional\invoke; use function array_map;
use function sprintf; use function sprintf;
use function str_contains; use function str_contains;
@@ -23,7 +23,7 @@ class DomainRedirectsCommand extends Command
{ {
public const NAME = 'domain:redirects'; public const NAME = 'domain:redirects';
public function __construct(private DomainServiceInterface $domainService) public function __construct(private readonly DomainServiceInterface $domainService)
{ {
parent::__construct(); parent::__construct();
} }
@@ -52,9 +52,9 @@ class DomainRedirectsCommand extends Command
$askNewDomain = static fn () => $io->ask('Domain authority for which you want to set specific redirects'); $askNewDomain = static fn () => $io->ask('Domain authority for which you want to set specific redirects');
/** @var string[] $availableDomains */ /** @var string[] $availableDomains */
$availableDomains = invoke( $availableDomains = array_map(
filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault), static fn (DomainItem $item) => $item->toString(),
'toString', array_filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault),
); );
if (empty($availableDomains)) { if (empty($availableDomains)) {
$input->setArgument('domain', $askNewDomain()); $input->setArgument('domain', $askNewDomain());

View File

@@ -14,13 +14,13 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use function Functional\map; use function array_map;
class ListDomainsCommand extends Command class ListDomainsCommand extends Command
{ {
public const NAME = 'domain:list'; public const NAME = 'domain:list';
public function __construct(private DomainServiceInterface $domainService) public function __construct(private readonly DomainServiceInterface $domainService)
{ {
parent::__construct(); parent::__construct();
} }
@@ -47,7 +47,7 @@ class ListDomainsCommand extends Command
$table->render( $table->render(
$showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields, $showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields,
map($domains, function (DomainItem $domain) use ($showRedirects) { array_map(function (DomainItem $domain) use ($showRedirects) {
$commonValues = [$domain->toString(), $domain->isDefault ? 'Yes' : 'No']; $commonValues = [$domain->toString(), $domain->isDefault ? 'Yes' : 'No'];
return $showRedirects return $showRedirects
@@ -56,7 +56,7 @@ class ListDomainsCommand extends Command
$this->notFoundRedirectsToString($domain->notFoundRedirectConfig), $this->notFoundRedirectsToString($domain->notFoundRedirectConfig),
] ]
: $commonValues; : $commonValues;
}), }, $domains),
); );
return ExitCode::EXIT_SUCCESS; return ExitCode::EXIT_SUCCESS;

View File

@@ -20,10 +20,9 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use function array_map; use function array_map;
use function array_unique;
use function explode; use function explode;
use function Functional\curry; use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
use function Functional\flatten;
use function Functional\unique;
use function sprintf; use function sprintf;
class CreateShortUrlCommand extends Command class CreateShortUrlCommand extends Command
@@ -144,8 +143,8 @@ class CreateShortUrlCommand extends Command
return ExitCode::EXIT_FAILURE; return ExitCode::EXIT_FAILURE;
} }
$explodeWithComma = curry(explode(...))(','); $explodeWithComma = static fn (string $tag) => explode(',', $tag);
$tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags')))); $tags = array_unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
$customSlug = $input->getOption('custom-slug'); $customSlug = $input->getOption('custom-slug');
$maxVisits = $input->getOption('max-visits'); $maxVisits = $input->getOption('max-visits');
$shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength; $shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength;

View File

@@ -23,9 +23,9 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use function array_keys; use function array_keys;
use function array_map;
use function array_pad; use function array_pad;
use function explode; use function explode;
use function Functional\map;
use function implode; use function implode;
use function sprintf; use function sprintf;
@@ -184,10 +184,10 @@ class ListShortUrlsCommand extends Command
): Paginator { ): Paginator {
$shortUrls = $this->shortUrlService->listShortUrls($params); $shortUrls = $this->shortUrlService->listShortUrls($params);
$rows = map($shortUrls, function (ShortUrl $shortUrl) use ($columnsMap) { $rows = array_map(function (ShortUrl $shortUrl) use ($columnsMap) {
$rawShortUrl = $this->transformer->transform($shortUrl); $rawShortUrl = $this->transformer->transform($shortUrl);
return map($columnsMap, fn (callable $call) => $call($rawShortUrl, $shortUrl)); return array_map(fn (callable $call) => $call($rawShortUrl, $shortUrl), $columnsMap);
}); }, [...$shortUrls]);
ShlinkTable::default($output)->render( ShlinkTable::default($output)->render(
array_keys($columnsMap), array_keys($columnsMap),

View File

@@ -13,13 +13,13 @@ use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use function Functional\map; use function array_map;
class ListTagsCommand extends Command class ListTagsCommand extends Command
{ {
public const NAME = 'tag:list'; public const NAME = 'tag:list';
public function __construct(private TagServiceInterface $tagService) public function __construct(private readonly TagServiceInterface $tagService)
{ {
parent::__construct(); parent::__construct();
} }
@@ -44,9 +44,9 @@ class ListTagsCommand extends Command
return [['No tags found', '-', '-']]; return [['No tags found', '-', '-']];
} }
return map( return array_map(
$tags,
static fn (TagInfo $tagInfo) => [$tagInfo->tag, $tagInfo->shortUrlsCount, $tagInfo->visitsSummary->total], static fn (TagInfo $tagInfo) => [$tagInfo->tag, $tagInfo->shortUrlsCount, $tagInfo->visitsSummary->total],
[...$tags],
); );
} }
} }

View File

@@ -17,9 +17,9 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use function array_keys; use function array_keys;
use function Functional\map; use function array_map;
use function Functional\select_keys;
use function Shlinkio\Shlink\Common\buildDateRange; use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\ArrayUtils\select_keys;
use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly; use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly;
abstract class AbstractVisitsListCommand extends Command abstract class AbstractVisitsListCommand extends Command
@@ -49,7 +49,7 @@ abstract class AbstractVisitsListCommand extends Command
private function resolveRowsAndHeaders(Paginator $paginator): array private function resolveRowsAndHeaders(Paginator $paginator): array
{ {
$extraKeys = []; $extraKeys = [];
$rows = map($paginator->getCurrentPageResults(), function (Visit $visit) use (&$extraKeys) { $rows = array_map(function (Visit $visit) use (&$extraKeys) {
$extraFields = $this->mapExtraFields($visit); $extraFields = $this->mapExtraFields($visit);
$extraKeys = array_keys($extraFields); $extraKeys = array_keys($extraFields);
@@ -60,9 +60,10 @@ abstract class AbstractVisitsListCommand extends Command
...$extraFields, ...$extraFields,
]; ];
// Filter out unknown keys
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]); return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]);
}); }, [...$paginator->getCurrentPageResults()]);
$extra = map($extraKeys, camelCaseToHumanFriendly(...)); $extra = array_map(camelCaseToHumanFriendly(...), $extraKeys);
return [ return [
$rows, $rows,

View File

@@ -8,30 +8,30 @@ use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Helper\TableSeparator; use Symfony\Component\Console\Helper\TableSeparator;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use function Functional\intersperse; use function array_pop;
final class ShlinkTable final class ShlinkTable
{ {
private const DEFAULT_STYLE_NAME = 'default'; private const DEFAULT_STYLE_NAME = 'default';
private const TABLE_TITLE_STYLE = '<options=bold> %s </>'; private const TABLE_TITLE_STYLE = '<options=bold> %s </>';
private function __construct(private readonly Table $baseTable, private readonly bool $withRowSeparators) private function __construct(private readonly Table $baseTable, private readonly bool $withRowSeparators = false)
{ {
} }
public static function default(OutputInterface $output): self public static function default(OutputInterface $output): self
{ {
return new self(new Table($output), false); return new self(new Table($output));
} }
public static function withRowSeparators(OutputInterface $output): self public static function withRowSeparators(OutputInterface $output): self
{ {
return new self(new Table($output), true); return new self(new Table($output), withRowSeparators: true);
} }
public static function fromBaseTable(Table $baseTable): self public static function fromBaseTable(Table $baseTable): self
{ {
return new self($baseTable, false); return new self($baseTable);
} }
public function render(array $headers, array $rows, ?string $footerTitle = null, ?string $headerTitle = null): void public function render(array $headers, array $rows, ?string $footerTitle = null, ?string $headerTitle = null): void
@@ -39,7 +39,7 @@ final class ShlinkTable
$style = Table::getStyleDefinition(self::DEFAULT_STYLE_NAME); $style = Table::getStyleDefinition(self::DEFAULT_STYLE_NAME);
$style->setFooterTitleFormat(self::TABLE_TITLE_STYLE) $style->setFooterTitleFormat(self::TABLE_TITLE_STYLE)
->setHeaderTitleFormat(self::TABLE_TITLE_STYLE); ->setHeaderTitleFormat(self::TABLE_TITLE_STYLE);
$tableRows = $this->withRowSeparators ? intersperse($rows, new TableSeparator()) : $rows; $tableRows = $this->withRowSeparators ? $this->addRowSeparators($rows) : $rows;
$table = clone $this->baseTable; $table = clone $this->baseTable;
$table->setStyle($style) $table->setStyle($style)
@@ -49,4 +49,20 @@ final class ShlinkTable
->setHeaderTitle($headerTitle) ->setHeaderTitle($headerTitle)
->render(); ->render();
} }
private function addRowSeparators(array $rows): array
{
$aggregation = [];
$separator = new TableSeparator();
foreach ($rows as $row) {
$aggregation[] = $row;
$aggregation[] = $separator;
}
// Remove last separator
array_pop($aggregation);
return $aggregation;
}
} }

View File

@@ -16,8 +16,6 @@ use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\ApiKey\Role;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use function Functional\map;
class RoleResolverTest extends TestCase class RoleResolverTest extends TestCase
{ {
private RoleResolver $resolver; private RoleResolver $resolver;
@@ -49,10 +47,13 @@ class RoleResolverTest extends TestCase
{ {
$domain = self::domainWithId(Domain::withAuthority('example.com')); $domain = self::domainWithId(Domain::withAuthority('example.com'));
$buildInput = static fn (array $definition) => function (TestCase $test) use ($definition): InputInterface { $buildInput = static fn (array $definition) => function (TestCase $test) use ($definition): InputInterface {
$returnMap = [];
foreach ($definition as $param => $returnValue) {
$returnMap[] = [$param, $returnValue];
}
$input = $test->createStub(InputInterface::class); $input = $test->createStub(InputInterface::class);
$input->method('getOption')->willReturnMap( $input->method('getOption')->willReturnMap($returnMap);
map($definition, static fn (mixed $returnValue, string $param) => [$param, $returnValue]),
);
return $input; return $input;
}; };

View File

@@ -21,7 +21,7 @@ use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Symfony\Component\Lock; use Symfony\Component\Lock;
use Throwable; use Throwable;
use function Functional\map; use function array_map;
use function range; use function range;
class GeolocationDbUpdaterTest extends TestCase class GeolocationDbUpdaterTest extends TestCase
@@ -128,7 +128,7 @@ class GeolocationDbUpdaterTest extends TestCase
return [$days % 2 === 0 ? $timestamp : (string) $timestamp]; return [$days % 2 === 0 ? $timestamp : (string) $timestamp];
}; };
return map(range(0, 34), $generateParamsWithTimestamp); return array_map($generateParamsWithTimestamp, range(0, 34));
} }
#[Test] #[Test]

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ArrayUtils;
use function array_filter;
use function array_reduce;
use function in_array;
use const ARRAY_FILTER_USE_KEY;
function contains(mixed $value, array $array): bool
{
return in_array($value, $array, strict: true);
}
/**
* @param array[] $multiArray
* @return array
*/
function flatten(array $multiArray): array
{
return array_reduce(
$multiArray,
static fn (array $carry, array $value) => [...$carry, ...$value],
initial: [],
);
}
/**
* Checks if a callback returns true for at least one item in a collection.
* @param callable(mixed $value, mixed $key): bool $callback
*/
function some(iterable $collection, callable $callback): bool
{
foreach ($collection as $key => $value) {
if ($callback($value, $key)) {
return true;
}
}
return false;
}
/**
* Checks if a callback returns true for all item in a collection.
* @param callable(mixed $value, string|number $key): bool $callback
*/
function every(iterable $collection, callable $callback): bool
{
foreach ($collection as $key => $value) {
if (! $callback($value, $key)) {
return false;
}
}
return true;
}
/**
* Returns an array containing only those entries in the array whose key is in the supplied keys.
*/
function select_keys(array $array, array $keys): array
{
return array_filter(
$array,
static fn (string $key) => contains(
$key,
$keys,
),
ARRAY_FILTER_USE_KEY,
);
}

View File

@@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Core;
use BackedEnum; use BackedEnum;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Cake\Chronos\ChronosInterface;
use DateTimeInterface; use DateTimeInterface;
use Doctrine\ORM\Mapping\Builder\FieldBuilder; use Doctrine\ORM\Mapping\Builder\FieldBuilder;
use Jaybizzle\CrawlerDetect\CrawlerDetect; use Jaybizzle\CrawlerDetect\CrawlerDetect;
@@ -17,9 +16,10 @@ use PUGX\Shortid\Factory as ShortIdFactory;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
use function array_keys;
use function array_map;
use function array_reduce;
use function date_default_timezone_get; use function date_default_timezone_get;
use function Functional\map;
use function Functional\reduce_left;
use function is_array; use function is_array;
use function print_r; use function print_r;
use function Shlinkio\Shlink\Common\buildDateRange; use function Shlinkio\Shlink\Common\buildDateRange;
@@ -57,7 +57,7 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en
/** /**
* @return ($date is null ? null : Chronos) * @return ($date is null ? null : Chronos)
*/ */
function normalizeOptionalDate(string|DateTimeInterface|ChronosInterface|null $date): ?Chronos function normalizeOptionalDate(string|DateTimeInterface|Chronos|null $date): ?Chronos
{ {
$parsedDate = match (true) { $parsedDate = match (true) {
$date === null || $date instanceof Chronos => $date, $date === null || $date instanceof Chronos => $date,
@@ -68,7 +68,7 @@ function normalizeOptionalDate(string|DateTimeInterface|ChronosInterface|null $d
return $parsedDate?->setTimezone(date_default_timezone_get()); return $parsedDate?->setTimezone(date_default_timezone_get());
} }
function normalizeDate(string|DateTimeInterface|ChronosInterface $date): Chronos function normalizeDate(string|DateTimeInterface|Chronos $date): Chronos
{ {
return normalizeOptionalDate($date); return normalizeOptionalDate($date);
} }
@@ -94,10 +94,12 @@ function getNonEmptyOptionalValueFromInputFilter(InputFilter $inputFilter, strin
function arrayToString(array $array, int $indentSize = 4): string function arrayToString(array $array, int $indentSize = 4): string
{ {
$indent = str_repeat(' ', $indentSize); $indent = str_repeat(' ', $indentSize);
$names = array_keys($array);
$index = 0; $index = 0;
return reduce_left($array, static function ($messages, string $name, $_, string $acc) use (&$index, $indent) { return array_reduce($names, static function (string $acc, string $name) use (&$index, $indent, $array) {
$index++; $index++;
$messages = $array[$name];
return $acc . sprintf( return $acc . sprintf(
"%s%s'%s' => %s", "%s%s'%s' => %s",
@@ -177,6 +179,6 @@ function enumValues(string $enum): array
} }
return $cache[$enum] ?? ( return $cache[$enum] ?? (
$cache[$enum] = map($enum::cases(), static fn (BackedEnum $type) => (string) $type->value) $cache[$enum] = array_map(static fn (BackedEnum $type) => (string) $type->value, $enum::cases())
); );
} }

View File

@@ -11,7 +11,7 @@ use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration; use Doctrine\Migrations\AbstractMigration;
use function Functional\some; use function Shlinkio\Shlink\Core\ArrayUtils\some;
final class Version20200105165647 extends AbstractMigration final class Version20200105165647 extends AbstractMigration
{ {
@@ -25,7 +25,7 @@ final class Version20200105165647 extends AbstractMigration
$visitLocations = $schema->getTable('visit_locations'); $visitLocations = $schema->getTable('visit_locations');
$this->skipIf(some( $this->skipIf(some(
self::COLUMNS, self::COLUMNS,
fn (string $v, string $newColName) => $visitLocations->hasColumn($newColName), fn (string $v, string|int $newColName) => $visitLocations->hasColumn((string) $newColName),
), 'New columns already exist'); ), 'New columns already exist');
foreach (self::COLUMNS as $columnName) { foreach (self::COLUMNS as $columnName) {

View File

@@ -7,11 +7,10 @@ namespace ShlinkMigrations;
use Doctrine\DBAL\Exception; use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\Table;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration; use Doctrine\Migrations\AbstractMigration;
use function Functional\none;
final class Version20200106215144 extends AbstractMigration final class Version20200106215144 extends AbstractMigration
{ {
private const COLUMNS = ['latitude', 'longitude']; private const COLUMNS = ['latitude', 'longitude'];
@@ -22,16 +21,24 @@ final class Version20200106215144 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
$visitLocations = $schema->getTable('visit_locations'); $visitLocations = $schema->getTable('visit_locations');
$this->skipIf(none( $this->skipIf($this->oldColumnsDoNotExist($visitLocations), 'Old columns do not exist');
self::COLUMNS,
fn (string $oldColName) => $visitLocations->hasColumn($oldColName),
), 'Old columns do not exist');
foreach (self::COLUMNS as $colName) { foreach (self::COLUMNS as $colName) {
$visitLocations->dropColumn($colName); $visitLocations->dropColumn($colName);
} }
} }
public function oldColumnsDoNotExist(Table $visitLocations): bool
{
foreach (self::COLUMNS as $oldColName) {
if ($visitLocations->hasColumn($oldColName)) {
return false;
}
}
return true;
}
/** /**
* @throws Exception * @throws Exception
*/ */

View File

@@ -9,9 +9,6 @@ use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration; use Doctrine\Migrations\AbstractMigration;
use function Functional\each;
use function Functional\partial_left;
final class Version20200110182849 extends AbstractMigration final class Version20200110182849 extends AbstractMigration
{ {
private const DEFAULT_EMPTY_VALUE = ''; private const DEFAULT_EMPTY_VALUE = '';
@@ -31,11 +28,11 @@ final class Version20200110182849 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
each( foreach (self::COLUMN_DEFAULTS_MAP as $tableName => $columns) {
self::COLUMN_DEFAULTS_MAP, foreach ($columns as $columnName) {
fn (array $columns, string $tableName) => $this->setDefaultValueForColumnInTable($tableName, $columnName);
each($columns, partial_left([$this, 'setDefaultValueForColumnInTable'], $tableName)), }
); }
} }
/** /**

View File

@@ -18,7 +18,7 @@ use Endroid\QrCode\Writer\WriterInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Options\QrCodeOptions; use Shlinkio\Shlink\Core\Options\QrCodeOptions;
use function Functional\contains; use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function strtolower; use function strtolower;
use function trim; use function trim;
@@ -74,7 +74,7 @@ final class QrCodeParams
private static function resolveWriter(array $query, QrCodeOptions $defaults): WriterInterface private static function resolveWriter(array $query, QrCodeOptions $defaults): WriterInterface
{ {
$qFormat = self::normalizeParam($query['format'] ?? ''); $qFormat = self::normalizeParam($query['format'] ?? '');
$format = contains(self::SUPPORTED_FORMATS, $qFormat) ? $qFormat : self::normalizeParam($defaults->format); $format = contains($qFormat, self::SUPPORTED_FORMATS) ? $qFormat : self::normalizeParam($defaults->format);
return match ($format) { return match ($format) {
'svg' => new SvgWriter(), 'svg' => new SvgWriter(),

View File

@@ -18,13 +18,13 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
class QrCodeAction implements MiddlewareInterface readonly class QrCodeAction implements MiddlewareInterface
{ {
public function __construct( public function __construct(
private ShortUrlResolverInterface $urlResolver, private ShortUrlResolverInterface $urlResolver,
private ShortUrlStringifierInterface $stringifier, private ShortUrlStringifierInterface $stringifier,
private LoggerInterface $logger, private LoggerInterface $logger,
private QrCodeOptions $defaultOptions, private QrCodeOptions $options,
) { ) {
} }
@@ -33,13 +33,15 @@ class QrCodeAction implements MiddlewareInterface
$identifier = ShortUrlIdentifier::fromRedirectRequest($request); $identifier = ShortUrlIdentifier::fromRedirectRequest($request);
try { try {
$shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier); $shortUrl = $this->options->enabledForDisabledShortUrls
? $this->urlResolver->resolvePublicShortUrl($identifier)
: $this->urlResolver->resolveEnabledShortUrl($identifier);
} catch (ShortUrlNotFoundException $e) { } catch (ShortUrlNotFoundException $e) {
$this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]); $this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]);
return $handler->handle($request); return $handler->handle($request);
} }
$params = QrCodeParams::fromRequest($request, $this->defaultOptions); $params = QrCodeParams::fromRequest($request, $this->options);
$qrCodeBuilder = Builder::create() $qrCodeBuilder = Builder::create()
->data($this->stringifier->stringify($shortUrl)) ->data($this->stringifier->stringify($shortUrl))
->size($params->size) ->size($params->size)

View File

@@ -43,6 +43,7 @@ enum EnvVars: string
case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT'; case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT';
case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION'; case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION';
case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE'; case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE';
case QR_CODE_FOR_DISABLED_SHORT_URLS = 'QR_CODE_FOR_DISABLED_SHORT_URLS';
case DEFAULT_INVALID_SHORT_URL_REDIRECT = 'DEFAULT_INVALID_SHORT_URL_REDIRECT'; case DEFAULT_INVALID_SHORT_URL_REDIRECT = 'DEFAULT_INVALID_SHORT_URL_REDIRECT';
case DEFAULT_REGULAR_404_REDIRECT = 'DEFAULT_REGULAR_404_REDIRECT'; case DEFAULT_REGULAR_404_REDIRECT = 'DEFAULT_REGULAR_404_REDIRECT';
case DEFAULT_BASE_URL_REDIRECT = 'DEFAULT_BASE_URL_REDIRECT'; case DEFAULT_BASE_URL_REDIRECT = 'DEFAULT_BASE_URL_REDIRECT';

View File

@@ -12,8 +12,6 @@ use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
use function Functional\compose;
use function Functional\id;
use function str_replace; use function str_replace;
use function urlencode; use function urlencode;
@@ -23,8 +21,8 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
private const ORIGINAL_PATH_PLACEHOLDER = '{ORIGINAL_PATH}'; private const ORIGINAL_PATH_PLACEHOLDER = '{ORIGINAL_PATH}';
public function __construct( public function __construct(
private RedirectResponseHelperInterface $redirectResponseHelper, private readonly RedirectResponseHelperInterface $redirectResponseHelper,
private LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {
} }
@@ -52,9 +50,6 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
private function resolvePlaceholders(UriInterface $currentUri, string $redirectUrl): string private function resolvePlaceholders(UriInterface $currentUri, string $redirectUrl): string
{ {
$domain = $currentUri->getAuthority();
$path = $currentUri->getPath();
try { try {
$redirectUri = Uri::createFromString($redirectUrl); $redirectUri = Uri::createFromString($redirectUrl);
} catch (SyntaxError $e) { } catch (SyntaxError $e) {
@@ -65,18 +60,32 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
return $redirectUrl; return $redirectUrl;
} }
$replacePlaceholderForPattern = static fn (string $pattern, string $replace, callable $modifier) => $path = $currentUri->getPath();
static fn (?string $value) => $domain = $currentUri->getAuthority();
$value === null ? null : str_replace($modifier($pattern), $modifier($replace), $value);
$replacePlaceholders = static fn (callable $modifier) => compose( $replacePlaceholderForPattern = static fn (string $pattern, string $replace, ?string $value): string|null =>
$replacePlaceholderForPattern(self::DOMAIN_PLACEHOLDER, $domain, $modifier), $value === null ? null : str_replace($pattern, $replace, $value);
$replacePlaceholderForPattern(self::ORIGINAL_PATH_PLACEHOLDER, $path, $modifier),
$replacePlaceholders = static function (
callable $modifier,
?string $value,
) use (
$replacePlaceholderForPattern,
$path,
$domain,
): string|null {
$value = $replacePlaceholderForPattern($modifier(self::DOMAIN_PLACEHOLDER), $modifier($domain), $value);
return $replacePlaceholderForPattern($modifier(self::ORIGINAL_PATH_PLACEHOLDER), $modifier($path), $value);
};
$replacePlaceholdersInPath = static function (string $path) use ($replacePlaceholders): string {
$result = $replacePlaceholders(static fn (mixed $v) => $v, $path);
return str_replace('//', '/', $result ?? '');
};
$replacePlaceholdersInQuery = static fn (?string $query): string|null => $replacePlaceholders(
urlencode(...),
$query,
); );
$replacePlaceholdersInPath = compose(
$replacePlaceholders(id(...)),
static fn (?string $path) => $path === null ? null : str_replace('//', '/', $path),
);
$replacePlaceholdersInQuery = $replacePlaceholders(urlencode(...));
return $redirectUri return $redirectUri
->withPath($replacePlaceholdersInPath($redirectUri->getPath())) ->withPath($replacePlaceholdersInPath($redirectUri->getPath()))

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config\PostProcessor; namespace Shlinkio\Shlink\Core\Config\PostProcessor;
use function Functional\map; use function array_map;
class BasePathPrefixer class BasePathPrefixer
{ {
@@ -23,13 +23,13 @@ class BasePathPrefixer
private function prefixPathsWithBasePath(string $configKey, array $config, string $basePath): array private function prefixPathsWithBasePath(string $configKey, array $config, string $basePath): array
{ {
return map($config[$configKey] ?? [], function (array $element) use ($basePath) { return array_map(function (array $element) use ($basePath) {
if (! isset($element['path'])) { if (! isset($element['path'])) {
return $element; return $element;
} }
$element['path'] = $basePath . $element['path']; $element['path'] = $basePath . $element['path'];
return $element; return $element;
}); }, $config[$configKey] ?? []);
} }
} }

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config\PostProcessor; namespace Shlinkio\Shlink\Core\Config\PostProcessor;
use function Functional\map; use function array_map;
use function str_replace; use function str_replace;
class MultiSegmentSlugProcessor class MultiSegmentSlugProcessor
@@ -19,11 +19,11 @@ class MultiSegmentSlugProcessor
return $config; return $config;
} }
$config['routes'] = map($config['routes'] ?? [], static function (array $route): array { $config['routes'] = array_map(static function (array $route): array {
['path' => $path] = $route; ['path' => $path] = $route;
$route['path'] = str_replace(self::SINGLE_SEGMENT_PATTERN, self::MULTI_SEGMENT_PATTERN, $path); $route['path'] = str_replace(self::SINGLE_SEGMENT_PATTERN, self::MULTI_SEGMENT_PATTERN, $path);
return $route; return $route;
}); }, $config['routes'] ?? []);
return $config; return $config;
} }

View File

@@ -9,25 +9,34 @@ use Mezzio\Router\Route;
use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Util\RedirectStatus; use Shlinkio\Shlink\Core\Util\RedirectStatus;
use function array_values;
use function count;
use function Functional\partition;
use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE; use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
/**
* Sets the appropriate allowed methods on the redirect route, based on the redirect status code that was configured.
* * For "legacy" status codes (301 and 302) the redirect URL will work only on GET method.
* * For other status codes (307 and 308) the redirect URL will work on any method.
*/
class ShortUrlMethodsProcessor class ShortUrlMethodsProcessor
{ {
public function __invoke(array $config): array public function __invoke(array $config): array
{ {
[$redirectRoutes, $rest] = partition( $allRoutes = $config['routes'] ?? [];
$config['routes'] ?? [], $redirectRoute = null;
static fn (array $route) => $route['name'] === RedirectAction::class, $rest = [];
);
if (count($redirectRoutes) === 0) { // Get default route from routes array
foreach ($allRoutes as $route) {
if ($route['name'] === RedirectAction::class) {
$redirectRoute ??= $route;
} else {
$rest[] = $route;
}
}
if ($redirectRoute === null) {
return $config; return $config;
} }
[$redirectRoute] = array_values($redirectRoutes);
$redirectStatus = RedirectStatus::tryFrom( $redirectStatus = RedirectStatus::tryFrom(
$config['redirects']['redirect_status_code'] ?? 0, $config['redirects']['redirect_status_code'] ?? 0,
) ?? DEFAULT_REDIRECT_STATUS_CODE; ) ?? DEFAULT_REDIRECT_STATUS_CODE;

View File

@@ -14,9 +14,7 @@ use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function Functional\first; use function array_map;
use function Functional\group;
use function Functional\map;
class DomainService implements DomainServiceInterface class DomainService implements DomainServiceInterface
{ {
@@ -30,7 +28,7 @@ class DomainService implements DomainServiceInterface
public function listDomains(?ApiKey $apiKey = null): array public function listDomains(?ApiKey $apiKey = null): array
{ {
[$default, $domains] = $this->defaultDomainAndRest($apiKey); [$default, $domains] = $this->defaultDomainAndRest($apiKey);
$mappedDomains = map($domains, fn (Domain $domain) => DomainItem::forNonDefaultDomain($domain)); $mappedDomains = array_map(fn (Domain $domain) => DomainItem::forNonDefaultDomain($domain), $domains);
if ($apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) { if ($apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) {
return $mappedDomains; return $mappedDomains;
@@ -49,12 +47,19 @@ class DomainService implements DomainServiceInterface
{ {
/** @var DomainRepositoryInterface $repo */ /** @var DomainRepositoryInterface $repo */
$repo = $this->em->getRepository(Domain::class); $repo = $this->em->getRepository(Domain::class);
$groups = group( $allDomains = $repo->findDomains($apiKey);
$repo->findDomains($apiKey), $defaultDomain = null;
fn (Domain $domain) => $domain->authority === $this->defaultDomain ? 'default' : 'domains', $restOfDomains = [];
);
return [first($groups['default'] ?? []), $groups['domains'] ?? []]; foreach ($allDomains as $domain) {
if ($domain->authority === $this->defaultDomain) {
$defaultDomain = $domain;
} else {
$restOfDomains[] = $domain;
}
}
return [$defaultDomain, $restOfDomains];
} }
/** /**

View File

@@ -13,7 +13,7 @@ use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Throwable; use Throwable;
use function Functional\each; use function array_walk;
abstract class AbstractNotifyVisitListener extends AbstractAsyncListener abstract class AbstractNotifyVisitListener extends AbstractAsyncListener
{ {
@@ -46,7 +46,7 @@ abstract class AbstractNotifyVisitListener extends AbstractAsyncListener
$updates = $this->determineUpdatesForVisit($visit); $updates = $this->determineUpdatesForVisit($visit);
try { try {
each($updates, fn (Update $update) => $this->publishingHelper->publishUpdate($update)); array_walk($updates, fn (Update $update) => $this->publishingHelper->publishUpdate($update));
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->debug( $this->logger->debug(
'Error while trying to notify {name} with new visit. {e}', 'Error while trying to notify {name} with new visit. {e}',

View File

@@ -19,18 +19,18 @@ use Shlinkio\Shlink\Core\Options\WebhookOptions;
use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Throwable; use Throwable;
use function Functional\map; use function array_map;
/** @deprecated */ /** @deprecated */
class NotifyVisitToWebHooks class NotifyVisitToWebHooks
{ {
public function __construct( public function __construct(
private ClientInterface $httpClient, private readonly ClientInterface $httpClient,
private EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private LoggerInterface $logger, private readonly LoggerInterface $logger,
private WebhookOptions $webhookOptions, private readonly WebhookOptions $webhookOptions,
private DataTransformerInterface $transformer, private readonly DataTransformerInterface $transformer,
private AppOptions $appOptions, private readonly AppOptions $appOptions,
) { ) {
} }
@@ -82,11 +82,11 @@ class NotifyVisitToWebHooks
*/ */
private function performRequests(array $requestOptions, string $visitId): array private function performRequests(array $requestOptions, string $visitId): array
{ {
return map( return array_map(
$this->webhookOptions->webhooks(),
fn (string $webhook): PromiseInterface => $this->httpClient fn (string $webhook): PromiseInterface => $this->httpClient
->requestAsync(RequestMethodInterface::METHOD_POST, $webhook, $requestOptions) ->requestAsync(RequestMethodInterface::METHOD_POST, $webhook, $requestOptions)
->otherwise(fn (Throwable $e) => $this->logWebhookFailure($webhook, $visitId, $e)), ->otherwise(fn (Throwable $e) => $this->logWebhookFailure($webhook, $visitId, $e)),
$this->webhookOptions->webhooks(),
); );
} }

View File

@@ -12,9 +12,9 @@ use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit;
use function Shlinkio\Shlink\Core\normalizeDate; use function Shlinkio\Shlink\Core\normalizeDate;
use function sprintf; use function sprintf;
final class ShortUrlImporting final readonly class ShortUrlImporting
{ {
private function __construct(private readonly ShortUrl $shortUrl, private readonly bool $isNew) private function __construct(private ShortUrl $shortUrl, private bool $isNew)
{ {
} }
@@ -57,11 +57,18 @@ final class ShortUrlImporting
private function resolveShortUrl(EntityManagerInterface $em): ShortUrl private function resolveShortUrl(EntityManagerInterface $em): ShortUrl
{ {
// If wrapped ShortUrl has no ID, avoid trying to query the EM, as it would fail in Postgres.
// See https://github.com/shlinkio/shlink/issues/1947
$id = $this->shortUrl->getId();
if (!$id) {
return $this->shortUrl;
}
// Instead of directly accessing wrapped ShortUrl entity, try to get it from the EM. // Instead of directly accessing wrapped ShortUrl entity, try to get it from the EM.
// With this, we will get the same entity from memory if it is known by the EM, but if it was cleared, the EM // With this, we will get the same entity from memory if it is known by the EM, but if it was cleared, the EM
// will fetch it again from the database, preventing errors at runtime. // will fetch it again from the database, preventing errors at runtime.
// However, if the EM was not flushed yet, the entity will not be found by ID, but it is known by the EM. // However, if the EM was not flushed yet, the entity will not be found by ID, but it is known by the EM.
// In that case, we fall back to wrapped ShortUrl entity directly. // In that case, we fall back to wrapped ShortUrl entity directly.
return $em->find(ShortUrl::class, $this->shortUrl->getId()) ?? $this->shortUrl; return $em->find(ShortUrl::class, $id) ?? $this->shortUrl;
} }
} }

View File

@@ -4,20 +4,22 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Options; namespace Shlinkio\Shlink\Core\Options;
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_ERROR_CORRECTION;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT; use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN; 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_ROUND_BLOCK_SIZE;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE; use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE;
final class QrCodeOptions readonly final class QrCodeOptions
{ {
public function __construct( public function __construct(
public readonly int $size = DEFAULT_QR_CODE_SIZE, public int $size = DEFAULT_QR_CODE_SIZE,
public readonly int $margin = DEFAULT_QR_CODE_MARGIN, public int $margin = DEFAULT_QR_CODE_MARGIN,
public readonly string $format = DEFAULT_QR_CODE_FORMAT, public string $format = DEFAULT_QR_CODE_FORMAT,
public readonly string $errorCorrection = DEFAULT_QR_CODE_ERROR_CORRECTION, public string $errorCorrection = DEFAULT_QR_CODE_ERROR_CORRECTION,
public readonly bool $roundBlockSize = DEFAULT_QR_CODE_ROUND_BLOCK_SIZE, public bool $roundBlockSize = DEFAULT_QR_CODE_ROUND_BLOCK_SIZE,
public bool $enabledForDisabledShortUrls = DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS,
) { ) {
} }
} }

View File

@@ -27,8 +27,8 @@ use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function array_fill_keys; use function array_fill_keys;
use function array_map;
use function count; use function count;
use function Functional\map;
use function Shlinkio\Shlink\Core\enumValues; use function Shlinkio\Shlink\Core\enumValues;
use function Shlinkio\Shlink\Core\generateRandomShortCode; use function Shlinkio\Shlink\Core\generateRandomShortCode;
use function Shlinkio\Shlink\Core\normalizeDate; use function Shlinkio\Shlink\Core\normalizeDate;
@@ -90,9 +90,9 @@ class ShortUrl extends AbstractEntity
$instance->longUrl = $creation->getLongUrl(); $instance->longUrl = $creation->getLongUrl();
$instance->dateCreated = Chronos::now(); $instance->dateCreated = Chronos::now();
$instance->visits = new ArrayCollection(); $instance->visits = new ArrayCollection();
$instance->deviceLongUrls = new ArrayCollection(map( $instance->deviceLongUrls = new ArrayCollection(array_map(
$creation->deviceLongUrls,
fn (DeviceLongUrlPair $pair) => DeviceLongUrl::fromShortUrlAndPair($instance, $pair), fn (DeviceLongUrlPair $pair) => DeviceLongUrl::fromShortUrlAndPair($instance, $pair),
$creation->deviceLongUrls,
)); ));
$instance->tags = $relationResolver->resolveTags($creation->tags); $instance->tags = $relationResolver->resolveTags($creation->tags);
$instance->validSince = $creation->validSince; $instance->validSince = $creation->validSince;

View File

@@ -6,9 +6,6 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model;
use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\Model\DeviceType;
use function array_values;
use function Functional\group;
use function Functional\map;
use function trim; use function trim;
final class DeviceLongUrlPair final class DeviceLongUrlPair
@@ -27,20 +24,21 @@ final class DeviceLongUrlPair
* * The first one is a list of mapped instances for those entries in the map with non-null value * * The first one is a list of mapped instances for those entries in the map with non-null value
* * The second is a list of DeviceTypes which have been provided with value null * * The second is a list of DeviceTypes which have been provided with value null
* *
* @param array<string, string> $map * @param array<string, string|null> $map
* @return array{array<string, self>, DeviceType[]} * @return array{array<string, self>, DeviceType[]}
*/ */
public static function fromMapToChangeSet(array $map): array public static function fromMapToChangeSet(array $map): array
{ {
$typesWithNullUrl = group($map, static fn (?string $longUrl) => $longUrl === null ? 'remove' : 'keep'); $pairsToKeep = [];
$deviceTypesToRemove = array_values(map( $deviceTypesToRemove = [];
$typesWithNullUrl['remove'] ?? [],
static fn ($_, string $deviceType) => DeviceType::from($deviceType), foreach ($map as $deviceType => $longUrl) {
)); if ($longUrl === null) {
$pairsToKeep = map( $deviceTypesToRemove[] = DeviceType::from($deviceType);
$typesWithNullUrl['keep'] ?? [], } else {
fn (string $longUrl, string $deviceType) => self::fromRawTypeAndLongUrl($deviceType, $longUrl), $pairsToKeep[$deviceType] = self::fromRawTypeAndLongUrl($deviceType, $longUrl);
); }
}
return [$pairsToKeep, $deviceTypesToRemove]; return [$pairsToKeep, $deviceTypesToRemove];
} }

View File

@@ -2,7 +2,7 @@
namespace Shlinkio\Shlink\Core\ShortUrl\Model; namespace Shlinkio\Shlink\Core\ShortUrl\Model;
use function Functional\contains; use function Shlinkio\Shlink\Core\ArrayUtils\contains;
enum OrderableField: string enum OrderableField: string
{ {
@@ -16,8 +16,8 @@ enum OrderableField: string
public static function isBasicField(string $value): bool public static function isBasicField(string $value): bool
{ {
return contains( return contains(
[self::LONG_URL->value, self::SHORT_CODE->value, self::DATE_CREATED->value, self::TITLE->value],
$value, $value,
[self::LONG_URL->value, self::SHORT_CODE->value, self::DATE_CREATED->value, self::TITLE->value],
); );
} }

View File

@@ -10,9 +10,9 @@ use Symfony\Component\Console\Input\InputInterface;
use function sprintf; use function sprintf;
final class ShortUrlIdentifier final readonly class ShortUrlIdentifier
{ {
private function __construct(public readonly string $shortCode, public readonly ?string $domain = null) private function __construct(public string $shortCode, public ?string $domain = null)
{ {
} }

View File

@@ -10,9 +10,9 @@ use Shlinkio\Shlink\Core\Model\DeviceType;
use function array_keys; use function array_keys;
use function array_values; use function array_values;
use function Functional\contains;
use function Functional\every;
use function is_array; use function is_array;
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function Shlinkio\Shlink\Core\ArrayUtils\every;
use function Shlinkio\Shlink\Core\enumValues; use function Shlinkio\Shlink\Core\enumValues;
class DeviceLongUrlsValidator extends AbstractValidator class DeviceLongUrlsValidator extends AbstractValidator
@@ -41,7 +41,7 @@ class DeviceLongUrlsValidator extends AbstractValidator
$validValues = enumValues(DeviceType::class); $validValues = enumValues(DeviceType::class);
$keys = array_keys($value); $keys = array_keys($value);
if (! every($keys, static fn ($key) => contains($validValues, $key))) { if (! every($keys, static fn ($key) => contains($key, $validValues))) {
$this->error(self::INVALID_DEVICE); $this->error(self::INVALID_DEVICE);
return false; return false;
} }

View File

@@ -15,9 +15,8 @@ use Symfony\Component\Lock\Lock;
use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\InMemoryStore; use Symfony\Component\Lock\Store\InMemoryStore;
use function Functional\invoke; use function array_map;
use function Functional\map; use function array_unique;
use function Functional\unique;
class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInterface class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInterface
{ {
@@ -74,10 +73,10 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
return new Collections\ArrayCollection(); return new Collections\ArrayCollection();
} }
$tags = unique($tags); $tags = array_unique($tags);
$repo = $this->em->getRepository(Tag::class); $repo = $this->em->getRepository(Tag::class);
return new Collections\ArrayCollection(map($tags, function (string $tagName) use ($repo): Tag { return new Collections\ArrayCollection(array_map(function (string $tagName) use ($repo): Tag {
$this->lock($this->tagLocks, 'tag_' . $tagName); $this->lock($this->tagLocks, 'tag_' . $tagName);
$existingTag = $repo->findOneBy(['name' => $tagName]); $existingTag = $repo->findOneBy(['name' => $tagName]);
@@ -91,7 +90,7 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
$this->em->persist($tag); $this->em->persist($tag);
return $tag; return $tag;
})); }, $tags));
} }
private function memoizeNewTag(string $tagName): Tag private function memoizeNewTag(string $tagName): Tag
@@ -110,6 +109,7 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
$lock->acquire(true); $lock->acquire(true);
} }
/**
/** /**
* @param array<string, Lock> $locks * @param array<string, Lock> $locks
*/ */
@@ -126,9 +126,15 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
$this->memoizedNewTags = []; $this->memoizedNewTags = [];
// Release all locks // Release all locks
invoke($this->tagLocks, 'release'); $this->releaseLocks($this->tagLocks);
invoke($this->domainLocks, 'release'); $this->releaseLocks($this->domainLocks);
$this->tagLocks = []; }
$this->domainLocks = [];
private function releaseLocks(array &$locks): void
{
foreach ($locks as $tagLock) {
$tagLock->release();
}
$locks = [];
} }
} }

View File

@@ -8,7 +8,7 @@ use Doctrine\Common\Collections;
use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Entity\Tag;
use function Functional\map; use function array_map;
class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterface class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterface
{ {
@@ -23,6 +23,6 @@ class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterfac
*/ */
public function resolveTags(array $tags): Collections\Collection public function resolveTags(array $tags): Collections\Collection
{ {
return new Collections\ArrayCollection(map($tags, fn (string $tag) => new Tag($tag))); return new Collections\ArrayCollection(array_map(fn (string $tag) => new Tag($tag), $tags));
} }
} }

View File

@@ -12,11 +12,11 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlResolver implements ShortUrlResolverInterface readonly class ShortUrlResolver implements ShortUrlResolverInterface
{ {
public function __construct( public function __construct(
private readonly EntityManagerInterface $em, private EntityManagerInterface $em,
private readonly UrlShortenerOptions $urlShortenerOptions, private UrlShortenerOptions $urlShortenerOptions,
) { ) {
} }
@@ -39,11 +39,21 @@ class ShortUrlResolver implements ShortUrlResolverInterface
* @throws ShortUrlNotFoundException * @throws ShortUrlNotFoundException
*/ */
public function resolveEnabledShortUrl(ShortUrlIdentifier $identifier): ShortUrl public function resolveEnabledShortUrl(ShortUrlIdentifier $identifier): ShortUrl
{
$shortUrl = $this->resolvePublicShortUrl($identifier);
if (! $shortUrl->isEnabled()) {
throw ShortUrlNotFoundException::fromNotFound($identifier);
}
return $shortUrl;
}
public function resolvePublicShortUrl(ShortUrlIdentifier $identifier): ShortUrl
{ {
/** @var ShortUrlRepository $shortUrlRepo */ /** @var ShortUrlRepository $shortUrlRepo */
$shortUrlRepo = $this->em->getRepository(ShortUrl::class); $shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier, $this->urlShortenerOptions->mode); $shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier, $this->urlShortenerOptions->mode);
if (! $shortUrl?->isEnabled()) { if ($shortUrl === null) {
throw ShortUrlNotFoundException::fromNotFound($identifier); throw ShortUrlNotFoundException::fromNotFound($identifier);
} }

View File

@@ -17,6 +17,19 @@ interface ShortUrlResolverInterface
public function resolveShortUrl(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): ShortUrl; public function resolveShortUrl(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): ShortUrl;
/** /**
* Resolves a public short URL matching provided identifier.
* When trying to match public short URLs, if provided domain is default one, it gets ignored.
* If provided domain is not default, but the short code is found in default domain, we fall back to that short URL.
*
* @throws ShortUrlNotFoundException
*/
public function resolvePublicShortUrl(ShortUrlIdentifier $identifier): ShortUrl;
/**
* Resolves a public short URL matching provided identifier, only if it's not disabled.
* Disabled short URLs are those which received the max amount of visits, have a `validSince` in the future or have
* a `validUntil` in the past.
*
* @throws ShortUrlNotFoundException * @throws ShortUrlNotFoundException
*/ */
public function resolveEnabledShortUrl(ShortUrlIdentifier $identifier): ShortUrl; public function resolveEnabledShortUrl(ShortUrlIdentifier $identifier): ShortUrl;

View File

@@ -7,10 +7,10 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Transformer;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary; use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary;
use function Functional\invoke; use function array_map;
use function Functional\invoke_if;
class ShortUrlDataTransformer implements DataTransformerInterface class ShortUrlDataTransformer implements DataTransformerInterface
{ {
@@ -29,7 +29,7 @@ class ShortUrlDataTransformer implements DataTransformerInterface
'longUrl' => $shortUrl->getLongUrl(), 'longUrl' => $shortUrl->getLongUrl(),
'deviceLongUrls' => $shortUrl->deviceLongUrls(), 'deviceLongUrls' => $shortUrl->deviceLongUrls(),
'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(),
'tags' => invoke($shortUrl->getTags(), '__toString'), 'tags' => array_map(static fn (Tag $tag) => $tag->__toString(), $shortUrl->getTags()->toArray()),
'meta' => $this->buildMeta($shortUrl), 'meta' => $this->buildMeta($shortUrl),
'domain' => $shortUrl->getDomain(), 'domain' => $shortUrl->getDomain(),
'title' => $shortUrl->title(), 'title' => $shortUrl->title(),
@@ -52,8 +52,8 @@ class ShortUrlDataTransformer implements DataTransformerInterface
$maxVisits = $shortUrl->getMaxVisits(); $maxVisits = $shortUrl->getMaxVisits();
return [ return [
'validSince' => invoke_if($validSince, 'toAtomString'), 'validSince' => $validSince?->toAtomString(),
'validUntil' => invoke_if($validUntil, 'toAtomString'), 'validUntil' => $validUntil?->toAtomString(),
'maxVisits' => $maxVisits, 'maxVisits' => $maxVisits,
]; ];
} }

View File

@@ -17,8 +17,8 @@ use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function Functional\each; use function array_map;
use function Functional\map; use function array_walk;
use function Shlinkio\Shlink\Core\camelCaseToSnakeCase; use function Shlinkio\Shlink\Core\camelCaseToSnakeCase;
use const PHP_INT_MAX; use const PHP_INT_MAX;
@@ -95,7 +95,8 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito
$nonBotVisitsSubQb = $buildVisitsSubQb(true, 'non_bot_visits'); $nonBotVisitsSubQb = $buildVisitsSubQb(true, 'non_bot_visits');
// Apply API key specification to all sub-queries // Apply API key specification to all sub-queries
each([$tagsSubQb, $allVisitsSubQb, $nonBotVisitsSubQb], $applyApiKeyToNativeQb); $queryBuilders = [$tagsSubQb, $allVisitsSubQb, $nonBotVisitsSubQb];
array_walk($queryBuilders, $applyApiKeyToNativeQb);
// A native query builder needs to be used here, because DQL and ORM query builders do not support // A native query builder needs to be used here, because DQL and ORM query builders do not support
// sub-queries at "from" and "join" level. // sub-queries at "from" and "join" level.
@@ -126,9 +127,9 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito
$rsm->addScalarResult('non_bot_visits', 'nonBotVisits'); $rsm->addScalarResult('non_bot_visits', 'nonBotVisits');
$rsm->addScalarResult('short_urls_count', 'shortUrlsCount'); $rsm->addScalarResult('short_urls_count', 'shortUrlsCount');
return map( return array_map(
$this->getEntityManager()->createNativeQuery($mainQb->getSQL(), $rsm)->getResult(),
TagInfo::fromRawData(...), TagInfo::fromRawData(...),
$this->getEntityManager()->createNativeQuery($mainQb->getSQL(), $rsm)->getResult(),
); );
} }

View File

@@ -2,7 +2,7 @@
namespace Shlinkio\Shlink\Core\Util; namespace Shlinkio\Shlink\Core\Util;
use function Functional\contains; use function Shlinkio\Shlink\Core\ArrayUtils\contains;
enum RedirectStatus: int enum RedirectStatus: int
{ {
@@ -13,11 +13,11 @@ enum RedirectStatus: int
public function allowsCache(): bool public function allowsCache(): bool
{ {
return contains([self::STATUS_301, self::STATUS_308], $this); return contains($this, [self::STATUS_301, self::STATUS_308]);
} }
public function isLegacyStatus(): bool public function isLegacyStatus(): bool
{ {
return contains([self::STATUS_301, self::STATUS_302], $this); return contains($this, [self::STATUS_301, self::STATUS_302]);
} }
} }

View File

@@ -16,10 +16,11 @@ use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use function array_keys;
use function array_map;
use function explode; use function explode;
use function Functional\map;
use function Functional\some;
use function implode; use function implode;
use function Shlinkio\Shlink\Core\ArrayUtils\some;
use function str_contains; use function str_contains;
class RequestTracker implements RequestTrackerInterface, RequestMethodInterface class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
@@ -96,11 +97,15 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
private function parseValueWithWildcards(string $value, array $remoteAddrParts): ?RangeInterface private function parseValueWithWildcards(string $value, array $remoteAddrParts): ?RangeInterface
{ {
$octets = explode('.', $value);
$keys = array_keys($octets);
// Replace wildcard parts with the corresponding ones from the remote address // Replace wildcard parts with the corresponding ones from the remote address
return Factory::parseRangeString( return Factory::parseRangeString(
implode('.', map( implode('.', array_map(
explode('.', $value),
fn (string $part, int $index) => $part === '*' ? $remoteAddrParts[$index] : $part, fn (string $part, int $index) => $part === '*' ? $remoteAddrParts[$index] : $part,
$octets,
$keys,
)), )),
); );
} }

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Core\Action;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
class QrCodeTest extends ApiTestCase
{
#[Test]
public function returnsNotFoundWhenShortUrlIsNotEnabled(): void
{
// The QR code successfully resolves at first
$response = $this->callShortUrl('custom/qr-code');
self::assertEquals(200, $response->getStatusCode());
// This short URL allow max 2 visits
$this->callShortUrl('custom');
$this->callShortUrl('custom');
// After 2 visits, the QR code should return a 404
$response = $this->callShortUrl('custom/qr-code');
self::assertEquals(404, $response->getStatusCode());
}
}

View File

@@ -22,8 +22,8 @@ use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
use function array_map;
use function count; use function count;
use function Functional\map;
use function range; use function range;
class ShortUrlListRepositoryTest extends DatabaseTestCase class ShortUrlListRepositoryTest extends DatabaseTestCase
@@ -60,22 +60,22 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->persist($foo); $this->getEntityManager()->persist($foo);
$bar = ShortUrl::withLongUrl('https://bar'); $bar = ShortUrl::withLongUrl('https://bar');
$visits = map(range(0, 5), function () use ($bar) { $visits = array_map(function () use ($bar) {
$visit = Visit::forValidShortUrl($bar, Visitor::botInstance()); $visit = Visit::forValidShortUrl($bar, Visitor::botInstance());
$this->getEntityManager()->persist($visit); $this->getEntityManager()->persist($visit);
return $visit; return $visit;
}); }, range(0, 5));
$bar->setVisits(new ArrayCollection($visits)); $bar->setVisits(new ArrayCollection($visits));
$this->getEntityManager()->persist($bar); $this->getEntityManager()->persist($bar);
$foo2 = ShortUrl::withLongUrl('https://foo_2'); $foo2 = ShortUrl::withLongUrl('https://foo_2');
$visits2 = map(range(0, 3), function () use ($foo2) { $visits2 = array_map(function () use ($foo2) {
$visit = Visit::forValidShortUrl($foo2, Visitor::emptyInstance()); $visit = Visit::forValidShortUrl($foo2, Visitor::emptyInstance());
$this->getEntityManager()->persist($visit); $this->getEntityManager()->persist($visit);
return $visit; return $visit;
}); }, range(0, 3));
$foo2->setVisits(new ArrayCollection($visits2)); $foo2->setVisits(new ArrayCollection($visits2));
$ref = new ReflectionObject($foo2); $ref = new ReflectionObject($foo2);
$dateProp = $ref->getProperty('dateCreated'); $dateProp = $ref->getProperty('dateCreated');

View File

@@ -12,7 +12,7 @@ use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsPaginatorAdapter;
use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; use Shlinkio\Shlink\Core\Tag\Repository\TagRepository;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
use function Functional\map; use function array_map;
class TagsPaginatorAdapterTest extends DatabaseTestCase class TagsPaginatorAdapterTest extends DatabaseTestCase
{ {
@@ -47,7 +47,7 @@ class TagsPaginatorAdapterTest extends DatabaseTestCase
'orderBy' => $orderBy, 'orderBy' => $orderBy,
]), null); ]), null);
$tagNames = map($adapter->getSlice($offset, $length), static fn (Tag $tag) => $tag->__toString()); $tagNames = array_map(static fn (Tag $tag) => $tag->__toString(), [...$adapter->getSlice($offset, $length)]);
self::assertEquals($expectedTags, $tagNames); self::assertEquals($expectedTags, $tagNames);
self::assertEquals($expectedTotalCount, $adapter->getNbResults()); self::assertEquals($expectedTotalCount, $adapter->getNbResults());

View File

@@ -14,7 +14,7 @@ use Shlinkio\Shlink\Core\Visit\Repository\VisitLocationRepository;
use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
use function Functional\map; use function array_map;
use function range; use function range;
class VisitLocationRepositoryTest extends DatabaseTestCase class VisitLocationRepositoryTest extends DatabaseTestCase
@@ -57,6 +57,6 @@ class VisitLocationRepositoryTest extends DatabaseTestCase
public static function provideBlockSize(): iterable public static function provideBlockSize(): iterable
{ {
return map(range(1, 10), fn (int $value) => [$value]); return array_map(static fn (int $value) => [$value], range(1, 10));
} }
} }

View File

@@ -230,6 +230,35 @@ class QrCodeActionTest extends TestCase
]; ];
} }
#[Test, DataProvider('provideEnabled')]
public function qrCodeIsResolvedBasedOnOptions(bool $enabledForDisabledShortUrls): void
{
if ($enabledForDisabledShortUrls) {
$this->urlResolver->expects($this->once())->method('resolvePublicShortUrl')->willThrowException(
ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain('')),
);
$this->urlResolver->expects($this->never())->method('resolveEnabledShortUrl');
} else {
$this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->willThrowException(
ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain('')),
);
$this->urlResolver->expects($this->never())->method('resolvePublicShortUrl');
}
$options = new QrCodeOptions(enabledForDisabledShortUrls: $enabledForDisabledShortUrls);
$this->action($options)->process(
ServerRequestFactory::fromGlobals(),
$this->createMock(RequestHandlerInterface::class),
);
}
public static function provideEnabled(): iterable
{
yield 'always enabled' => [true];
yield 'only enabled short URLs' => [false];
}
public function action(?QrCodeOptions $options = null): QrCodeAction public function action(?QrCodeOptions $options = null): QrCodeAction
{ {
return new QrCodeAction( return new QrCodeAction(

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