mirror of
https://github.com/shlinkio/shlink.git
synced 2025-12-10 22:13:47 -06:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89419e278c | ||
|
|
1996745f64 | ||
|
|
cfab13bc47 | ||
|
|
9432a5ba78 | ||
|
|
7812a85b39 | ||
|
|
1e0b6be67d | ||
|
|
88e5bb5618 | ||
|
|
db1411d3f8 | ||
|
|
933c54e884 | ||
|
|
f3ff059d48 | ||
|
|
039a58bb44 | ||
|
|
0604237b94 | ||
|
|
c8537e4f71 | ||
|
|
c42fb67efc | ||
|
|
ad15ae1922 | ||
|
|
a731e01bd4 | ||
|
|
63bea36c05 | ||
|
|
8a33c6968a | ||
|
|
359129f586 | ||
|
|
fdcc9933a3 | ||
|
|
94adba95eb | ||
|
|
8bafd82e1d | ||
|
|
d2bc9f7c2b | ||
|
|
9f564b9785 | ||
|
|
1b6929acf6 | ||
|
|
91fd5809ff | ||
|
|
c7fd6b3cba | ||
|
|
1eb1f5344c | ||
|
|
f9ec4cea62 | ||
|
|
c3961b139a | ||
|
|
c2aae9640d | ||
|
|
b4043be7fa | ||
|
|
49c67abf0a | ||
|
|
c6f718eb11 | ||
|
|
d3e8e9a735 | ||
|
|
8f1542c7aa | ||
|
|
058c0ebfaf | ||
|
|
b69db91378 | ||
|
|
6113c28768 | ||
|
|
506ed47531 | ||
|
|
10173d2ab8 | ||
|
|
9ee709f0f3 | ||
|
|
0fe28a5eb5 | ||
|
|
2142afae89 | ||
|
|
e7f4b84c65 | ||
|
|
2d83b8d046 | ||
|
|
dfef735c89 | ||
|
|
c34c4e0eea | ||
|
|
f024fd414e | ||
|
|
12d81c3213 | ||
|
|
628fb9ebb5 | ||
|
|
e21cea1971 | ||
|
|
37088b1a4b | ||
|
|
b5f8e8a4cd | ||
|
|
a236f19dc4 | ||
|
|
94426c7bf4 | ||
|
|
9dcc51abde | ||
|
|
70e376d569 | ||
|
|
14a7e3bb05 | ||
|
|
10dab5be20 | ||
|
|
532700028a | ||
|
|
fc54a25c32 | ||
|
|
ba16ba45f2 | ||
|
|
51c732a013 | ||
|
|
0f17990821 | ||
|
|
02500143c1 | ||
|
|
9c22c7fc9c | ||
|
|
7860225c25 | ||
|
|
506ed6207f | ||
|
|
30ed1d7c6b | ||
|
|
b5a9353b85 | ||
|
|
cae18ccfb3 | ||
|
|
f876769b67 | ||
|
|
2b06f56a9a | ||
|
|
1c38ab1217 | ||
|
|
fb9e8cd79f | ||
|
|
eb199a61da | ||
|
|
25de0263c5 | ||
|
|
41c03a66e4 | ||
|
|
13c1b12d84 | ||
|
|
fe10aaf245 | ||
|
|
464e3d7f8e | ||
|
|
ac40a7021b | ||
|
|
c60a5e750b | ||
|
|
785f728afc | ||
|
|
648696f778 | ||
|
|
774a579a94 | ||
|
|
98bbb01165 | ||
|
|
0bcb9e0438 | ||
|
|
edb8b57a48 | ||
|
|
b01f271f72 | ||
|
|
98b504a2de | ||
|
|
075e6347b6 | ||
|
|
92a70b8c11 | ||
|
|
613c7b7368 | ||
|
|
232f6e37c6 | ||
|
|
c818d5603d | ||
|
|
766b227e47 | ||
|
|
95be5a93fc | ||
|
|
20c41690da | ||
|
|
22b5fa5a83 | ||
|
|
0c4d1b6d2f |
2
.github/actions/ci-setup/action.yml
vendored
2
.github/actions/ci-setup/action.yml
vendored
@ -43,5 +43,5 @@ runs:
|
||||
coverage: xdebug
|
||||
- name: Install dependencies
|
||||
if: ${{ inputs.install-deps == 'yes' }}
|
||||
run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.5' && '--ignore-platform-req=php' || '' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
shell: bash
|
||||
|
||||
9
.github/workflows/ci-db-tests.yml
vendored
9
.github/workflows/ci-db-tests.yml
vendored
@ -13,12 +13,11 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.3', '8.4', '8.5']
|
||||
continue-on-error: ${{ inputs.php-version == '8.5' }}
|
||||
php-version: ['8.4', '8.5']
|
||||
env:
|
||||
LC_ALL: C
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install MSSQL ODBC
|
||||
if: ${{ inputs.platform == 'ms' }}
|
||||
run: sudo ./data/infra/ci/install-ms-odbc.sh
|
||||
@ -36,8 +35,8 @@ jobs:
|
||||
- name: Run tests
|
||||
run: composer test:db:${{ inputs.platform }}
|
||||
- name: Upload code coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ matrix.php-version == '8.3' && inputs.platform == 'sqlite:ci' }}
|
||||
uses: actions/upload-artifact@v5
|
||||
if: ${{ matrix.php-version == '8.4' && inputs.platform == 'sqlite:ci' }}
|
||||
with:
|
||||
name: coverage-db
|
||||
path: |
|
||||
|
||||
9
.github/workflows/ci-tests.yml
vendored
9
.github/workflows/ci-tests.yml
vendored
@ -13,12 +13,11 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.3', '8.4', '8.5']
|
||||
continue-on-error: ${{ inputs.php-version == '8.5' }}
|
||||
php-version: ['8.4', '8.5']
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Start postgres database server
|
||||
if: ${{ inputs.test-group == 'api' }}
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
|
||||
@ -33,8 +32,8 @@ jobs:
|
||||
if: ${{ inputs.test-group == 'api' }}
|
||||
run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr
|
||||
- run: composer test:${{ inputs.test-group }}:ci
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ matrix.php-version == '8.3' }}
|
||||
- uses: actions/upload-artifact@v5
|
||||
if: ${{ matrix.php-version == '8.4' }}
|
||||
with:
|
||||
name: coverage-${{ inputs.test-group }}
|
||||
path: |
|
||||
|
||||
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
@ -27,10 +27,10 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.3']
|
||||
php-version: ['8.4']
|
||||
command: ['cs', 'stan', 'openapi:validate']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
@ -69,16 +69,15 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.3']
|
||||
php-version: ['8.4']
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Use PHP
|
||||
uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
extensions-cache-key: tests-extensions-${{ matrix.php-version }}
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: build
|
||||
- run: mv build/coverage-unit/coverage-unit.cov build/coverage-unit.cov
|
||||
@ -87,9 +86,9 @@ jobs:
|
||||
- run: mv build/coverage-cli/coverage-cli.cov build/coverage-cli.cov
|
||||
- run: vendor/bin/phpcov merge build --clover build/clover.xml
|
||||
- name: Publish coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
file: ./build/clover.xml
|
||||
files: ./build/clover.xml
|
||||
|
||||
delete-artifacts:
|
||||
needs:
|
||||
|
||||
4
.github/workflows/publish-openapi-spec.yml
vendored
4
.github/workflows/publish-openapi-spec.yml
vendored
@ -10,9 +10,9 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.3']
|
||||
php-version: ['8.4']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Determine version
|
||||
id: determine_version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
10
.github/workflows/publish-release.yml
vendored
10
.github/workflows/publish-release.yml
vendored
@ -10,16 +10,16 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.3', '8.4']
|
||||
php-version: ['8.4', '8.4', '8.5']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
|
||||
install-deps: 'no'
|
||||
- run: ./build.sh ${GITHUB_REF#refs/tags/v}
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: dist-files-${{ matrix.php-version }}
|
||||
path: build
|
||||
@ -28,8 +28,8 @@ jobs:
|
||||
needs: ['build']
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: build
|
||||
- name: Publish release with assets
|
||||
|
||||
116
CHANGELOG.md
116
CHANGELOG.md
@ -4,6 +4,122 @@ 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).
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* [#2522](https://github.com/shlinkio/shlink/issues/2522) Shlink no longer tries to detect trusted proxies automatically, when resolving the visitor's IP address, as this is a potential security issue.
|
||||
|
||||
Instead, if you have more than 1 proxy in front of Shlink, you should provide `TRUSTED_PROXIES` env var, with either a comma-separated list of the IP addresses of your proxies, or a number indicating how many proxies are there in front of Shlink.
|
||||
|
||||
* [#2540](https://github.com/shlinkio/shlink/issues/2540) Update Symfony packages to 8.0.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* [#2507](https://github.com/shlinkio/shlink/issues/2507) Drop support for PHP 8.3.
|
||||
* [#2514](https://github.com/shlinkio/shlink/issues/2514) Remove support to generate QR codes. This functionality is now handled by Shlink Web Client and Shlink Dashboard.
|
||||
* [#2517](https://github.com/shlinkio/shlink/issues/2517) Remove `REDIRECT_APPEND_EXTRA_PATH` env var. Use `REDIRECT_EXTRA_PATH_MODE=append` instead.
|
||||
* [#2519](https://github.com/shlinkio/shlink/issues/2519) Disabling API keys by their plain-text key is no longer supported. When calling `api-key:disable`, the first argument is now always assumed to be the name.
|
||||
* [#2520](https://github.com/shlinkio/shlink/issues/2520) Remove deprecated `--including-all-tags` and `--show-api-key-name` deprecated options from `short-url:list` command. Use `--tags-all` and `--show-api-key` instead.
|
||||
* [#2521](https://github.com/shlinkio/shlink/issues/2521) Remove deprecated `--tags` option in all commands using it. Use `--tag` multiple times instead, one per tag.
|
||||
|
||||
### Fixed
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## [4.6.0] - 2025-11-01
|
||||
### Added
|
||||
* [#2327](https://github.com/shlinkio/shlink/issues/2327) Allow filtering short URL lists by those not including certain tags.
|
||||
|
||||
Now, the `GET /short-urls` endpoint accepts two new params: `excludeTags`, which is an array of strings with the tags that should not be included, and `excludeTagsMode`, which accepts the values `any` and `all`, and determines if short URLs should be filtered out if they contain any of the excluded tags, or all the excluded tags.
|
||||
|
||||
Additionally, the `short-url:list` command also supports the same feature via `--exclude-tag` option, which requires a value and can be provided multiple times, and `--exclude-tags-all`, which does not expect a value and determines if the mode should be `all`, or `any`.
|
||||
|
||||
* [#2192](https://github.com/shlinkio/shlink/issues/2192) Allow filtering short URL lists by the API key that was used to create them.
|
||||
|
||||
Now, the `GET /short-urls` endpoint accepts a new `apiKeyName` param, which is ignored if the request is performed with a non-admin API key which name does not match the one provided here.
|
||||
|
||||
Additionally, the `short-url:list` command also supports the same feature via the `--api-key-name` option.
|
||||
|
||||
* [#2330](https://github.com/shlinkio/shlink/issues/2330) Add support to serve Shlink with FrankenPHP, by providing a worker script in `bin/frankenphp-worker.php`.
|
||||
|
||||
* [#2449](https://github.com/shlinkio/shlink/issues/2449) Add support to provide redis credentials separately when using redis sentinels, where provided servers are the sentinels and not the redis instances.
|
||||
|
||||
For this, Shlink supports two new env ras / config options, as `REDIS_SERVERS_USER` and `REDIS_SERVERS_PASSWORD`.
|
||||
|
||||
* [#2498](https://github.com/shlinkio/shlink/issues/2498) Allow orphan visits, non-orphan visits and tag visits lists to be filtered by domain.
|
||||
|
||||
This is done via the `domain` query parameter in API endpoints, and via the `--domain` option in console commands.
|
||||
|
||||
* [#2472](https://github.com/shlinkio/shlink/issues/2472) Add support for PHP 8.5
|
||||
* [#2291](https://github.com/shlinkio/shlink/issues/2291) Add `api-key:delete` console command to delete API keys.
|
||||
|
||||
### Changed
|
||||
* [#2424](https://github.com/shlinkio/shlink/issues/2424) Make simple console commands invokable.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## [4.5.3] - 2025-10-10
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#2488](https://github.com/shlinkio/shlink/issues/2488) Ensure `Access-Control-Allow-Credentials` is set in all cross-origin responses when `CORS_ALLOW_ORIGIN=true`.
|
||||
|
||||
|
||||
## [4.5.2] - 2025-08-27
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#2433](https://github.com/shlinkio/shlink/issues/2433) Try to mitigate memory leaks allowing RoadRunner to garbage collect memory after every request and every job, by setting `GC_COLLECT_CYCLES=true`.
|
||||
|
||||
|
||||
## [4.5.1] - 2025-08-24
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#2433](https://github.com/shlinkio/shlink/issues/2433) Try to mitigate memory leaks by restarting job and http workers every 250 executions when using RoadRunner.
|
||||
|
||||
|
||||
## [4.5.0] - 2025-07-24
|
||||
### Added
|
||||
|
||||
@ -16,9 +16,8 @@ WORKDIR /etc/shlink
|
||||
# Install required PHP extensions
|
||||
RUN \
|
||||
# Temp install dev dependencies needed to compile the extensions
|
||||
# FIXME Deprecated image-related extensions. They can be removed with QR-code support
|
||||
apk add --no-cache --virtual .dev-deps sqlite-dev postgresql-dev icu-dev libzip-dev zlib-dev libpng-dev linux-headers && \
|
||||
docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql intl calendar sockets bcmath zip gd && \
|
||||
apk add --no-cache --virtual .dev-deps sqlite-dev postgresql-dev icu-dev libzip-dev zlib-dev linux-headers && \
|
||||
docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql intl calendar sockets bcmath zip && \
|
||||
apk add --no-cache sqlite-libs && \
|
||||
docker-php-ext-install -j"$(nproc)" pdo_sqlite && \
|
||||
# Remove temp dev extensions, and install prod equivalents that are required at runtime
|
||||
|
||||
@ -36,10 +36,9 @@ The idea is that you can just generate a container using the image and provide t
|
||||
|
||||
First, make sure the host where you are going to run shlink fulfills these requirements:
|
||||
|
||||
* PHP 8.3 or 8.4
|
||||
* PHP 8.4 or 8.5
|
||||
* The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath.
|
||||
* apcu extension is recommended if you don't plan to use RoadRunner.
|
||||
* xml extension is required if you want to generate QR codes in svg format.
|
||||
* sockets and bcmath extensions are required if you want to integrate with a RabbitMQ instance.
|
||||
* MySQL, MariaDB, PostgreSQL, MicrosoftSQL or SQLite.
|
||||
* You will also need the corresponding pdo variation for the database you are planning to use: `pdo_mysql`, `pdo_pgsql`, `pdo_sqlsrv` or `pdo_sqlite`.
|
||||
@ -99,6 +98,12 @@ Both the API and CLI allow you to do mostly the same operations, except for API
|
||||
|
||||
If you are trying to find out how to run the project in development mode or how to provide contributions, read the [CONTRIBUTING](CONTRIBUTING.md) doc.
|
||||
|
||||
## Powered by
|
||||
|
||||
Thanks to [JetBrains](https://www.jetbrains.com/) for their continuous support to this project in the form of IDE licenses.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
> This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com)
|
||||
|
||||
36
bin/frankenphp-worker.php
Normal file
36
bin/frankenphp-worker.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink;
|
||||
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use Laminas\HttpHandlerRunner\Emitter\EmitterInterface;
|
||||
use Mezzio\Application;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
use function frankenphp_handle_request;
|
||||
use function gc_collect_cycles;
|
||||
|
||||
(static function (): void {
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/../config/container.php';
|
||||
$app = $container->get(Application::class);
|
||||
$responseEmitter = $container->get(EmitterInterface::class);
|
||||
$handler = static function () use ($app, $responseEmitter): void {
|
||||
$response = $app->handle(ServerRequestFactory::fromGlobals());
|
||||
$responseEmitter->emit($response);
|
||||
};
|
||||
|
||||
$maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 0);
|
||||
for ($nbRequests = 0; !$maxRequests || $nbRequests < $maxRequests; ++$nbRequests) {
|
||||
$keepRunning = frankenphp_handle_request($handler);
|
||||
|
||||
// Call the garbage collector to reduce the chances of it being triggered in the middle of a page generation
|
||||
gc_collect_cycles();
|
||||
|
||||
if (! $keepRunning) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})();
|
||||
@ -2,18 +2,22 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink;
|
||||
|
||||
use Mezzio\Application;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware;
|
||||
use Shlinkio\Shlink\EventDispatcher\RoadRunner\RoadRunnerTaskConsumerToListener;
|
||||
use Spiral\RoadRunner\Http\PSR7Worker;
|
||||
|
||||
use function gc_collect_cycles;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
|
||||
(static function (): void {
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/../config/container.php';
|
||||
$rrMode = env('RR_MODE');
|
||||
$gcCollectCycles = env('GC_COLLECT_CYCLES', default: false);
|
||||
|
||||
if ($rrMode === 'http') {
|
||||
// This was spin-up as a web worker
|
||||
@ -25,6 +29,10 @@ use function Shlinkio\Shlink\Config\env;
|
||||
$worker->respond($app->handle($req));
|
||||
} catch (Throwable $e) {
|
||||
$worker->getWorker()->error((string) $e);
|
||||
} finally {
|
||||
if ($gcCollectCycles) {
|
||||
gc_collect_cycles();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -12,19 +12,17 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"php": "^8.4",
|
||||
"ext-curl": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-pdo": "*",
|
||||
"akrabat/ip-address-middleware": "^2.6",
|
||||
"cakephp/chronos": "^3.1",
|
||||
"doctrine/dbal": "^4.2",
|
||||
"doctrine/migrations": "^3.8",
|
||||
"doctrine/orm": "^3.3",
|
||||
"doctrine/dbal": "^4.3",
|
||||
"doctrine/migrations": "^3.9",
|
||||
"doctrine/orm": "^3.5",
|
||||
"donatj/phpuseragentparser": "^1.10",
|
||||
"endroid/qr-code": "^6.0.5",
|
||||
"friendsofphp/proxy-manager-lts": "^1.0",
|
||||
"geoip2/geoip2": "^3.1",
|
||||
"guzzlehttp/guzzle": "^7.9",
|
||||
@ -43,22 +41,22 @@
|
||||
"pagerfanta/core": "^3.8",
|
||||
"ramsey/uuid": "^4.7",
|
||||
"shlinkio/doctrine-specification": "^2.2",
|
||||
"shlinkio/shlink-common": "^7.1",
|
||||
"shlinkio/shlink-config": "^4.0",
|
||||
"shlinkio/shlink-event-dispatcher": "^4.2",
|
||||
"shlinkio/shlink-importer": "^5.6",
|
||||
"shlinkio/shlink-installer": "^9.6",
|
||||
"shlinkio/shlink-ip-geolocation": "^4.3",
|
||||
"shlinkio/shlink-json": "^1.2",
|
||||
"shlinkio/shlink-common": "dev-main#f2550b5 as 7.3.0",
|
||||
"shlinkio/shlink-config": "dev-main#fb186e4 as 4.1.0",
|
||||
"shlinkio/shlink-event-dispatcher": "dev-main#54d4701 as 4.4.0",
|
||||
"shlinkio/shlink-importer": "dev-main#4498f0a as 5.7.0",
|
||||
"shlinkio/shlink-installer": "dev-develop#40e08cb as 10.0.0",
|
||||
"shlinkio/shlink-ip-geolocation": "dev-main#e0c45b2 as 5.0.0",
|
||||
"shlinkio/shlink-json": "dev-main#7c096d6 as 1.3.0",
|
||||
"spiral/roadrunner": "^2025.1",
|
||||
"spiral/roadrunner-cli": "^2.7",
|
||||
"spiral/roadrunner-http": "^3.5",
|
||||
"spiral/roadrunner-jobs": "^4.6",
|
||||
"symfony/console": "^7.3",
|
||||
"symfony/filesystem": "^7.3",
|
||||
"symfony/lock": "7.1.6",
|
||||
"symfony/process": "^7.3",
|
||||
"symfony/string": "^7.3"
|
||||
"symfony/console": "^8.0 || ^7.4",
|
||||
"symfony/filesystem": "^8.0",
|
||||
"symfony/lock": "^8.0",
|
||||
"symfony/process": "^8.0",
|
||||
"symfony/string": "^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"devizzent/cebe-php-openapi": "^1.1.2",
|
||||
@ -71,9 +69,9 @@
|
||||
"phpunit/phpcov": "^11.0",
|
||||
"phpunit/phpunit": "^12.0.10",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"shlinkio/php-coding-standard": "~2.4.2",
|
||||
"shlinkio/shlink-test-utils": "^4.3.1",
|
||||
"symfony/var-dumper": "^7.3",
|
||||
"shlinkio/php-coding-standard": "~2.5.0",
|
||||
"shlinkio/shlink-test-utils": "^4.4",
|
||||
"symfony/var-dumper": "^8.0",
|
||||
"veewee/composer-run-parallel": "^1.4"
|
||||
},
|
||||
"conflict": {
|
||||
@ -147,12 +145,12 @@
|
||||
"test:cli:ci": [
|
||||
"@putenv GENERATE_COVERAGE=yes",
|
||||
"@test:cli",
|
||||
"vendor/bin/phpcov merge build/coverage-cli --php build/coverage-cli.cov && rm build/coverage-cli/*.cov"
|
||||
"@php -d memory_limit=-1 vendor/bin/phpcov merge build/coverage-cli --php build/coverage-cli.cov && rm build/coverage-cli/*.cov"
|
||||
],
|
||||
"test:cli:pretty": [
|
||||
"@putenv GENERATE_COVERAGE=yes",
|
||||
"@test:cli",
|
||||
"phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov"
|
||||
"@php -d memory_limit=-1 phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov"
|
||||
],
|
||||
"openapi:validate": "php-openapi validate docs/swagger/swagger.json",
|
||||
"openapi:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/openapi-inlined.json",
|
||||
|
||||
@ -11,6 +11,8 @@ return (static function (): array {
|
||||
'redis' => [
|
||||
'servers' => $redisServers,
|
||||
'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(),
|
||||
'username' => EnvVars::REDIS_SERVERS_USER->loadFromEnv(),
|
||||
'password' => EnvVars::REDIS_SERVERS_PASSWORD->loadFromEnv(),
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ return [
|
||||
|
||||
'geolite2' => [
|
||||
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
|
||||
'temp_dir' => __DIR__ . '/../../data',
|
||||
'temp_dir' => __DIR__ . '/../../data/temp-geolite',
|
||||
'license_key' => EnvVars::GEOLITE_LICENSE_KEY->loadFromEnv(),
|
||||
],
|
||||
|
||||
|
||||
@ -33,6 +33,8 @@ return [
|
||||
Option\Cache\CacheNamespaceConfigOption::class,
|
||||
Option\Redis\RedisServersConfigOption::class,
|
||||
Option\Redis\RedisSentinelServiceConfigOption::class,
|
||||
Option\Redis\RedisServersUserConfigOption::class,
|
||||
Option\Redis\RedisServersPasswordConfigOption::class,
|
||||
Option\Redis\RedisPubSubConfigOption::class,
|
||||
Option\UrlShortener\ShortCodeLengthOption::class,
|
||||
Option\Mercure\EnableMercureConfigOption::class,
|
||||
@ -58,15 +60,6 @@ return [
|
||||
Option\Tracking\DisableIpTrackingConfigOption::class,
|
||||
Option\Tracking\DisableReferrerTrackingConfigOption::class,
|
||||
Option\Tracking\DisableUaTrackingConfigOption::class,
|
||||
Option\QrCode\DefaultSizeConfigOption::class,
|
||||
Option\QrCode\DefaultMarginConfigOption::class,
|
||||
Option\QrCode\DefaultFormatConfigOption::class,
|
||||
Option\QrCode\DefaultErrorCorrectionConfigOption::class,
|
||||
Option\QrCode\DefaultRoundBlockSizeConfigOption::class,
|
||||
Option\QrCode\DefaultColorConfigOption::class,
|
||||
Option\QrCode\DefaultBgColorConfigOption::class,
|
||||
Option\QrCode\DefaultLogoUrlConfigOption::class,
|
||||
Option\QrCode\EnabledForDisabledShortUrlsConfigOption::class,
|
||||
Option\RabbitMq\RabbitMqEnabledConfigOption::class,
|
||||
Option\RabbitMq\RabbitMqHostConfigOption::class,
|
||||
Option\RabbitMq\RabbitMqUseSslConfigOption::class,
|
||||
|
||||
@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
use RKA\Middleware\IpAddress;
|
||||
use RKA\Middleware\Mezzio\IpAddressFactory;
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
use Shlinkio\Shlink\Core\Middleware\ReverseForwardedAddressesMiddlewareDecorator;
|
||||
|
||||
use function Shlinkio\Shlink\Core\splitByComma;
|
||||
|
||||
@ -43,18 +42,6 @@ return (static function (): array {
|
||||
'factories' => [
|
||||
IpAddress::class => IpAddressFactory::class,
|
||||
],
|
||||
'delegators' => [
|
||||
// Make middleware decoration transparent to other parts of the code
|
||||
IpAddress::class => [
|
||||
fn ($c, $n, callable $callback) =>
|
||||
// If trusted proxies have been provided, use original middleware verbatim, otherwise decorate
|
||||
// with workaround
|
||||
$trustedProxies !== null
|
||||
? $callback()
|
||||
: new ReverseForwardedAddressesMiddlewareDecorator($callback()),
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@ -94,14 +94,6 @@ return (static function (): array {
|
||||
],
|
||||
'allowed_methods' => [RequestMethodInterface::METHOD_GET],
|
||||
],
|
||||
[
|
||||
'name' => CoreAction\QrCodeAction::class,
|
||||
'path' => '/{shortCode}/qr-code',
|
||||
'middleware' => [
|
||||
CoreAction\QrCodeAction::class,
|
||||
],
|
||||
'allowed_methods' => [RequestMethodInterface::METHOD_GET],
|
||||
],
|
||||
[
|
||||
'name' => CoreAction\RedirectAction::class,
|
||||
'path' => sprintf('/{shortCode}%s', $shortUrlRouteSuffix),
|
||||
|
||||
@ -10,7 +10,7 @@ use Mezzio;
|
||||
use Mezzio\ProblemDetails;
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
|
||||
return (new ConfigAggregator\ConfigAggregator(
|
||||
return new ConfigAggregator\ConfigAggregator(
|
||||
providers: [
|
||||
Mezzio\ConfigProvider::class,
|
||||
Mezzio\Router\ConfigProvider::class,
|
||||
@ -39,4 +39,4 @@ return (new ConfigAggregator\ConfigAggregator(
|
||||
Core\Config\PostProcessor\MultiSegmentSlugProcessor::class,
|
||||
Core\Config\PostProcessor\ShortUrlMethodsProcessor::class,
|
||||
],
|
||||
))->getMergedConfig();
|
||||
)->getMergedConfig();
|
||||
|
||||
@ -38,20 +38,3 @@ const ISO_COUNTRY_CODES = [
|
||||
'TO', 'TT', 'TN', 'TR', 'TM', 'TC', 'TV', 'UG', 'UA', 'AE', 'GB', 'US', 'UM', 'UY', 'UZ', 'VU',
|
||||
'VE', 'VN', 'VG', 'VI', 'WF', 'EH', 'YE', 'ZM', 'ZW',
|
||||
];
|
||||
|
||||
/** @deprecated */
|
||||
const DEFAULT_QR_CODE_SIZE = 300;
|
||||
/** @deprecated */
|
||||
const DEFAULT_QR_CODE_MARGIN = 0;
|
||||
/** @deprecated */
|
||||
const DEFAULT_QR_CODE_FORMAT = 'png';
|
||||
/** @deprecated */
|
||||
const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
|
||||
/** @deprecated */
|
||||
const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true;
|
||||
/** @deprecated */
|
||||
const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true;
|
||||
/** @deprecated */
|
||||
const DEFAULT_QR_CODE_COLOR = '#000000'; // Black
|
||||
/** @deprecated */
|
||||
const DEFAULT_QR_CODE_BG_COLOR = '#ffffff'; // White
|
||||
|
||||
@ -4,13 +4,15 @@ declare(strict_types=1);
|
||||
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
|
||||
use function Shlinkio\Shlink\Config\runningInRoadRunner;
|
||||
|
||||
return [
|
||||
|
||||
EnvVars::APP_ENV->value => 'dev',
|
||||
// EnvVars::GEOLITE_LICENSE_KEY->value => '',
|
||||
|
||||
// URL shortener
|
||||
EnvVars::DEFAULT_DOMAIN->value => 'localhost:8800',
|
||||
EnvVars::DEFAULT_DOMAIN->value => runningInRoadRunner() ? 'localhost:8800' : 'localhost:8008',
|
||||
EnvVars::IS_HTTPS_ENABLED->value => false,
|
||||
|
||||
// Database - MySQL
|
||||
|
||||
@ -14,11 +14,13 @@ http:
|
||||
forbid: ['.php', '.htaccess']
|
||||
pool:
|
||||
num_workers: ${WEB_WORKER_NUM:-0}
|
||||
max_jobs: 250 # Restart worker after processing this amount of requests to mitigate memory leaks
|
||||
|
||||
jobs:
|
||||
timeout: 300 # 5 minutes
|
||||
pool:
|
||||
num_workers: ${TASK_WORKER_NUM:-0}
|
||||
max_jobs: 250 # Restart worker after processing this amount of jobs to mitigate memory leaks
|
||||
consume: ['shlink']
|
||||
pipelines:
|
||||
shlink:
|
||||
|
||||
@ -11,7 +11,7 @@ server {
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
|
||||
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
|
||||
fastcgi_index index.php;
|
||||
include fastcgi.conf;
|
||||
}
|
||||
|
||||
52
data/infra/frankenphp.Dockerfile
Normal file
52
data/infra/frankenphp.Dockerfile
Normal file
@ -0,0 +1,52 @@
|
||||
FROM dunglas/frankenphp:1-php8.4-alpine
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV PDO_SQLSRV_VERSION='5.12.0'
|
||||
ENV MS_ODBC_DOWNLOAD='7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
|
||||
ENV MS_ODBC_SQL_VERSION='18_18.4.1.1'
|
||||
|
||||
RUN apk update
|
||||
|
||||
# Install common php extensions
|
||||
RUN docker-php-ext-install pdo_mysql
|
||||
RUN docker-php-ext-install calendar
|
||||
|
||||
RUN apk add --no-cache oniguruma-dev
|
||||
RUN docker-php-ext-install mbstring
|
||||
|
||||
RUN apk add --no-cache sqlite-libs
|
||||
RUN apk add --no-cache sqlite-dev
|
||||
RUN docker-php-ext-install pdo_sqlite
|
||||
|
||||
RUN apk add --no-cache icu-dev
|
||||
RUN docker-php-ext-install intl
|
||||
|
||||
RUN apk add --no-cache libzip-dev zlib-dev
|
||||
RUN docker-php-ext-install zip
|
||||
|
||||
RUN apk add --no-cache postgresql-dev
|
||||
RUN docker-php-ext-install pdo_pgsql
|
||||
|
||||
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
|
||||
docker-php-ext-install sockets && \
|
||||
apk del .phpize-deps
|
||||
RUN docker-php-ext-install bcmath
|
||||
|
||||
# Install xdebug and sqlsrv driver
|
||||
RUN apk add --update linux-headers && \
|
||||
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} xdebug && \
|
||||
docker-php-ext-enable pdo_sqlsrv xdebug && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk
|
||||
|
||||
# Install composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
|
||||
# Make home directory writable by anyone
|
||||
RUN chmod 777 /home
|
||||
|
||||
VOLUME /home/shlink
|
||||
WORKDIR /home/shlink
|
||||
2
data/infra/frankenphp_caddy_config/.gitignore
vendored
Executable file
2
data/infra/frankenphp_caddy_config/.gitignore
vendored
Executable file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
data/infra/frankenphp_caddy_data/.gitignore
vendored
Executable file
2
data/infra/frankenphp_caddy_data/.gitignore
vendored
Executable file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@ -25,9 +25,6 @@ RUN docker-php-ext-install intl
|
||||
RUN apk add --no-cache libzip-dev zlib-dev
|
||||
RUN docker-php-ext-install zip
|
||||
|
||||
RUN apk add --no-cache libpng-dev
|
||||
RUN docker-php-ext-install gd
|
||||
|
||||
RUN apk add --no-cache postgresql-dev
|
||||
RUN docker-php-ext-install pdo_pgsql
|
||||
|
||||
|
||||
@ -24,9 +24,6 @@ RUN docker-php-ext-install intl
|
||||
RUN apk add --no-cache libzip-dev zlib-dev
|
||||
RUN docker-php-ext-install zip
|
||||
|
||||
RUN apk add --no-cache libpng-dev
|
||||
RUN docker-php-ext-install gd
|
||||
|
||||
RUN apk add --no-cache postgresql-dev
|
||||
RUN docker-php-ext-install pdo_pgsql
|
||||
|
||||
|
||||
2
data/temp-geolite/.gitignore
vendored
Executable file
2
data/temp-geolite/.gitignore
vendored
Executable file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@ -66,6 +66,37 @@ services:
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
|
||||
shlink_frankenphp:
|
||||
container_name: shlink_frankenphp
|
||||
user: 1000:1000
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./data/infra/frankenphp.Dockerfile
|
||||
ports:
|
||||
- "8008:8008"
|
||||
volumes:
|
||||
- ./:/home/shlink
|
||||
- ./data/infra/php.ini:/usr/local/etc/php/php.ini
|
||||
- ./data/infra/frankenphp_caddy_data:/data
|
||||
- ./data/infra/frankenphp_caddy_config:/config
|
||||
links:
|
||||
- shlink_db_mysql
|
||||
- shlink_db_postgres
|
||||
- shlink_db_maria
|
||||
- shlink_db_ms
|
||||
- shlink_redis
|
||||
- shlink_redis_acl
|
||||
- shlink_mercure
|
||||
- shlink_mercure_proxy
|
||||
- shlink_rabbitmq
|
||||
- shlink_matomo
|
||||
environment:
|
||||
FRANKENPHP_CONFIG: 'worker /home/shlink/bin/frankenphp-worker.php'
|
||||
SERVER_NAME: ':8008 https:8009'
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
tty: true
|
||||
|
||||
shlink_db_mysql:
|
||||
container_name: shlink_db_mysql
|
||||
user: 1000:1000
|
||||
|
||||
@ -4,7 +4,7 @@ set -e
|
||||
cd /etc/shlink
|
||||
|
||||
# Create data directories if they do not exist. This allows data dir to be mounted as an empty dir if needed
|
||||
mkdir -p data/cache data/locks data/log data/proxies
|
||||
mkdir -p data/cache data/locks data/log data/proxies data/temp-geolite
|
||||
|
||||
flags="--no-interaction --clear-db-cache"
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
{
|
||||
"name": "searchTerm",
|
||||
"in": "query",
|
||||
"description": "A query used to filter results by searching for it on the longUrl and shortCode fields. (Since v1.3.0)",
|
||||
"description": "A query used to filter results by searching for it on the longUrl and shortCode fields.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
@ -40,7 +40,7 @@
|
||||
{
|
||||
"name": "tags[]",
|
||||
"in": "query",
|
||||
"description": "A list of tags used to filter the result set. Only short URLs tagged with at least one of the provided tags will be returned. (Since v1.3.0)",
|
||||
"description": "A list of tags used to filter the result set. Only short URLs **with** these tags will be returned.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "array",
|
||||
@ -52,7 +52,29 @@
|
||||
{
|
||||
"name": "tagsMode",
|
||||
"in": "query",
|
||||
"description": "Tells how the filtering by tags should work, returning short URLs containing \"any\" of the tags, or \"all\" the tags. It's ignored if no tags are provided, and defaults to \"any\" if not provided.",
|
||||
"description": "Tells how the filtering by `tags` should work, returning short URLs containing \"any\" of the tags, or \"all\" the tags. Defaults to \"any\".<br />It's ignored if `tags` is not provided.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["any", "all"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "excludeTags[]",
|
||||
"in": "query",
|
||||
"description": "A list of tags used to filter the result set. Only short URLs **without** these tags will be returned.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "excludeTagsMode",
|
||||
"in": "query",
|
||||
"description": "Tells how the filtering by `excludeTags` should work, returning short URLs not containing \"any\" of the tags, or not containing \"all\" the tags. Defaults to \"any\".<br />It's ignored if `excludeTags` is not provided.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
@ -134,6 +156,15 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "apiKeyName",
|
||||
"in": "query",
|
||||
"description": "Only get short URLs created with this API key.<br />This value is **ignored** if the request is performed with a non-admin API key that does not match this name.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
|
||||
@ -64,6 +64,10 @@
|
||||
"type": "string",
|
||||
"enum": ["true"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "../parameters/domain.json",
|
||||
"description": "Return visits for short URLs that belong to this domain. Use **DEFAULT** keyword to return visits from default domain."
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
|
||||
@ -55,6 +55,10 @@
|
||||
"type": "string",
|
||||
"enum": ["true"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "../parameters/domain.json",
|
||||
"description": "Return visits for short URLs that belong to this domain. Use **DEFAULT** keyword to return visits from default domain."
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
|
||||
@ -65,6 +65,10 @@
|
||||
"type": "string",
|
||||
"enum": ["invalid_short_url", "base_url", "regular_404"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "../parameters/domain.json",
|
||||
"description": "Return only visits for this domain. Use **DEFAULT** keyword to return visits from default domain."
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
|
||||
@ -1,121 +0,0 @@
|
||||
{
|
||||
"get": {
|
||||
"deprecated": true,
|
||||
"operationId": "shortUrlQrCode",
|
||||
"tags": [
|
||||
"URL Shortener"
|
||||
],
|
||||
"summary": "[Deprecated] Short URL QR code",
|
||||
"description": "**[Deprecated]** Use an external mechanism to generate QR codes. Shlink dashboard and shlink-web-client provide their own.",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/shortCode.json"
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"in": "query",
|
||||
"description": "The size of the image to be returned.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 50,
|
||||
"maximum": 1000,
|
||||
"default": 300
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "format",
|
||||
"in": "query",
|
||||
"description": "The format for the QR code image, being valid values png and svg. Not providing the param or providing any other value will fall back to png.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["png", "svg"],
|
||||
"default": "png"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "margin",
|
||||
"in": "query",
|
||||
"description": "The margin around the QR code image.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "errorCorrection",
|
||||
"in": "query",
|
||||
"description": "The error correction level to apply to the QR code: **[L]**ow, **[M]**edium, **[Q]**uartile or **[H]**igh. See [docs](https://www.qrcode.com/en/about/error_correction.html).",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["L", "M", "Q", "H"],
|
||||
"default": "L"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "roundBlockSize",
|
||||
"in": "query",
|
||||
"description": "Allows to disable block size rounding, which might reduce the readability of the QR code, but ensures no extra margin is added.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["true", "false"],
|
||||
"default": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "color",
|
||||
"in": "query",
|
||||
"description": "The QR code foreground color. It should be an hex representation of a color, in 3 or 6 characters, optionally preceded by the \"#\" character.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "#000000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bgColor",
|
||||
"in": "query",
|
||||
"description": "The QR code background color. It should be an hex representation of a color, in 3 or 6 characters, optionally preceded by the \"#\" character.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "#ffffff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "logo",
|
||||
"in": "query",
|
||||
"description": "Currently used to disable the logo that was set via configuration options. It may be used in future to dynamically choose from multiple logos.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["disable"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "QR code in PNG format",
|
||||
"content": {
|
||||
"image/png": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
},
|
||||
"image/svg+xml": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -133,9 +133,6 @@
|
||||
},
|
||||
"/{shortCode}/track": {
|
||||
"$ref": "paths/{shortCode}_track.json"
|
||||
},
|
||||
"/{shortCode}/qr-code": {
|
||||
"$ref": "paths/{shortCode}_qr-code.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,6 +26,7 @@ return [
|
||||
|
||||
Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class,
|
||||
Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,
|
||||
Command\Api\DeleteKeyCommand::NAME => Command\Api\DeleteKeyCommand::class,
|
||||
Command\Api\ListKeysCommand::NAME => Command\Api\ListKeysCommand::class,
|
||||
Command\Api\InitialApiKeyCommand::NAME => Command\Api\InitialApiKeyCommand::class,
|
||||
Command\Api\RenameApiKeyCommand::NAME => Command\Api\RenameApiKeyCommand::class,
|
||||
|
||||
@ -52,6 +52,7 @@ return [
|
||||
|
||||
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Api\DeleteKeyCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Api\InitialApiKeyCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Api\RenameApiKeyCommand::class => ConfigAbstractFactory::class,
|
||||
@ -108,6 +109,7 @@ return [
|
||||
|
||||
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
|
||||
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
|
||||
Command\Api\DeleteKeyCommand::class => [ApiKeyService::class],
|
||||
Command\Api\ListKeysCommand::class => [ApiKeyService::class],
|
||||
Command\Api\InitialApiKeyCommand::class => [ApiKeyService::class],
|
||||
Command\Api\RenameApiKeyCommand::class => [ApiKeyService::class],
|
||||
|
||||
94
module/CLI/src/Command/Api/DeleteKeyCommand.php
Normal file
94
module/CLI/src/Command/Api/DeleteKeyCommand.php
Normal file
@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Api;
|
||||
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Exception\ApiKeyNotFoundException;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\map;
|
||||
use function sprintf;
|
||||
|
||||
#[AsCommand(
|
||||
name: DeleteKeyCommand::NAME,
|
||||
description: 'Deletes an API key by name',
|
||||
help: <<<HELP
|
||||
The <info>%command.name%</info> command allows you to delete an existing API key via its name.
|
||||
|
||||
If no arguments are provided, you will be prompted to select one of the existing API keys.
|
||||
|
||||
<info>%command.full_name%</info>
|
||||
|
||||
You can optionally pass the API key name to be disabled:
|
||||
|
||||
<info>%command.full_name% the_key_name</info>
|
||||
|
||||
HELP,
|
||||
)]
|
||||
class DeleteKeyCommand extends Command
|
||||
{
|
||||
public const string NAME = 'api-key:delete';
|
||||
|
||||
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$apiKeyName = $input->getArgument('name');
|
||||
|
||||
if ($apiKeyName === null) {
|
||||
$apiKeys = $this->apiKeyService->listKeys();
|
||||
$name = new SymfonyStyle($input, $output)->choice(
|
||||
'What API key do you want to delete?',
|
||||
map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
|
||||
);
|
||||
|
||||
$input->setArgument('name', $name);
|
||||
}
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
InputInterface $input,
|
||||
#[Argument(description: 'The API key to delete.')]
|
||||
string|null $name = null,
|
||||
): int {
|
||||
if ($name === null) {
|
||||
$io->warning('An API key name was not provided.');
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
if (! $this->shouldProceed($io, $input)) {
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->apiKeyService->deleteByName($name);
|
||||
$io->success(sprintf('API key "%s" properly deleted', $name));
|
||||
return Command::SUCCESS;
|
||||
} catch (ApiKeyNotFoundException $e) {
|
||||
$io->error($e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldProceed(SymfonyStyle $io, InputInterface $input): bool
|
||||
{
|
||||
if (! $input->isInteractive()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$io->warning('You are about to delete an API key. This action cannot be undone.');
|
||||
return $io->confirm('Are you sure you want to delete the API key?');
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,6 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Attribute\Option;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@ -20,24 +19,17 @@ use function sprintf;
|
||||
|
||||
#[AsCommand(
|
||||
name: DisableKeyCommand::NAME,
|
||||
description: 'Disables an API key by name or plain-text key (providing a plain-text key is DEPRECATED)',
|
||||
description: 'Disables an API key by name',
|
||||
help: <<<HELP
|
||||
The <info>%command.name%</info> command allows you to disable an existing API key, via its name or the
|
||||
plain-text key.
|
||||
The <info>%command.name%</info> command allows you to disable an existing API key.
|
||||
|
||||
If no arguments are provided, you will be prompted to select one of the existing non-disabled API keys.
|
||||
|
||||
<info>%command.full_name%</info>
|
||||
|
||||
You can optionally pass the API key name to be disabled. In that case <comment>--by-name</comment> is also
|
||||
required, to indicate the first argument is the API key name and not the plain-text key:
|
||||
You can optionally pass the API key name to be disabled:
|
||||
|
||||
<info>%command.full_name% the_key_name --by-name</info>
|
||||
|
||||
You can pass the plain-text key to be disabled, but that is <options=bold>DEPRECATED</>. In next major version,
|
||||
the argument will always be assumed to be the name:
|
||||
|
||||
<info>%command.full_name% d6b6c60e-edcd-4e43-96ad-fa6b7014c143</info>
|
||||
<info>%command.full_name% the_key_name</info>
|
||||
|
||||
HELP,
|
||||
)]
|
||||
@ -52,41 +44,31 @@ class DisableKeyCommand extends Command
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$keyOrName = $input->getArgument('key-or-name');
|
||||
$name = $input->getArgument('name');
|
||||
|
||||
if ($keyOrName === null) {
|
||||
if ($name === null) {
|
||||
$apiKeys = $this->apiKeyService->listKeys(enabledOnly: true);
|
||||
$name = (new SymfonyStyle($input, $output))->choice(
|
||||
$name = new SymfonyStyle($input, $output)->choice(
|
||||
'What API key do you want to disable?',
|
||||
map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
|
||||
);
|
||||
|
||||
$input->setArgument('key-or-name', $name);
|
||||
$input->setOption('by-name', true);
|
||||
$input->setArgument('name', $name);
|
||||
}
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
#[Argument(
|
||||
description: 'The API key to disable. Pass `--by-name` to indicate this value is the name and not the key.',
|
||||
)]
|
||||
string|null $keyOrName = null,
|
||||
#[Option(description: 'Indicates the first argument is the API key name, not the plain-text key.')]
|
||||
bool $byName = false,
|
||||
#[Argument('The name of the API key to disable.')] string|null $name = null,
|
||||
): int {
|
||||
if ($keyOrName === null) {
|
||||
if ($name === null) {
|
||||
$io->warning('An API key name was not provided.');
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($byName) {
|
||||
$this->apiKeyService->disableByName($keyOrName);
|
||||
} else {
|
||||
$this->apiKeyService->disableByKey($keyOrName);
|
||||
}
|
||||
$io->success(sprintf('API key "%s" properly disabled', $keyOrName));
|
||||
$this->apiKeyService->disableByName($name);
|
||||
$io->success(sprintf('API key "%s" properly disabled', $name));
|
||||
return Command::SUCCESS;
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
@ -5,11 +5,15 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\CLI\Command\Api;
|
||||
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: InitialApiKeyCommand::NAME,
|
||||
description: 'Tries to create initial API key',
|
||||
)]
|
||||
class InitialApiKeyCommand extends Command
|
||||
{
|
||||
public const string NAME = 'api-key:initial';
|
||||
@ -19,22 +23,14 @@ class InitialApiKeyCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setHidden()
|
||||
->setName(self::NAME)
|
||||
->setDescription('Tries to create initial API key')
|
||||
->addArgument('apiKey', InputArgument::REQUIRED, 'The initial API to create');
|
||||
}
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
#[Argument('The initial API to create')] string $apiKey,
|
||||
): int {
|
||||
$result = $this->apiKeyService->createInitial($apiKey);
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$key = $input->getArgument('apiKey');
|
||||
$result = $this->apiKeyService->createInitial($key);
|
||||
|
||||
if ($result === null && $output->isVerbose()) {
|
||||
$output->writeln('<comment>Other API keys already exist. Initial API key creation skipped.</comment>');
|
||||
if ($result === null && $io->isVerbose()) {
|
||||
$io->writeln('<comment>Other API keys already exist. Initial API key creation skipped.</comment>');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
@ -6,9 +6,10 @@ namespace Shlinkio\Shlink\CLI\Command\Config;
|
||||
|
||||
use Closure;
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Exception\InvalidArgumentException;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
@ -18,6 +19,11 @@ use function Shlinkio\Shlink\Core\ArrayUtils\contains;
|
||||
use function Shlinkio\Shlink\Core\enumValues;
|
||||
use function sprintf;
|
||||
|
||||
#[AsCommand(
|
||||
name: ReadEnvVarCommand::NAME,
|
||||
description: 'Display current value for an env var',
|
||||
hidden: true,
|
||||
)]
|
||||
class ReadEnvVarCommand extends Command
|
||||
{
|
||||
public const string NAME = 'env-var:read';
|
||||
@ -31,19 +37,10 @@ class ReadEnvVarCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setHidden()
|
||||
->setDescription('Display current value for an env var')
|
||||
->addArgument('envVar', InputArgument::REQUIRED, 'The env var to read');
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$envVar = $input->getArgument('envVar');
|
||||
$envVar = $input->getArgument('env-var');
|
||||
$validEnvVars = enumValues(EnvVars::class);
|
||||
|
||||
if ($envVar === null) {
|
||||
@ -54,14 +51,14 @@ class ReadEnvVarCommand extends Command
|
||||
throw new InvalidArgumentException(sprintf('%s is not a valid Shlink environment variable', $envVar));
|
||||
}
|
||||
|
||||
$input->setArgument('envVar', $envVar);
|
||||
$input->setArgument('env-var', $envVar);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$envVar = $input->getArgument('envVar');
|
||||
$output->writeln(formatEnvVarValue(($this->loadEnvVar)($envVar)));
|
||||
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
#[Argument(description: 'The env var to read')] string $envVar,
|
||||
): int {
|
||||
$io->writeln(formatEnvVarValue(($this->loadEnvVar)($envVar)));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,8 +7,9 @@ namespace Shlinkio\Shlink\CLI\Command\Domain;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
@ -18,6 +19,10 @@ use function array_map;
|
||||
use function sprintf;
|
||||
use function str_contains;
|
||||
|
||||
#[AsCommand(
|
||||
name: DomainRedirectsCommand::NAME,
|
||||
description: 'Set specific "not found" redirects for individual domains.',
|
||||
)]
|
||||
class DomainRedirectsCommand extends Command
|
||||
{
|
||||
public const string NAME = 'domain:redirects';
|
||||
@ -27,18 +32,6 @@ class DomainRedirectsCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Set specific "not found" redirects for individual domains.')
|
||||
->addArgument(
|
||||
'domain',
|
||||
InputArgument::REQUIRED,
|
||||
'The domain authority to which you want to set the specific redirects',
|
||||
);
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
/** @var string|null $domain */
|
||||
@ -67,10 +60,11 @@ class DomainRedirectsCommand extends Command
|
||||
$input->setArgument('domain', str_contains($selectedOption, 'New domain') ? $askNewDomain() : $selectedOption);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$domainAuthority = $input->getArgument('domain');
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
#[Argument('The domain authority to which you want to set the specific redirects', name: 'domain')]
|
||||
string $domainAuthority,
|
||||
): int {
|
||||
$domain = $this->domainService->findByAuthority($domainAuthority);
|
||||
|
||||
$ask = static function (string $message, string|null $current) use ($io): string|null {
|
||||
@ -94,15 +88,15 @@ class DomainRedirectsCommand extends Command
|
||||
$this->domainService->configureNotFoundRedirects($domainAuthority, NotFoundRedirects::withRedirects(
|
||||
$ask(
|
||||
'URL to redirect to when a user hits this domain\'s base URL',
|
||||
$domain?->baseUrlRedirect(),
|
||||
$domain?->baseUrlRedirect,
|
||||
),
|
||||
$ask(
|
||||
'URL to redirect to when a user hits a not found URL other than an invalid short URL',
|
||||
$domain?->regular404Redirect(),
|
||||
$domain?->regular404Redirect,
|
||||
),
|
||||
$ask(
|
||||
'URL to redirect to when a user hits an invalid short URL',
|
||||
$domain?->invalidShortUrlRedirect(),
|
||||
$domain?->invalidShortUrlRedirect,
|
||||
),
|
||||
));
|
||||
|
||||
|
||||
@ -8,13 +8,17 @@ use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Attribute\Option;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function array_map;
|
||||
|
||||
#[AsCommand(
|
||||
name: ListDomainsCommand::NAME,
|
||||
description: 'List all domains that have been ever used for some short URL',
|
||||
)]
|
||||
class ListDomainsCommand extends Command
|
||||
{
|
||||
public const string NAME = 'domain:list';
|
||||
@ -24,25 +28,17 @@ class ListDomainsCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('List all domains that have been ever used for some short URL')
|
||||
->addOption(
|
||||
'show-redirects',
|
||||
'r',
|
||||
InputOption::VALUE_NONE,
|
||||
'Will display an extra column with the information of the "not found" redirects for every domain.',
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
#[Option(
|
||||
'Will display an extra column with the information of the "not found" redirects for every domain.',
|
||||
shortcut: 'r',
|
||||
)]
|
||||
bool $showRedirects = false,
|
||||
): int {
|
||||
$domains = $this->domainService->listDomains();
|
||||
$showRedirects = $input->getOption('show-redirects');
|
||||
$commonFields = ['Domain', 'Is default'];
|
||||
$table = $showRedirects ? ShlinkTable::withRowSeparators($output) : ShlinkTable::default($output);
|
||||
$table = $showRedirects ? ShlinkTable::withRowSeparators($io) : ShlinkTable::default($io);
|
||||
|
||||
$table->render(
|
||||
$showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields,
|
||||
@ -53,7 +49,7 @@ class ListDomainsCommand extends Command
|
||||
? [
|
||||
...$commonValues,
|
||||
$this->notFoundRedirectsToString($domain->notFoundRedirectConfig),
|
||||
]
|
||||
]
|
||||
: $commonValues;
|
||||
}, $domains),
|
||||
);
|
||||
@ -63,9 +59,9 @@ class ListDomainsCommand extends Command
|
||||
|
||||
private function notFoundRedirectsToString(NotFoundRedirectConfigInterface $config): string
|
||||
{
|
||||
$baseUrl = $config->baseUrlRedirect() ?? 'N/A';
|
||||
$regular404 = $config->regular404Redirect() ?? 'N/A';
|
||||
$invalidShortUrl = $config->invalidShortUrlRedirect() ?? 'N/A';
|
||||
$baseUrl = $config->baseUrlRedirect ?? 'N/A';
|
||||
$regular404 = $config->regular404Redirect ?? 'N/A';
|
||||
$invalidShortUrl = $config->invalidShortUrlRedirect ?? 'N/A';
|
||||
|
||||
return <<<EOL
|
||||
* Base URL: {$baseUrl}
|
||||
|
||||
@ -8,10 +8,10 @@ use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
|
||||
use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface;
|
||||
use Shlinkio\Shlink\Core\Matomo\VisitSendingProgressTrackerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Attribute\Option;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Throwable;
|
||||
|
||||
@ -19,22 +19,9 @@ use function Shlinkio\Shlink\Common\buildDateRange;
|
||||
use function Shlinkio\Shlink\Core\dateRangeToHumanFriendly;
|
||||
use function sprintf;
|
||||
|
||||
class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface
|
||||
{
|
||||
public const string NAME = 'integration:matomo:send-visits';
|
||||
|
||||
private readonly bool $matomoEnabled;
|
||||
private SymfonyStyle $io;
|
||||
|
||||
public function __construct(MatomoOptions $matomoOptions, private readonly MatomoVisitSenderInterface $visitSender)
|
||||
{
|
||||
$this->matomoEnabled = $matomoOptions->enabled;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$help = <<<HELP
|
||||
#[AsCommand(
|
||||
name: MatomoSendVisitsCommand::NAME,
|
||||
help: <<<HELP
|
||||
This command allows you to send existing visits from this Shlink instance to the configured Matomo server.
|
||||
|
||||
Its intention is to allow you to configure Matomo at some point in time, and still have your whole visits
|
||||
@ -54,32 +41,38 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra
|
||||
|
||||
Send all visits created during 2022:
|
||||
<info>%command.name% --since 2022-01-01 --until 2022-12-31</info>
|
||||
HELP;
|
||||
HELP,
|
||||
)]
|
||||
class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface
|
||||
{
|
||||
public const string NAME = 'integration:matomo:send-visits';
|
||||
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription(sprintf(
|
||||
'%sSend existing visits to the configured matomo instance',
|
||||
$this->matomoEnabled ? '' : '[MATOMO INTEGRATION DISABLED] ',
|
||||
))
|
||||
->setHelp($help)
|
||||
->addOption(
|
||||
'since',
|
||||
's',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Only visits created since this date, inclusively, will be sent to Matomo',
|
||||
)
|
||||
->addOption(
|
||||
'until',
|
||||
'u',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Only visits created until this date, inclusively, will be sent to Matomo',
|
||||
);
|
||||
private readonly bool $matomoEnabled;
|
||||
private SymfonyStyle $io;
|
||||
|
||||
public function __construct(MatomoOptions $matomoOptions, private readonly MatomoVisitSenderInterface $visitSender)
|
||||
{
|
||||
$this->matomoEnabled = $matomoOptions->enabled;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->io = new SymfonyStyle($input, $output);
|
||||
$this->setDescription(sprintf(
|
||||
'%sSend existing visits to the configured matomo instance',
|
||||
$this->matomoEnabled ? '' : '<comment>[MATOMO INTEGRATION DISABLED]</comment> ',
|
||||
));
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
InputInterface $input,
|
||||
#[Option('Only visits created since this date, inclusively, will be sent to Matomo', shortcut: 's')]
|
||||
string|null $since = null,
|
||||
#[Option('Only visits created until this date, inclusively, will be sent to Matomo', shortcut: 'u')]
|
||||
string|null $until = null,
|
||||
): int {
|
||||
$this->io = $io;
|
||||
|
||||
if (! $this->matomoEnabled) {
|
||||
$this->io->warning('Matomo integration is not enabled in this Shlink instance');
|
||||
@ -87,8 +80,6 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra
|
||||
}
|
||||
|
||||
// TODO Validate provided date formats
|
||||
$since = $input->getOption('since');
|
||||
$until = $input->getOption('until');
|
||||
$dateRange = buildDateRange(
|
||||
startDate: $since !== null ? Chronos::parse($since) : null,
|
||||
endDate: $until !== null ? Chronos::parse($until) : null,
|
||||
|
||||
@ -6,14 +6,18 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Attribute\Option;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[AsCommand(
|
||||
name: DeleteExpiredShortUrlsCommand::NAME,
|
||||
description: 'Deletes all short URLs that are considered expired, because they have a validUntil date in the past',
|
||||
)]
|
||||
class DeleteExpiredShortUrlsCommand extends Command
|
||||
{
|
||||
public const string NAME = 'short-url:delete-expired';
|
||||
@ -23,32 +27,17 @@ class DeleteExpiredShortUrlsCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription(
|
||||
'Deletes all short URLs that are considered expired, because they have a validUntil date in the past',
|
||||
)
|
||||
->addOption(
|
||||
'evaluate-max-visits',
|
||||
mode: InputOption::VALUE_NONE,
|
||||
description: 'Also take into consideration short URLs which have reached their max amount of visits.',
|
||||
)
|
||||
->addOption('force', 'f', InputOption::VALUE_NONE, 'Delete short URLs with no confirmation')
|
||||
->addOption(
|
||||
'dry-run',
|
||||
mode: InputOption::VALUE_NONE,
|
||||
description: 'Delete short URLs with no confirmation',
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$force = $input->getOption('force') || ! $input->isInteractive();
|
||||
$dryRun = $input->getOption('dry-run');
|
||||
$conditions = new ExpiredShortUrlsConditions(maxVisitsReached: $input->getOption('evaluate-max-visits'));
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
InputInterface $input,
|
||||
#[Option('Also take into consideration short URLs which have reached their max amount of visits.')]
|
||||
bool $evaluateMaxVisits = false,
|
||||
#[Option('Delete short URLs with no confirmation', shortcut: 'f')] bool $force = false,
|
||||
#[Option('Only check how many short URLs would be affected, without actually deleting them')]
|
||||
bool $dryRun = false,
|
||||
): int {
|
||||
$conditions = new ExpiredShortUrlsConditions(maxVisitsReached: $evaluateMaxVisits);
|
||||
$force = $force || ! $input->isInteractive();
|
||||
|
||||
if (! $force && ! $dryRun) {
|
||||
$io->warning([
|
||||
@ -69,6 +58,7 @@ class DeleteExpiredShortUrlsCommand extends Command
|
||||
|
||||
$result = $this->deleteShortUrlService->deleteExpiredShortUrls($conditions);
|
||||
$io->success(sprintf('%s expired short URLs have been deleted', $result));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Input\EndDateOption;
|
||||
use Shlinkio\Shlink\CLI\Input\StartDateOption;
|
||||
use Shlinkio\Shlink\CLI\Input\TagsOption;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
|
||||
@ -36,6 +37,7 @@ class ListShortUrlsCommand extends Command
|
||||
|
||||
private readonly StartDateOption $startDateOption;
|
||||
private readonly EndDateOption $endDateOption;
|
||||
private readonly TagsOption $tagsOption;
|
||||
|
||||
public function __construct(
|
||||
private readonly ShortUrlListServiceInterface $shortUrlService,
|
||||
@ -44,6 +46,7 @@ class ListShortUrlsCommand extends Command
|
||||
parent::__construct();
|
||||
$this->startDateOption = new StartDateOption($this, 'short URLs');
|
||||
$this->endDateOption = new EndDateOption($this, 'short URLs');
|
||||
$this->tagsOption = new TagsOption($this, 'A list of tags that short URLs need to include.');
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
@ -71,16 +74,20 @@ class ListShortUrlsCommand extends Command
|
||||
'Used to filter results by domain. Use DEFAULT keyword to filter by default domain',
|
||||
)
|
||||
->addOption(
|
||||
'tags',
|
||||
't',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'A comma-separated list of tags to filter results.',
|
||||
'tags-all',
|
||||
mode: InputOption::VALUE_NONE,
|
||||
description: 'If --tag is provided, returns only short URLs including ALL of them',
|
||||
)
|
||||
->addOption(
|
||||
'including-all-tags',
|
||||
'i',
|
||||
InputOption::VALUE_NONE,
|
||||
'If tags is provided, returns only short URLs having ALL tags.',
|
||||
'exclude-tag',
|
||||
'et',
|
||||
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
|
||||
'A list of tags that short URLs should not have.',
|
||||
)
|
||||
->addOption(
|
||||
'exclude-tags-all',
|
||||
mode: InputOption::VALUE_NONE,
|
||||
description: 'If --exclude-tag is provided, returns only short URLs not including ANY of them',
|
||||
)
|
||||
->addOption(
|
||||
'exclude-max-visits-reached',
|
||||
@ -101,6 +108,12 @@ class ListShortUrlsCommand extends Command
|
||||
'The field from which you want to order by. '
|
||||
. 'Define ordering dir by passing ASC or DESC after "-" or ",".',
|
||||
)
|
||||
->addOption(
|
||||
'api-key-name',
|
||||
'kn',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'List only short URLs created by the API key matching provided name.',
|
||||
)
|
||||
->addOption(
|
||||
'show-tags',
|
||||
null,
|
||||
@ -119,7 +132,6 @@ class ListShortUrlsCommand extends Command
|
||||
InputOption::VALUE_NONE,
|
||||
'Whether to display the API key name from which the URL was generated or not.',
|
||||
)
|
||||
->addOption('show-api-key-name', 'm', InputOption::VALUE_NONE, '[DEPRECATED] Use show-api-key')
|
||||
->addOption(
|
||||
'all',
|
||||
'a',
|
||||
@ -134,33 +146,30 @@ class ListShortUrlsCommand extends Command
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$page = (int) $input->getOption('page');
|
||||
$searchTerm = $input->getOption('search-term');
|
||||
$domain = $input->getOption('domain');
|
||||
$tags = $input->getOption('tags');
|
||||
$tagsMode = $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
|
||||
$tags = ! empty($tags) ? explode(',', $tags) : [];
|
||||
$all = $input->getOption('all');
|
||||
$startDate = $this->startDateOption->get($input, $output);
|
||||
$endDate = $this->endDateOption->get($input, $output);
|
||||
$orderBy = $this->processOrderBy($input);
|
||||
$columnsMap = $this->resolveColumnsMap($input);
|
||||
$tagsMode = $input->getOption('tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
|
||||
$excludeTagsMode = $input->getOption('exclude-tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
|
||||
|
||||
$data = [
|
||||
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
|
||||
ShortUrlsParamsInputFilter::DOMAIN => $domain,
|
||||
ShortUrlsParamsInputFilter::TAGS => $tags,
|
||||
ShortUrlsParamsInputFilter::SEARCH_TERM => $input->getOption('search-term'),
|
||||
ShortUrlsParamsInputFilter::DOMAIN => $input->getOption('domain'),
|
||||
ShortUrlsParamsInputFilter::TAGS => $this->tagsOption->get($input),
|
||||
ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode,
|
||||
ShortUrlsParamsInputFilter::ORDER_BY => $orderBy,
|
||||
ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(),
|
||||
ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(),
|
||||
ShortUrlsParamsInputFilter::EXCLUDE_TAGS => $input->getOption('exclude-tag'),
|
||||
ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE => $excludeTagsMode,
|
||||
ShortUrlsParamsInputFilter::ORDER_BY => $this->processOrderBy($input),
|
||||
ShortUrlsParamsInputFilter::START_DATE => $this->startDateOption->get($input, $output)?->toAtomString(),
|
||||
ShortUrlsParamsInputFilter::END_DATE => $this->endDateOption->get($input, $output)?->toAtomString(),
|
||||
ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED => $input->getOption('exclude-max-visits-reached'),
|
||||
ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL => $input->getOption('exclude-past-valid-until'),
|
||||
ShortUrlsParamsInputFilter::API_KEY_NAME => $input->getOption('api-key-name'),
|
||||
];
|
||||
|
||||
$all = $input->getOption('all');
|
||||
if ($all) {
|
||||
$data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = Paginator::ALL_ITEMS;
|
||||
}
|
||||
|
||||
$columnsMap = $this->resolveColumnsMap($input);
|
||||
do {
|
||||
$data[ShortUrlsParamsInputFilter::PAGE] = $page;
|
||||
$result = $this->renderPage($output, $columnsMap, ShortUrlsParams::fromRawData($data), $all);
|
||||
@ -168,7 +177,7 @@ class ListShortUrlsCommand extends Command
|
||||
|
||||
$continue = $result->hasNextPage() && $io->confirm(
|
||||
sprintf('Continue with page <options=bold>%s</>?', $page),
|
||||
false,
|
||||
default: false,
|
||||
);
|
||||
} while ($continue);
|
||||
|
||||
@ -236,7 +245,7 @@ class ListShortUrlsCommand extends Command
|
||||
$columnsMap['Domain'] = static fn (array $_, ShortUrl $shortUrl): string =>
|
||||
$shortUrl->getDomain()->authority ?? Domain::DEFAULT_AUTHORITY;
|
||||
}
|
||||
if ($input->getOption('show-api-key') || $input->getOption('show-api-key-name')) {
|
||||
if ($input->getOption('show-api-key')) {
|
||||
$columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): string|null =>
|
||||
$shortUrl->authorApiKey?->name;
|
||||
}
|
||||
|
||||
@ -5,12 +5,12 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\CLI\Command\Tag;
|
||||
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Attribute\Option;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: DeleteTagsCommand::NAME, description: 'Deletes one or more tags.')]
|
||||
class DeleteTagsCommand extends Command
|
||||
{
|
||||
public const string NAME = 'tag:delete';
|
||||
@ -20,24 +20,13 @@ class DeleteTagsCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Deletes one or more tags.')
|
||||
->addOption(
|
||||
'name',
|
||||
't',
|
||||
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
|
||||
'The name of the tags to delete',
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$tagNames = $input->getOption('name');
|
||||
|
||||
/**
|
||||
* @param string[] $tagNames
|
||||
*/
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
#[Option('The name of the tags to delete', name: 'name', shortcut: 't')] array $tagNames = [],
|
||||
): int {
|
||||
if (empty($tagNames)) {
|
||||
$io->warning('You have to provide at least one tag name');
|
||||
return self::INVALID;
|
||||
@ -45,6 +34,7 @@ class DeleteTagsCommand extends Command
|
||||
|
||||
$this->tagService->deleteTags($tagNames);
|
||||
$io->success('Tags properly deleted');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,24 +5,34 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\CLI\Command\Tag;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
|
||||
use Shlinkio\Shlink\CLI\Input\DomainOption;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class GetTagVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
public const string NAME = 'tag:visits';
|
||||
|
||||
private readonly DomainOption $domainOption;
|
||||
|
||||
public function __construct(
|
||||
VisitsStatsHelperInterface $visitsHelper,
|
||||
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
|
||||
) {
|
||||
parent::__construct($visitsHelper);
|
||||
$this->domainOption = new DomainOption($this, sprintf(
|
||||
'Return visits that belong to this domain only. Use %s keyword for visits in default domain',
|
||||
Domain::DEFAULT_AUTHORITY,
|
||||
));
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
@ -39,7 +49,10 @@ class GetTagVisitsCommand extends AbstractVisitsListCommand
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
$tag = $input->getArgument('tag');
|
||||
return $this->visitsHelper->visitsForTag($tag, new VisitsParams($dateRange));
|
||||
return $this->visitsHelper->visitsForTag($tag, new WithDomainVisitsParams(
|
||||
dateRange: $dateRange,
|
||||
domain: $this->domainOption->get($input),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -8,12 +8,13 @@ use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function array_map;
|
||||
|
||||
#[AsCommand(ListTagsCommand::NAME, 'Lists existing tags.')]
|
||||
class ListTagsCommand extends Command
|
||||
{
|
||||
public const string NAME = 'tag:list';
|
||||
@ -23,16 +24,9 @@ class ListTagsCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
public function __invoke(SymfonyStyle $io): int
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Lists existing tags.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
ShlinkTable::default($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
|
||||
ShlinkTable::default($io)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
|
||||
@ -8,12 +8,12 @@ use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\Renaming;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(RenameTagCommand::NAME, 'Renames one existing tag.')]
|
||||
class RenameTagCommand extends Command
|
||||
{
|
||||
public const string NAME = 'tag:rename';
|
||||
@ -23,21 +23,11 @@ class RenameTagCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Renames one existing tag.')
|
||||
->addArgument('oldName', InputArgument::REQUIRED, 'Current name of the tag.')
|
||||
->addArgument('newName', InputArgument::REQUIRED, 'New name of the tag.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$oldName = $input->getArgument('oldName');
|
||||
$newName = $input->getArgument('newName');
|
||||
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
#[Argument('Current name of the tag.')] string $oldName,
|
||||
#[Argument('New name of the tag.')] string $newName,
|
||||
): int {
|
||||
try {
|
||||
$this->tagService->renameTag(Renaming::fromNames($oldName, $newName));
|
||||
$io->success('Tag properly renamed.');
|
||||
|
||||
@ -8,14 +8,17 @@ use Shlinkio\Shlink\Core\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationDownloadProgressHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationResult;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[AsCommand(
|
||||
DownloadGeoLiteDbCommand::NAME,
|
||||
'Checks if the GeoLite2 db file is too old or it does not exist, and tries to download an up-to-date copy if so.',
|
||||
)]
|
||||
class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadProgressHandlerInterface
|
||||
{
|
||||
public const string NAME = 'visit:download-db';
|
||||
@ -28,19 +31,9 @@ class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadPro
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
public function __invoke(SymfonyStyle $io): int
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription(
|
||||
'Checks if the GeoLite2 db file is too old or it does not exist, and tries to download an up-to-date '
|
||||
. 'copy if so.',
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$this->io = new SymfonyStyle($input, $output);
|
||||
$this->io = $io;
|
||||
|
||||
try {
|
||||
$result = $this->dbUpdater->checkDbUpdate($this);
|
||||
|
||||
@ -4,23 +4,33 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Input\DomainOption;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
public const string NAME = 'visit:non-orphan';
|
||||
|
||||
private readonly DomainOption $domainOption;
|
||||
|
||||
public function __construct(
|
||||
VisitsStatsHelperInterface $visitsHelper,
|
||||
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
|
||||
) {
|
||||
parent::__construct($visitsHelper);
|
||||
$this->domainOption = new DomainOption($this, sprintf(
|
||||
'Return visits that belong to this domain only. Use %s keyword for visits in default domain',
|
||||
Domain::DEFAULT_AUTHORITY,
|
||||
));
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
@ -35,7 +45,10 @@ class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
|
||||
*/
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
return $this->visitsHelper->nonOrphanVisits(new VisitsParams($dateRange));
|
||||
return $this->visitsHelper->nonOrphanVisits(new WithDomainVisitsParams(
|
||||
dateRange: $dateRange,
|
||||
domain: $this->domainOption->get($input),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -4,11 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Input\DomainOption;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
@ -19,6 +22,17 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
public const string NAME = 'visit:orphan';
|
||||
|
||||
private readonly DomainOption $domainOption;
|
||||
|
||||
public function __construct(VisitsStatsHelperInterface $visitsHelper)
|
||||
{
|
||||
parent::__construct($visitsHelper);
|
||||
$this->domainOption = new DomainOption($this, sprintf(
|
||||
'Return visits that belong to this domain only. Use %s keyword for visits in default domain',
|
||||
Domain::DEFAULT_AUTHORITY,
|
||||
));
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
@ -37,7 +51,11 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
$rawType = $input->getOption('type');
|
||||
$type = $rawType !== null ? OrphanVisitType::from($rawType) : null;
|
||||
return $this->visitsHelper->orphanVisits(new OrphanVisitsParams(dateRange: $dateRange, type: $type));
|
||||
return $this->visitsHelper->orphanVisits(new OrphanVisitsParams(
|
||||
dateRange: $dateRange,
|
||||
domain: $this->domainOption->get($input),
|
||||
type: $type,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
29
module/CLI/src/Input/DomainOption.php
Normal file
29
module/CLI/src/Input/DomainOption.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Input;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
final readonly class DomainOption
|
||||
{
|
||||
private const string NAME = 'domain';
|
||||
|
||||
public function __construct(Command $command, string $description)
|
||||
{
|
||||
$command->addOption(
|
||||
name: self::NAME,
|
||||
shortcut: 'd',
|
||||
mode: InputOption::VALUE_REQUIRED,
|
||||
description: $description,
|
||||
);
|
||||
}
|
||||
|
||||
public function get(InputInterface $input): string|null
|
||||
{
|
||||
return $input->getOption(self::NAME);
|
||||
}
|
||||
}
|
||||
@ -13,13 +13,10 @@ use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
use function array_map;
|
||||
use function array_unique;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
|
||||
use function Shlinkio\Shlink\Core\splitByComma;
|
||||
|
||||
final readonly class ShortUrlDataInput
|
||||
{
|
||||
private readonly TagsOption $tagsOption;
|
||||
|
||||
public function __construct(Command $command, private bool $longUrlAsOption = false)
|
||||
{
|
||||
if ($longUrlAsOption) {
|
||||
@ -28,13 +25,9 @@ final readonly class ShortUrlDataInput
|
||||
$command->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to set');
|
||||
}
|
||||
|
||||
$this->tagsOption = new TagsOption($command, 'Tags to apply to the short URL');
|
||||
|
||||
$command
|
||||
->addOption(
|
||||
ShortUrlDataOption::TAGS->value,
|
||||
ShortUrlDataOption::TAGS->shortcut(),
|
||||
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
|
||||
'Tags to apply to the short URL',
|
||||
)
|
||||
->addOption(
|
||||
ShortUrlDataOption::VALID_SINCE->value,
|
||||
ShortUrlDataOption::VALID_SINCE->shortcut(),
|
||||
@ -117,9 +110,8 @@ final readonly class ShortUrlDataInput
|
||||
$maxVisits = $input->getOption('max-visits');
|
||||
$data[ShortUrlInputFilter::MAX_VISITS] = $maxVisits !== null ? (int) $maxVisits : null;
|
||||
}
|
||||
if (ShortUrlDataOption::TAGS->wasProvided($input)) {
|
||||
$tags = array_unique(flatten(array_map(splitByComma(...), $input->getOption('tags'))));
|
||||
$data[ShortUrlInputFilter::TAGS] = $tags;
|
||||
if ($this->tagsOption->exists($input)) {
|
||||
$data[ShortUrlInputFilter::TAGS] = $this->tagsOption->get($input);
|
||||
}
|
||||
if (ShortUrlDataOption::TITLE->wasProvided($input)) {
|
||||
$data[ShortUrlInputFilter::TITLE] = $input->getOption('title');
|
||||
|
||||
@ -10,7 +10,6 @@ use function sprintf;
|
||||
|
||||
enum ShortUrlDataOption: string
|
||||
{
|
||||
case TAGS = 'tags';
|
||||
case VALID_SINCE = 'valid-since';
|
||||
case VALID_UNTIL = 'valid-until';
|
||||
case MAX_VISITS = 'max-visits';
|
||||
@ -21,7 +20,6 @@ enum ShortUrlDataOption: string
|
||||
public function shortcut(): string|null
|
||||
{
|
||||
return match ($this) {
|
||||
self::TAGS => 't',
|
||||
self::VALID_SINCE => 's',
|
||||
self::VALID_UNTIL => 'u',
|
||||
self::MAX_VISITS => 'm',
|
||||
|
||||
41
module/CLI/src/Input/TagsOption.php
Normal file
41
module/CLI/src/Input/TagsOption.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Input;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
use function array_unique;
|
||||
|
||||
readonly class TagsOption
|
||||
{
|
||||
public function __construct(Command $command, string $description)
|
||||
{
|
||||
$command
|
||||
->addOption(
|
||||
'tag',
|
||||
't',
|
||||
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
|
||||
$description,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether tags have been set or not, via `--tag` or `-t`
|
||||
*/
|
||||
public function exists(InputInterface $input): bool
|
||||
{
|
||||
return $input->hasParameterOption(['--tag', '-t']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function get(InputInterface $input): array
|
||||
{
|
||||
return array_unique($input->getOption('tag'));
|
||||
}
|
||||
}
|
||||
@ -87,6 +87,15 @@ class ListShortUrlsTest extends CliTestCase
|
||||
| ghi789 | | http://s.test/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
|
||||
+------------+---------------+----------------------+--------------------------------------- Page 1 of 1 -------------------------------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
yield 'exclude tags' => [['--exclude-tag=foo'], <<<OUTPUT
|
||||
+--------------------+-------+-------------------------------------------+----------------------------------+---------------------------+--------------+
|
||||
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
|
||||
+--------------------+-------+-------------------------------------------+----------------------------------+---------------------------+--------------+
|
||||
| custom | | http://s.test/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
|
||||
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
|
||||
| ghi789 | | http://s.test/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
|
||||
+--------------------+-------+--------------------------------------- Page 1 of 1 --------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
// phpcs:enable
|
||||
}
|
||||
}
|
||||
|
||||
@ -91,11 +91,17 @@ class RoleResolverTest extends TestCase
|
||||
[RoleDefinition::forAuthoredShortUrls()],
|
||||
0,
|
||||
];
|
||||
yield 'both roles' => [
|
||||
$buildInput(
|
||||
[Role::DOMAIN_SPECIFIC->paramName() => 'example.com', Role::AUTHORED_SHORT_URLS->paramName() => true],
|
||||
),
|
||||
[RoleDefinition::forAuthoredShortUrls(), RoleDefinition::forDomain($domain)],
|
||||
yield 'all roles' => [
|
||||
$buildInput([
|
||||
Role::DOMAIN_SPECIFIC->paramName() => 'example.com',
|
||||
Role::AUTHORED_SHORT_URLS->paramName() => true,
|
||||
Role::NO_ORPHAN_VISITS->paramName() => true,
|
||||
]),
|
||||
[
|
||||
RoleDefinition::forAuthoredShortUrls(),
|
||||
RoleDefinition::forDomain($domain),
|
||||
RoleDefinition::forNoOrphanVisits(),
|
||||
],
|
||||
1,
|
||||
];
|
||||
}
|
||||
|
||||
100
module/CLI/test/Command/Api/DeleteKeyCommandTest.php
Normal file
100
module/CLI/test/Command/Api/DeleteKeyCommandTest.php
Normal file
@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Api;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\DeleteKeyCommand;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Exception\ApiKeyNotFoundException;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class DeleteKeyCommandTest extends TestCase
|
||||
{
|
||||
private CommandTester $commandTester;
|
||||
private MockObject & ApiKeyServiceInterface $apiKeyService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
|
||||
$this->commandTester = CliTestUtils::testerForCommand(new DeleteKeyCommand($this->apiKeyService));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function warningIsReturnedIfNoArgumentIsProvidedInNonInteractiveMode(): void
|
||||
{
|
||||
$this->apiKeyService->expects($this->never())->method('deleteByName');
|
||||
$this->apiKeyService->expects($this->never())->method('listKeys');
|
||||
|
||||
$exitCode = $this->commandTester->execute([], ['interactive' => false]);
|
||||
|
||||
self::assertEquals(Command::INVALID, $exitCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function confirmationIsSkippedInNonInteractiveMode(): void
|
||||
{
|
||||
$this->apiKeyService->expects($this->once())->method('deleteByName');
|
||||
$this->apiKeyService->expects($this->never())->method('listKeys');
|
||||
|
||||
$exitCode = $this->commandTester->execute(['name' => 'key to delete'], ['interactive' => false]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(Command::SUCCESS, $exitCode);
|
||||
self::assertStringNotContainsString('Are you sure you want to delete the API key?', $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function keyIsNotDeletedIfConfirmationIsCancelled(): void
|
||||
{
|
||||
$this->apiKeyService->expects($this->never())->method('deleteByName');
|
||||
$this->apiKeyService->expects($this->never())->method('listKeys');
|
||||
|
||||
$this->commandTester->setInputs(['no']);
|
||||
$exitCode = $this->commandTester->execute(['name' => 'key_to_delete']);
|
||||
|
||||
self::assertEquals(Command::INVALID, $exitCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function existingApiKeyNamesAreListedIfNoArgumentIsProvidedInInteractiveMode(): void
|
||||
{
|
||||
$name = 'the key to delete';
|
||||
$this->apiKeyService->expects($this->once())->method('deleteByName')->with($name);
|
||||
$this->apiKeyService->expects($this->once())->method('listKeys')->willReturn([
|
||||
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'foo')),
|
||||
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: $name)),
|
||||
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'bar')),
|
||||
]);
|
||||
|
||||
$this->commandTester->setInputs([$name, 'y']);
|
||||
$exitCode = $this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('What API key do you want to delete?', $output);
|
||||
self::assertStringContainsString('API key "the key to delete" properly deleted', $output);
|
||||
self::assertEquals(Command::SUCCESS, $exitCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function errorIsReturnedIfDisableByKeyThrowsException(): void
|
||||
{
|
||||
$apiKey = 'key to delete';
|
||||
$e = ApiKeyNotFoundException::forName($apiKey);
|
||||
$this->apiKeyService->expects($this->once())->method('deleteByName')->with($apiKey)->willThrowException($e);
|
||||
$this->apiKeyService->expects($this->never())->method('listKeys');
|
||||
|
||||
$exitCode = $this->commandTester->execute(['name' => $apiKey]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString($e->getMessage(), $output);
|
||||
self::assertEquals(Command::FAILURE, $exitCode);
|
||||
}
|
||||
}
|
||||
@ -31,12 +31,9 @@ class DisableKeyCommandTest extends TestCase
|
||||
public function providedApiKeyIsDisabled(): void
|
||||
{
|
||||
$apiKey = 'abcd1234';
|
||||
$this->apiKeyService->expects($this->once())->method('disableByKey')->with($apiKey);
|
||||
$this->apiKeyService->expects($this->never())->method('disableByName');
|
||||
$this->apiKeyService->expects($this->once())->method('disableByName')->with($apiKey);
|
||||
|
||||
$exitCode = $this->commandTester->execute([
|
||||
'key-or-name' => $apiKey,
|
||||
]);
|
||||
$exitCode = $this->commandTester->execute(['name' => $apiKey]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('API key "abcd1234" properly disabled', $output);
|
||||
@ -44,55 +41,15 @@ class DisableKeyCommandTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function providedApiKeyIsDisabledByName(): void
|
||||
{
|
||||
$name = 'the key to delete';
|
||||
$this->apiKeyService->expects($this->once())->method('disableByName')->with($name);
|
||||
$this->apiKeyService->expects($this->never())->method('disableByKey');
|
||||
|
||||
$exitCode = $this->commandTester->execute([
|
||||
'key-or-name' => $name,
|
||||
'--by-name' => true,
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('API key "the key to delete" properly disabled', $output);
|
||||
self::assertEquals(Command::SUCCESS, $exitCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function errorIsReturnedIfDisableByKeyThrowsException(): void
|
||||
public function errorIsReturnedIfDisableByNameThrowsException(): void
|
||||
{
|
||||
$apiKey = 'abcd1234';
|
||||
$expectedMessage = 'API key "abcd1234" does not exist.';
|
||||
$this->apiKeyService->expects($this->once())->method('disableByKey')->with($apiKey)->willThrowException(
|
||||
$this->apiKeyService->expects($this->once())->method('disableByName')->with($apiKey)->willThrowException(
|
||||
new InvalidArgumentException($expectedMessage),
|
||||
);
|
||||
$this->apiKeyService->expects($this->never())->method('disableByName');
|
||||
|
||||
$exitCode = $this->commandTester->execute([
|
||||
'key-or-name' => $apiKey,
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString($expectedMessage, $output);
|
||||
self::assertEquals(Command::FAILURE, $exitCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function errorIsReturnedIfDisableByNameThrowsException(): void
|
||||
{
|
||||
$name = 'the key to delete';
|
||||
$expectedMessage = 'API key "the key to delete" does not exist.';
|
||||
$this->apiKeyService->expects($this->once())->method('disableByName')->with($name)->willThrowException(
|
||||
new InvalidArgumentException($expectedMessage),
|
||||
);
|
||||
$this->apiKeyService->expects($this->never())->method('disableByKey');
|
||||
|
||||
$exitCode = $this->commandTester->execute([
|
||||
'key-or-name' => $name,
|
||||
'--by-name' => true,
|
||||
]);
|
||||
$exitCode = $this->commandTester->execute(['name' => $apiKey]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString($expectedMessage, $output);
|
||||
@ -103,7 +60,6 @@ class DisableKeyCommandTest extends TestCase
|
||||
public function warningIsReturnedIfNoArgumentIsProvidedInNonInteractiveMode(): void
|
||||
{
|
||||
$this->apiKeyService->expects($this->never())->method('disableByName');
|
||||
$this->apiKeyService->expects($this->never())->method('disableByKey');
|
||||
$this->apiKeyService->expects($this->never())->method('listKeys');
|
||||
|
||||
$exitCode = $this->commandTester->execute([], ['interactive' => false]);
|
||||
@ -121,7 +77,6 @@ class DisableKeyCommandTest extends TestCase
|
||||
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: $name)),
|
||||
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'bar')),
|
||||
]);
|
||||
$this->apiKeyService->expects($this->never())->method('disableByKey');
|
||||
|
||||
$this->commandTester->setInputs([$name]);
|
||||
$exitCode = $this->commandTester->execute([]);
|
||||
|
||||
@ -35,7 +35,7 @@ class InitialApiKeyCommandTest extends TestCase
|
||||
$this->apiKeyService->expects($this->once())->method('createInitial')->with('the_key')->willReturn($result);
|
||||
|
||||
$this->commandTester->execute(
|
||||
['apiKey' => 'the_key'],
|
||||
['api-key' => 'the_key'],
|
||||
['verbosity' => $verbose ? OutputInterface::VERBOSITY_VERBOSE : OutputInterface::VERBOSITY_NORMAL],
|
||||
);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
@ -28,13 +28,13 @@ class ReadEnvVarCommandTest extends TestCase
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('foo is not a valid Shlink environment variable');
|
||||
|
||||
$this->commandTester->execute(['envVar' => 'foo']);
|
||||
$this->commandTester->execute(['env-var' => 'foo']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function valueIsPrintedIfProvidedEnvVarIsValid(): void
|
||||
{
|
||||
$this->commandTester->execute(['envVar' => EnvVars::BASE_PATH->value]);
|
||||
$this->commandTester->execute(['env-var' => EnvVars::BASE_PATH->value]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringNotContainsString('Select the env var to read', $output);
|
||||
|
||||
@ -41,7 +41,7 @@ class GetDomainVisitsCommandTest extends TestCase
|
||||
{
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate(
|
||||
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
VisitLocation::fromLocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
);
|
||||
$domain = 's.test';
|
||||
$this->visitsHelper->expects($this->once())->method('visitsForDomain')->with(
|
||||
|
||||
@ -41,8 +41,8 @@ class ListDomainsCommandTest extends TestCase
|
||||
|
||||
$this->domainService->expects($this->once())->method('listDomains')->with()->willReturn([
|
||||
DomainItem::forDefaultDomain('foo.com', new NotFoundRedirectOptions(
|
||||
invalidShortUrl: 'https://foo.com/default/invalid',
|
||||
baseUrl: 'https://foo.com/default/base',
|
||||
invalidShortUrlRedirect: 'https://foo.com/default/invalid',
|
||||
baseUrlRedirect: 'https://foo.com/default/base',
|
||||
)),
|
||||
DomainItem::forNonDefaultDomain(Domain::withAuthority('bar.com')),
|
||||
DomainItem::forNonDefaultDomain($bazDomain),
|
||||
|
||||
@ -114,6 +114,7 @@ class MatomoSendVisitsCommandTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $input
|
||||
* @return array{string, int, MatomoSendVisitsCommand}
|
||||
*/
|
||||
private function executeCommand(
|
||||
|
||||
@ -64,6 +64,21 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
self::assertStringNotContainsString('but the real-time updates cannot', $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function longUrlIsAskedIfNotProvided(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn(
|
||||
UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl),
|
||||
);
|
||||
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
|
||||
'stringified_short_url',
|
||||
);
|
||||
|
||||
$this->commandTester->setInputs([$shortUrl->getLongUrl()]);
|
||||
$this->commandTester->execute([]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function providingNonUniqueSlugOutputsError(): void
|
||||
{
|
||||
@ -95,7 +110,7 @@ class CreateShortUrlCommandTest extends TestCase
|
||||
|
||||
$this->commandTester->execute([
|
||||
'longUrl' => 'http://domain.com/foo/bar',
|
||||
'--tags' => ['foo,bar', 'baz', 'boo,zar,baz'],
|
||||
'--tag' => ['foo', 'bar', 'baz', 'boo', 'zar', 'baz'],
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
|
||||
@ -64,6 +64,9 @@ class DeleteShortUrlCommandTest extends TestCase
|
||||
self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $retryAnswer
|
||||
*/
|
||||
#[Test, DataProvider('provideRetryDeleteAnswers')]
|
||||
public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted(
|
||||
array $retryAnswer,
|
||||
|
||||
@ -27,6 +27,9 @@ class DeleteShortUrlVisitsCommandTest extends TestCase
|
||||
$this->commandTester = CliTestUtils::testerForCommand(new DeleteShortUrlVisitsCommand($this->deleter));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $input
|
||||
*/
|
||||
#[Test, DataProvider('provideCancellingInputs')]
|
||||
public function executionIsAbortedIfManuallyCancelled(array $input): void
|
||||
{
|
||||
|
||||
@ -50,6 +50,19 @@ class GetShortUrlVisitsCommandTest extends TestCase
|
||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function shortCodeIsAskedIfNotProvided(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with(
|
||||
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
|
||||
$this->anything(),
|
||||
)->willReturn(new Paginator(new ArrayAdapter([])));
|
||||
|
||||
$this->commandTester->setInputs([$shortCode]);
|
||||
$this->commandTester->execute([]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function providingDateFlagsTheListGetsFiltered(): void
|
||||
{
|
||||
@ -94,7 +107,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
|
||||
public function outputIsProperlyGenerated(): void
|
||||
{
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams('bar', 'foo', ''))->locate(
|
||||
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
VisitLocation::fromLocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
);
|
||||
$shortCode = 'abc123';
|
||||
$this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with(
|
||||
|
||||
@ -25,8 +25,6 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
use function explode;
|
||||
|
||||
class ListShortUrlsCommandTest extends TestCase
|
||||
{
|
||||
private CommandTester $commandTester;
|
||||
@ -209,6 +207,9 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
string $tagsMode,
|
||||
string|null $startDate = null,
|
||||
string|null $endDate = null,
|
||||
array $excludeTags = [],
|
||||
string $excludeTagsMode = TagsMode::ANY->value,
|
||||
string|null $apiKeyName = null,
|
||||
): void {
|
||||
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([
|
||||
'page' => $page,
|
||||
@ -217,6 +218,9 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
'tagsMode' => $tagsMode,
|
||||
'startDate' => $startDate !== null ? Chronos::parse($startDate)->toAtomString() : null,
|
||||
'endDate' => $endDate !== null ? Chronos::parse($endDate)->toAtomString() : null,
|
||||
'excludeTags' => $excludeTags,
|
||||
'excludeTagsMode' => $excludeTagsMode,
|
||||
'apiKeyName' => $apiKeyName,
|
||||
]))->willReturn(new Paginator(new ArrayAdapter([])));
|
||||
|
||||
$this->commandTester->setInputs(['n']);
|
||||
@ -227,13 +231,13 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
{
|
||||
yield [[], 1, null, [], TagsMode::ANY->value];
|
||||
yield [['--page' => $page = 3], $page, null, [], TagsMode::ANY->value];
|
||||
yield [['--including-all-tags' => true], 1, null, [], TagsMode::ALL->value];
|
||||
yield [['--tags-all' => true], 1, null, [], TagsMode::ALL->value];
|
||||
yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], TagsMode::ANY->value];
|
||||
yield [
|
||||
['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'],
|
||||
['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tag' => $tags = ['foo', 'bar']],
|
||||
$page,
|
||||
$searchTerm,
|
||||
explode(',', $tags),
|
||||
$tags,
|
||||
TagsMode::ANY->value,
|
||||
];
|
||||
yield [
|
||||
@ -262,6 +266,29 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
$startDate,
|
||||
$endDate,
|
||||
];
|
||||
yield [
|
||||
['--exclude-tag' => ['foo', 'bar'], '--exclude-tags-all' => true],
|
||||
1,
|
||||
null,
|
||||
[],
|
||||
TagsMode::ANY->value,
|
||||
null,
|
||||
null,
|
||||
['foo', 'bar'],
|
||||
TagsMode::ALL->value,
|
||||
];
|
||||
yield [
|
||||
['--api-key-name' => 'foo'],
|
||||
1,
|
||||
null,
|
||||
[],
|
||||
TagsMode::ANY->value,
|
||||
null,
|
||||
null,
|
||||
[],
|
||||
TagsMode::ANY->value,
|
||||
'foo',
|
||||
];
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideOrderBy')]
|
||||
|
||||
@ -45,6 +45,19 @@ class ResolveUrlCommandTest extends TestCase
|
||||
self::assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function shortCodeIsAskedIfNotProvided(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$this->urlResolver->expects($this->once())->method('resolveShortUrl')->with(
|
||||
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
|
||||
)->willReturn($shortUrl);
|
||||
|
||||
$this->commandTester->setInputs([$shortCode]);
|
||||
$this->commandTester->execute([]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function incorrectShortCodeOutputsErrorMessage(): void
|
||||
{
|
||||
|
||||
@ -41,7 +41,7 @@ class GetTagVisitsCommandTest extends TestCase
|
||||
{
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate(
|
||||
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
VisitLocation::fromLocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
);
|
||||
$tag = 'abc123';
|
||||
$this->visitsHelper->expects($this->once())->method('visitsForTag')->with($tag, $this->anything())->willReturn(
|
||||
|
||||
@ -36,8 +36,8 @@ class RenameTagCommandTest extends TestCase
|
||||
)->willThrowException(TagNotFoundException::fromTag('foo'));
|
||||
|
||||
$this->commandTester->execute([
|
||||
'oldName' => $oldName,
|
||||
'newName' => $newName,
|
||||
'old-name' => $oldName,
|
||||
'new-name' => $newName,
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
@ -54,8 +54,8 @@ class RenameTagCommandTest extends TestCase
|
||||
)->willReturn(new Tag($newName));
|
||||
|
||||
$this->commandTester->execute([
|
||||
'oldName' => $oldName,
|
||||
'newName' => $newName,
|
||||
'old-name' => $oldName,
|
||||
'new-name' => $newName,
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
|
||||
@ -41,7 +41,7 @@ class GetNonOrphanVisitsCommandTest extends TestCase
|
||||
{
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate(
|
||||
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
VisitLocation::fromLocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
);
|
||||
$this->visitsHelper->expects($this->once())->method('nonOrphanVisits')->withAnyParameters()->willReturn(
|
||||
new Paginator(new ArrayAdapter([$visit])),
|
||||
|
||||
@ -38,7 +38,7 @@ class GetOrphanVisitsCommandTest extends TestCase
|
||||
public function outputIsProperlyGenerated(array $args, bool $includesType): void
|
||||
{
|
||||
$visit = Visit::forBasePath(Visitor::fromParams('bar', 'foo', ''))->locate(
|
||||
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
VisitLocation::fromLocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
);
|
||||
$this->visitsHelper->expects($this->once())->method('orphanVisits')->with($this->callback(
|
||||
fn (OrphanVisitsParams $param) => (
|
||||
|
||||
@ -63,7 +63,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
array $args,
|
||||
): void {
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams('', '', '1.2.3.4'));
|
||||
$location = VisitLocation::fromGeolocation(Location::empty());
|
||||
$location = VisitLocation::fromLocation(Location::empty());
|
||||
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
|
||||
|
||||
$this->lock->method('acquire')->willReturn(true);
|
||||
@ -81,7 +81,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
->willReturnCallback($mockMethodBehavior);
|
||||
$this->visitToLocation->expects(
|
||||
$this->exactly($expectedUnlocatedCalls + $expectedEmptyCalls + $expectedAllCalls),
|
||||
)->method('resolveVisitLocation')->withAnyParameters()->willReturn(Location::emptyInstance());
|
||||
)->method('resolveVisitLocation')->withAnyParameters()->willReturn(Location::empty());
|
||||
$this->downloadDbCommand->method('run')->willReturn(Command::SUCCESS);
|
||||
|
||||
$this->commandTester->setInputs(['y']);
|
||||
@ -107,7 +107,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void
|
||||
{
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty());
|
||||
$location = VisitLocation::fromGeolocation(Location::empty());
|
||||
$location = VisitLocation::fromLocation(Location::empty());
|
||||
|
||||
$this->lock->method('acquire')->willReturn(true);
|
||||
$this->visitService->expects($this->once())
|
||||
@ -134,7 +134,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
public function errorWhileLocatingIpIsDisplayed(): void
|
||||
{
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4'));
|
||||
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
|
||||
$location = VisitLocation::fromLocation(Location::empty());
|
||||
|
||||
$this->lock->method('acquire')->willReturn(true);
|
||||
$this->visitService->expects($this->once())
|
||||
@ -204,6 +204,9 @@ class LocateVisitsCommandTest extends TestCase
|
||||
self::assertStringContainsString('The --all flag has no effect on its own', $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $inputs
|
||||
*/
|
||||
#[Test, DataProvider('provideAbortInputs')]
|
||||
public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void
|
||||
{
|
||||
|
||||
@ -40,9 +40,9 @@ class CliTestUtils
|
||||
public static function testerForCommand(Command $mainCommand, Command ...$extraCommands): CommandTester
|
||||
{
|
||||
$app = new Application();
|
||||
$app->add($mainCommand);
|
||||
$app->addCommand($mainCommand);
|
||||
foreach ($extraCommands as $command) {
|
||||
$app->add($command);
|
||||
$app->addCommand($command);
|
||||
}
|
||||
|
||||
return new CommandTester($mainCommand);
|
||||
|
||||
@ -51,7 +51,6 @@ class ShlinkTableTest extends TestCase
|
||||
|
||||
$ref = new ReflectionObject($instance);
|
||||
$baseTable = $ref->getProperty('baseTable');
|
||||
$baseTable->setAccessible(true);
|
||||
|
||||
self::assertInstanceOf(Table::class, $baseTable->getValue($instance));
|
||||
}
|
||||
|
||||
@ -33,7 +33,6 @@ return [
|
||||
Config\Options\RedirectOptions::class => [Config\Options\RedirectOptions::class, 'fromEnv'],
|
||||
Config\Options\UrlShortenerOptions::class => [Config\Options\UrlShortenerOptions::class, 'fromEnv'],
|
||||
Config\Options\TrackingOptions::class => [Config\Options\TrackingOptions::class, 'fromEnv'],
|
||||
Config\Options\QrCodeOptions::class => [Config\Options\QrCodeOptions::class, 'fromEnv'],
|
||||
Config\Options\RabbitMqOptions::class => [Config\Options\RabbitMqOptions::class, 'fromEnv'],
|
||||
Config\Options\RobotsOptions::class => [Config\Options\RobotsOptions::class, 'fromEnv'],
|
||||
Config\Options\RealTimeUpdatesOptions::class => [Config\Options\RealTimeUpdatesOptions::class, 'fromEnv'],
|
||||
@ -103,7 +102,6 @@ return [
|
||||
|
||||
Action\RedirectAction::class => ConfigAbstractFactory::class,
|
||||
Action\PixelAction::class => ConfigAbstractFactory::class,
|
||||
Action\QrCodeAction::class => ConfigAbstractFactory::class,
|
||||
Action\RobotsAction::class => ConfigAbstractFactory::class,
|
||||
|
||||
EventDispatcher\PublishingUpdatesGenerator::class => ConfigAbstractFactory::class,
|
||||
@ -168,7 +166,7 @@ return [
|
||||
],
|
||||
Visit\Geolocation\VisitLocator::class => ['em', Visit\Repository\VisitIterationRepository::class],
|
||||
Visit\Geolocation\VisitToLocationHelper::class => [IpLocationResolverInterface::class],
|
||||
Visit\VisitsStatsHelper::class => ['em'],
|
||||
Visit\VisitsStatsHelper::class => ['em', Config\Options\UrlShortenerOptions::class],
|
||||
Tag\TagService::class => ['em', Tag\Repository\TagRepository::class],
|
||||
ShortUrl\DeleteShortUrlService::class => [
|
||||
'em',
|
||||
@ -209,12 +207,6 @@ return [
|
||||
Util\RedirectResponseHelper::class,
|
||||
],
|
||||
Action\PixelAction::class => [ShortUrl\ShortUrlResolver::class, Visit\RequestTracker::class],
|
||||
Action\QrCodeAction::class => [
|
||||
ShortUrl\ShortUrlResolver::class,
|
||||
ShortUrl\Helper\ShortUrlStringifier::class,
|
||||
'Logger_Shlink',
|
||||
Config\Options\QrCodeOptions::class,
|
||||
],
|
||||
Action\RobotsAction::class => [Crawling\CrawlingHelper::class, Config\Options\RobotsOptions::class],
|
||||
|
||||
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => [
|
||||
|
||||
@ -1,161 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Action\Model;
|
||||
|
||||
use Endroid\QrCode\Color\Color;
|
||||
use Endroid\QrCode\Color\ColorInterface;
|
||||
use Endroid\QrCode\ErrorCorrectionLevel;
|
||||
use Endroid\QrCode\RoundBlockSizeMode;
|
||||
use Endroid\QrCode\Writer\PngWriter;
|
||||
use Endroid\QrCode\Writer\SvgWriter;
|
||||
use Endroid\QrCode\Writer\WriterInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Core\Config\Options\QrCodeOptions;
|
||||
|
||||
use function ctype_xdigit;
|
||||
use function hexdec;
|
||||
use function ltrim;
|
||||
use function max;
|
||||
use function min;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
|
||||
use function strlen;
|
||||
use function strtolower;
|
||||
use function substr;
|
||||
use function trim;
|
||||
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR;
|
||||
|
||||
/** @deprecated */
|
||||
final readonly class QrCodeParams
|
||||
{
|
||||
private const int MIN_SIZE = 50;
|
||||
private const int MAX_SIZE = 1000;
|
||||
private const array SUPPORTED_FORMATS = ['png', 'svg'];
|
||||
|
||||
private function __construct(
|
||||
public int $size,
|
||||
public int $margin,
|
||||
public WriterInterface $writer,
|
||||
public array $writerOptions,
|
||||
public ErrorCorrectionLevel $errorCorrectionLevel,
|
||||
public RoundBlockSizeMode $roundBlockSizeMode,
|
||||
public ColorInterface $color,
|
||||
public ColorInterface $bgColor,
|
||||
public bool $disableLogo,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromRequest(ServerRequestInterface $request, QrCodeOptions $defaults): self
|
||||
{
|
||||
$query = $request->getQueryParams();
|
||||
[$writer, $writerOptions] = self::resolveWriterAndWriterOptions($query, $defaults);
|
||||
|
||||
return new self(
|
||||
size: self::resolveSize($query, $defaults),
|
||||
margin: self::resolveMargin($query, $defaults),
|
||||
writer: $writer,
|
||||
writerOptions: $writerOptions,
|
||||
errorCorrectionLevel: self::resolveErrorCorrection($query, $defaults),
|
||||
roundBlockSizeMode: self::resolveRoundBlockSize($query, $defaults),
|
||||
color: self::resolveColor($query, $defaults),
|
||||
bgColor: self::resolveBackgroundColor($query, $defaults),
|
||||
disableLogo: isset($query['logo']) && $query['logo'] === 'disable',
|
||||
);
|
||||
}
|
||||
|
||||
private static function resolveSize(array $query, QrCodeOptions $defaults): int
|
||||
{
|
||||
$size = (int) ($query['size'] ?? $defaults->size);
|
||||
if ($size < self::MIN_SIZE) {
|
||||
return self::MIN_SIZE;
|
||||
}
|
||||
|
||||
return min($size, self::MAX_SIZE);
|
||||
}
|
||||
|
||||
private static function resolveMargin(array $query, QrCodeOptions $defaults): int
|
||||
{
|
||||
$margin = $query['margin'] ?? (string) $defaults->margin;
|
||||
$intMargin = (int) $margin;
|
||||
if ($margin !== (string) $intMargin) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return max($intMargin, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{WriterInterface, array}
|
||||
*/
|
||||
private static function resolveWriterAndWriterOptions(array $query, QrCodeOptions $defaults): array
|
||||
{
|
||||
$qFormat = self::normalizeParam($query['format'] ?? '');
|
||||
$format = contains($qFormat, self::SUPPORTED_FORMATS) ? $qFormat : self::normalizeParam($defaults->format);
|
||||
|
||||
return match ($format) {
|
||||
'svg' => [new SvgWriter(), []],
|
||||
default => [new PngWriter(), [PngWriter::WRITER_OPTION_NUMBER_OF_COLORS => null]],
|
||||
};
|
||||
}
|
||||
|
||||
private static function resolveErrorCorrection(array $query, QrCodeOptions $defaults): ErrorCorrectionLevel
|
||||
{
|
||||
$errorCorrectionLevel = self::normalizeParam($query['errorCorrection'] ?? $defaults->errorCorrection);
|
||||
return match ($errorCorrectionLevel) {
|
||||
'h' => ErrorCorrectionLevel::High,
|
||||
'q' => ErrorCorrectionLevel::Quartile,
|
||||
'm' => ErrorCorrectionLevel::Medium,
|
||||
default => ErrorCorrectionLevel::Low, // 'l'
|
||||
};
|
||||
}
|
||||
|
||||
private static function resolveRoundBlockSize(array $query, QrCodeOptions $defaults): RoundBlockSizeMode
|
||||
{
|
||||
$doNotRoundBlockSize = isset($query['roundBlockSize'])
|
||||
? $query['roundBlockSize'] === 'false'
|
||||
: ! $defaults->roundBlockSize;
|
||||
return $doNotRoundBlockSize ? RoundBlockSizeMode::None : RoundBlockSizeMode::Margin;
|
||||
}
|
||||
|
||||
private static function resolveColor(array $query, QrCodeOptions $defaults): ColorInterface
|
||||
{
|
||||
$color = self::normalizeParam($query['color'] ?? $defaults->color);
|
||||
return self::parseHexColor($color, DEFAULT_QR_CODE_COLOR);
|
||||
}
|
||||
|
||||
private static function resolveBackgroundColor(array $query, QrCodeOptions $defaults): ColorInterface
|
||||
{
|
||||
$bgColor = self::normalizeParam($query['bgColor'] ?? $defaults->bgColor);
|
||||
return self::parseHexColor($bgColor, DEFAULT_QR_CODE_BG_COLOR);
|
||||
}
|
||||
|
||||
private static function parseHexColor(string $hexColor, string|null $fallback): Color
|
||||
{
|
||||
$hexColor = ltrim($hexColor, '#');
|
||||
if (! ctype_xdigit($hexColor) && $fallback !== null) {
|
||||
return self::parseHexColor($fallback, null);
|
||||
}
|
||||
|
||||
if (strlen($hexColor) === 3) {
|
||||
return new Color(
|
||||
(int) hexdec(substr($hexColor, 0, 1) . substr($hexColor, 0, 1)),
|
||||
(int) hexdec(substr($hexColor, 1, 1) . substr($hexColor, 1, 1)),
|
||||
(int) hexdec(substr($hexColor, 2, 1) . substr($hexColor, 2, 1)),
|
||||
);
|
||||
}
|
||||
|
||||
return new Color(
|
||||
(int) hexdec(substr($hexColor, 0, 2)),
|
||||
(int) hexdec(substr($hexColor, 2, 2)),
|
||||
(int) hexdec(substr($hexColor, 4, 2)),
|
||||
);
|
||||
}
|
||||
|
||||
private static function normalizeParam(string $param): string
|
||||
{
|
||||
return strtolower(trim($param));
|
||||
}
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Action;
|
||||
|
||||
use Endroid\QrCode\Builder\Builder;
|
||||
use Endroid\QrCode\Writer\Result\ResultInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
|
||||
use Shlinkio\Shlink\Core\Action\Model\QrCodeParams;
|
||||
use Shlinkio\Shlink\Core\Config\Options\QrCodeOptions;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
|
||||
|
||||
/** @deprecated */
|
||||
readonly class QrCodeAction implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ShortUrlResolverInterface $urlResolver,
|
||||
private ShortUrlStringifierInterface $stringifier,
|
||||
private LoggerInterface $logger,
|
||||
private QrCodeOptions $options,
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||
{
|
||||
$identifier = ShortUrlIdentifier::fromRedirectRequest($request);
|
||||
|
||||
try {
|
||||
$shortUrl = $this->options->enabledForDisabledShortUrls
|
||||
? $this->urlResolver->resolvePublicShortUrl($identifier)
|
||||
: $this->urlResolver->resolveEnabledShortUrl($identifier);
|
||||
} catch (ShortUrlNotFoundException $e) {
|
||||
$this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]);
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
$params = QrCodeParams::fromRequest($request, $this->options);
|
||||
$qrCodeBuilder = new Builder(
|
||||
writer: $params->writer,
|
||||
writerOptions: $params->writerOptions,
|
||||
data: $this->stringifier->stringify($shortUrl),
|
||||
errorCorrectionLevel: $params->errorCorrectionLevel,
|
||||
size: $params->size,
|
||||
margin: $params->margin,
|
||||
roundBlockSizeMode: $params->roundBlockSizeMode,
|
||||
foregroundColor: $params->color,
|
||||
backgroundColor: $params->bgColor,
|
||||
);
|
||||
|
||||
return new QrCodeResponse($this->buildQrCode($qrCodeBuilder, $params));
|
||||
}
|
||||
|
||||
private function buildQrCode(Builder $qrCodeBuilder, QrCodeParams $params): ResultInterface
|
||||
{
|
||||
$logoUrl = $this->options->logoUrl;
|
||||
if ($logoUrl === null || $params->disableLogo) {
|
||||
return $qrCodeBuilder->build();
|
||||
}
|
||||
|
||||
return $qrCodeBuilder->build(
|
||||
logoPath: $logoUrl,
|
||||
logoResizeToHeight: (int) ($params->size / 4),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -6,33 +6,7 @@ namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
final class EmptyNotFoundRedirectConfig implements NotFoundRedirectConfigInterface
|
||||
{
|
||||
public function invalidShortUrlRedirect(): string|null
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function hasInvalidShortUrlRedirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function regular404Redirect(): string|null
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function hasRegular404Redirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function baseUrlRedirect(): string|null
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function hasBaseUrlRedirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
private(set) string|null $invalidShortUrlRedirect = null;
|
||||
private(set) string|null $regular404Redirect = null;
|
||||
private(set) string|null $baseUrlRedirect = null;
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
use Shlinkio\Shlink\Core\Config\Options\ExtraPathMode;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
||||
|
||||
use function date_default_timezone_get;
|
||||
@ -13,14 +14,6 @@ use function Shlinkio\Shlink\Config\env;
|
||||
use function Shlinkio\Shlink\Config\parseEnvVar;
|
||||
use function sprintf;
|
||||
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ROUND_BLOCK_SIZE;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE;
|
||||
use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||
use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
|
||||
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
|
||||
@ -41,6 +34,8 @@ enum EnvVars: string
|
||||
case CACHE_NAMESPACE = 'CACHE_NAMESPACE';
|
||||
case REDIS_SERVERS = 'REDIS_SERVERS';
|
||||
case REDIS_SENTINEL_SERVICE = 'REDIS_SENTINEL_SERVICE';
|
||||
case REDIS_SERVERS_USER = 'REDIS_SERVERS_USER';
|
||||
case REDIS_SERVERS_PASSWORD = 'REDIS_SERVERS_PASSWORD';
|
||||
case REDIS_PUB_SUB_ENABLED = 'REDIS_PUB_SUB_ENABLED';
|
||||
case MERCURE_ENABLED = 'MERCURE_ENABLED';
|
||||
case MERCURE_PUBLIC_HUB_URL = 'MERCURE_PUBLIC_HUB_URL';
|
||||
@ -93,27 +88,6 @@ enum EnvVars: string
|
||||
case TRUSTED_PROXIES = 'TRUSTED_PROXIES';
|
||||
case LOGS_FORMAT = 'LOGS_FORMAT';
|
||||
|
||||
/** @deprecated Use REDIRECT_EXTRA_PATH */
|
||||
case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH';
|
||||
/** @deprecated */
|
||||
case DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE';
|
||||
/** @deprecated */
|
||||
case DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN';
|
||||
/** @deprecated */
|
||||
case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT';
|
||||
/** @deprecated */
|
||||
case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION';
|
||||
/** @deprecated */
|
||||
case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE';
|
||||
/** @deprecated */
|
||||
case QR_CODE_FOR_DISABLED_SHORT_URLS = 'QR_CODE_FOR_DISABLED_SHORT_URLS';
|
||||
/** @deprecated */
|
||||
case DEFAULT_QR_CODE_COLOR = 'DEFAULT_QR_CODE_COLOR';
|
||||
/** @deprecated */
|
||||
case DEFAULT_QR_CODE_BG_COLOR = 'DEFAULT_QR_CODE_BG_COLOR';
|
||||
/** @deprecated */
|
||||
case DEFAULT_QR_CODE_LOGO_URL = 'DEFAULT_QR_CODE_LOGO_URL';
|
||||
|
||||
public function loadFromEnv(): mixed
|
||||
{
|
||||
return env($this->value) ?? $this->loadFromFileEnv() ?? $this->defaultValue();
|
||||
@ -150,9 +124,7 @@ enum EnvVars: string
|
||||
self::SHORT_URL_TRAILING_SLASH => false,
|
||||
self::DEFAULT_DOMAIN, self::BASE_PATH => '',
|
||||
self::CACHE_NAMESPACE => 'Shlink',
|
||||
// Deprecated. In Shlink 5.0.0, add default value for REDIRECT_EXTRA_PATH_MODE
|
||||
self::REDIRECT_APPEND_EXTRA_PATH => false,
|
||||
// self::REDIRECT_EXTRA_PATH_MODE => ExtraPathMode::DEFAULT->value,
|
||||
self::REDIRECT_EXTRA_PATH_MODE => ExtraPathMode::DEFAULT->value,
|
||||
|
||||
self::REDIS_PUB_SUB_ENABLED,
|
||||
self::MATOMO_ENABLED,
|
||||
@ -171,15 +143,6 @@ enum EnvVars: string
|
||||
self::MERCURE_ENABLED => self::MERCURE_PUBLIC_HUB_URL->existsInEnv(),
|
||||
self::MERCURE_INTERNAL_HUB_URL => self::MERCURE_PUBLIC_HUB_URL->loadFromEnv(),
|
||||
|
||||
self::DEFAULT_QR_CODE_SIZE, => DEFAULT_QR_CODE_SIZE,
|
||||
self::DEFAULT_QR_CODE_MARGIN, => DEFAULT_QR_CODE_MARGIN,
|
||||
self::DEFAULT_QR_CODE_FORMAT, => DEFAULT_QR_CODE_FORMAT,
|
||||
self::DEFAULT_QR_CODE_ERROR_CORRECTION, => DEFAULT_QR_CODE_ERROR_CORRECTION,
|
||||
self::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE, => DEFAULT_QR_CODE_ROUND_BLOCK_SIZE,
|
||||
self::QR_CODE_FOR_DISABLED_SHORT_URLS, => DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS,
|
||||
self::DEFAULT_QR_CODE_COLOR, => DEFAULT_QR_CODE_COLOR,
|
||||
self::DEFAULT_QR_CODE_BG_COLOR, => DEFAULT_QR_CODE_BG_COLOR,
|
||||
|
||||
self::RABBITMQ_ENABLED, self::RABBITMQ_USE_SSL => false,
|
||||
self::RABBITMQ_PORT => 5672,
|
||||
self::RABBITMQ_VHOST => '/',
|
||||
|
||||
@ -6,15 +6,7 @@ namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
interface NotFoundRedirectConfigInterface
|
||||
{
|
||||
public function invalidShortUrlRedirect(): string|null;
|
||||
|
||||
public function hasInvalidShortUrlRedirect(): bool;
|
||||
|
||||
public function regular404Redirect(): string|null;
|
||||
|
||||
public function hasRegular404Redirect(): bool;
|
||||
|
||||
public function baseUrlRedirect(): string|null;
|
||||
|
||||
public function hasBaseUrlRedirect(): bool;
|
||||
public string|null $invalidShortUrlRedirect { get; }
|
||||
public string|null $regular404Redirect { get; }
|
||||
public string|null $baseUrlRedirect { get; }
|
||||
}
|
||||
|
||||
@ -32,10 +32,9 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
|
||||
UriInterface $currentUri,
|
||||
): ResponseInterface|null {
|
||||
$urlToRedirectTo = match (true) {
|
||||
$notFoundType->isBaseUrl() && $config->hasBaseUrlRedirect() => $config->baseUrlRedirect(),
|
||||
$notFoundType->isRegularNotFound() && $config->hasRegular404Redirect() => $config->regular404Redirect(),
|
||||
$notFoundType->isInvalidShortUrl() && $config->hasInvalidShortUrlRedirect() =>
|
||||
$config->invalidShortUrlRedirect(),
|
||||
$notFoundType->isBaseUrl() => $config->baseUrlRedirect,
|
||||
$notFoundType->isRegularNotFound() => $config->regular404Redirect,
|
||||
$notFoundType->isInvalidShortUrl() => $config->invalidShortUrlRedirect,
|
||||
default => null,
|
||||
};
|
||||
|
||||
|
||||
@ -6,12 +6,12 @@ namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
final class NotFoundRedirects implements JsonSerializable
|
||||
final readonly class NotFoundRedirects implements JsonSerializable
|
||||
{
|
||||
private function __construct(
|
||||
public readonly string|null $baseUrlRedirect,
|
||||
public readonly string|null $regular404Redirect,
|
||||
public readonly string|null $invalidShortUrlRedirect,
|
||||
public string|null $baseUrlRedirect,
|
||||
public string|null $regular404Redirect,
|
||||
public string|null $invalidShortUrlRedirect,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -30,7 +30,7 @@ final class NotFoundRedirects implements JsonSerializable
|
||||
|
||||
public static function fromConfig(NotFoundRedirectConfigInterface $config): self
|
||||
{
|
||||
return new self($config->baseUrlRedirect(), $config->regular404Redirect(), $config->invalidShortUrlRedirect());
|
||||
return new self($config->baseUrlRedirect, $config->regular404Redirect, $config->invalidShortUrlRedirect);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
|
||||
@ -6,8 +6,6 @@ namespace Shlinkio\Shlink\Core\Config\Options;
|
||||
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class AppOptions
|
||||
{
|
||||
public function __construct(public string $name = 'Shlink', public string $version = '4.0.0')
|
||||
@ -19,9 +17,4 @@ final class AppOptions
|
||||
$version = EnvVars::isDevEnv() ? 'latest' : '%SHLINK_VERSION%';
|
||||
return new self(version: $version);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return sprintf('%s:v%s', $this->name, $this->version);
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,7 +37,19 @@ final readonly class CorsOptions
|
||||
);
|
||||
}
|
||||
|
||||
public function responseWithAllowOrigin(RequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
/**
|
||||
* Creates a new response which contains the CORS headers that apply to provided request
|
||||
*/
|
||||
public function responseWithCorsHeaders(RequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$response = $this->responseWithAllowOrigin($request, $response);
|
||||
return $this->allowCredentials ? $response->withHeader('Access-Control-Allow-Credentials', 'true') : $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* If applicable, a new response with the appropriate Access-Control-Allow-Origin header is returned
|
||||
*/
|
||||
private function responseWithAllowOrigin(RequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
if ($this->allowOrigins === '*') {
|
||||
return $response->withHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
@ -10,48 +10,18 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
|
||||
final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigInterface
|
||||
{
|
||||
public function __construct(
|
||||
public string|null $invalidShortUrl = null,
|
||||
public string|null $regular404 = null,
|
||||
public string|null $baseUrl = null,
|
||||
public string|null $invalidShortUrlRedirect = null,
|
||||
public string|null $regular404Redirect = null,
|
||||
public string|null $baseUrlRedirect = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromEnv(): self
|
||||
{
|
||||
return new self(
|
||||
invalidShortUrl: EnvVars::DEFAULT_INVALID_SHORT_URL_REDIRECT->loadFromEnv(),
|
||||
regular404: EnvVars::DEFAULT_REGULAR_404_REDIRECT->loadFromEnv(),
|
||||
baseUrl: EnvVars::DEFAULT_BASE_URL_REDIRECT->loadFromEnv(),
|
||||
invalidShortUrlRedirect: EnvVars::DEFAULT_INVALID_SHORT_URL_REDIRECT->loadFromEnv(),
|
||||
regular404Redirect: EnvVars::DEFAULT_REGULAR_404_REDIRECT->loadFromEnv(),
|
||||
baseUrlRedirect: EnvVars::DEFAULT_BASE_URL_REDIRECT->loadFromEnv(),
|
||||
);
|
||||
}
|
||||
|
||||
public function invalidShortUrlRedirect(): string|null
|
||||
{
|
||||
return $this->invalidShortUrl;
|
||||
}
|
||||
|
||||
public function hasInvalidShortUrlRedirect(): bool
|
||||
{
|
||||
return $this->invalidShortUrl !== null;
|
||||
}
|
||||
|
||||
public function regular404Redirect(): string|null
|
||||
{
|
||||
return $this->regular404;
|
||||
}
|
||||
|
||||
public function hasRegular404Redirect(): bool
|
||||
{
|
||||
return $this->regular404 !== null;
|
||||
}
|
||||
|
||||
public function baseUrlRedirect(): string|null
|
||||
{
|
||||
return $this->baseUrl;
|
||||
}
|
||||
|
||||
public function hasBaseUrlRedirect(): bool
|
||||
{
|
||||
return $this->baseUrl !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Config\Options;
|
||||
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ROUND_BLOCK_SIZE;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE;
|
||||
|
||||
/** @deprecated */
|
||||
final readonly class QrCodeOptions
|
||||
{
|
||||
public function __construct(
|
||||
public int $size = DEFAULT_QR_CODE_SIZE,
|
||||
public int $margin = DEFAULT_QR_CODE_MARGIN,
|
||||
public string $format = DEFAULT_QR_CODE_FORMAT,
|
||||
public string $errorCorrection = DEFAULT_QR_CODE_ERROR_CORRECTION,
|
||||
public bool $roundBlockSize = DEFAULT_QR_CODE_ROUND_BLOCK_SIZE,
|
||||
public bool $enabledForDisabledShortUrls = DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS,
|
||||
public string $color = DEFAULT_QR_CODE_COLOR,
|
||||
public string $bgColor = DEFAULT_QR_CODE_BG_COLOR,
|
||||
public string|null $logoUrl = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromEnv(): self
|
||||
{
|
||||
return new self(
|
||||
size: (int) EnvVars::DEFAULT_QR_CODE_SIZE->loadFromEnv(),
|
||||
margin: (int) EnvVars::DEFAULT_QR_CODE_MARGIN->loadFromEnv(),
|
||||
format: EnvVars::DEFAULT_QR_CODE_FORMAT->loadFromEnv(),
|
||||
errorCorrection: EnvVars::DEFAULT_QR_CODE_ERROR_CORRECTION->loadFromEnv(),
|
||||
roundBlockSize: (bool) EnvVars::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE->loadFromEnv(),
|
||||
enabledForDisabledShortUrls: (bool) EnvVars::QR_CODE_FOR_DISABLED_SHORT_URLS->loadFromEnv(),
|
||||
color: EnvVars::DEFAULT_QR_CODE_COLOR->loadFromEnv(),
|
||||
bgColor: EnvVars::DEFAULT_QR_CODE_BG_COLOR->loadFromEnv(),
|
||||
logoUrl: EnvVars::DEFAULT_QR_CODE_LOGO_URL->loadFromEnv(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -36,15 +36,8 @@ final readonly class UrlShortenerOptions
|
||||
MIN_SHORT_CODES_LENGTH,
|
||||
);
|
||||
|
||||
// Deprecated. Initialize extra path from REDIRECT_APPEND_EXTRA_PATH.
|
||||
$appendExtraPath = EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv();
|
||||
$extraPathMode = $appendExtraPath ? ExtraPathMode::APPEND : ExtraPathMode::DEFAULT;
|
||||
|
||||
// If REDIRECT_EXTRA_PATH_MODE was explicitly provided, it has precedence
|
||||
$extraPathModeFromEnv = EnvVars::REDIRECT_EXTRA_PATH_MODE->loadFromEnv();
|
||||
if ($extraPathModeFromEnv !== null) {
|
||||
$extraPathMode = ExtraPathMode::tryFrom($extraPathModeFromEnv) ?? ExtraPathMode::DEFAULT;
|
||||
}
|
||||
$extraPathMode = ExtraPathMode::tryFrom($extraPathModeFromEnv) ?? ExtraPathMode::DEFAULT;
|
||||
|
||||
return new self(
|
||||
defaultDomain: EnvVars::DEFAULT_DOMAIN->loadFromEnv(),
|
||||
|
||||
@ -15,9 +15,9 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec
|
||||
|
||||
private function __construct(
|
||||
public readonly string $authority,
|
||||
private string|null $baseUrlRedirect = null,
|
||||
private string|null $regular404Redirect = null,
|
||||
private string|null $invalidShortUrlRedirect = null,
|
||||
private(set) string|null $baseUrlRedirect = null,
|
||||
private(set) string|null $regular404Redirect = null,
|
||||
private(set) string|null $invalidShortUrlRedirect = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -31,36 +31,6 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec
|
||||
return $this->authority;
|
||||
}
|
||||
|
||||
public function invalidShortUrlRedirect(): string|null
|
||||
{
|
||||
return $this->invalidShortUrlRedirect;
|
||||
}
|
||||
|
||||
public function hasInvalidShortUrlRedirect(): bool
|
||||
{
|
||||
return $this->invalidShortUrlRedirect !== null;
|
||||
}
|
||||
|
||||
public function regular404Redirect(): string|null
|
||||
{
|
||||
return $this->regular404Redirect;
|
||||
}
|
||||
|
||||
public function hasRegular404Redirect(): bool
|
||||
{
|
||||
return $this->regular404Redirect !== null;
|
||||
}
|
||||
|
||||
public function baseUrlRedirect(): string|null
|
||||
{
|
||||
return $this->baseUrlRedirect;
|
||||
}
|
||||
|
||||
public function hasBaseUrlRedirect(): bool
|
||||
{
|
||||
return $this->baseUrlRedirect !== null;
|
||||
}
|
||||
|
||||
public function configureNotFoundRedirects(NotFoundRedirects $redirects): void
|
||||
{
|
||||
$this->baseUrlRedirect = $redirects->baseUrlRedirect;
|
||||
|
||||
@ -15,9 +15,7 @@ final readonly class ShortUrlCreated implements JsonSerializable, JsonUnserializ
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'shortUrlId' => $this->shortUrlId,
|
||||
];
|
||||
return ['shortUrlId' => $this->shortUrlId];
|
||||
}
|
||||
|
||||
public static function fromPayload(array $payload): self
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Middleware;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
use function array_reverse;
|
||||
use function explode;
|
||||
use function implode;
|
||||
|
||||
/**
|
||||
* Decorates a middleware to make sure it gets called with a list of reversed addresses in `X-Forwarded-For`.
|
||||
*
|
||||
* This is a workaround for a change in behavior introduced in akrabat/ip-address-middleware 2.5, which now
|
||||
* takes the first non-trusted-proxy address in that header, starting from the right, instead of the first
|
||||
* address starting from the left.
|
||||
* That change breaks Shlink's visitor IP resolution when more than one proxy is used, and trusted proxies
|
||||
* are not explicitly set for akrabat/ip-address-middleware (which Shlink does not do).
|
||||
*
|
||||
* A proper solution would require allowing trusted proxies to be configurable, and apply this logic conditionally, only
|
||||
* if trusted proxies are not set.
|
||||
*
|
||||
* @see https://github.com/akrabat/ip-address-middleware/pull/51
|
||||
* @deprecated Remove in future major version, and enforce users with multiple reverse proxies to provide the list via
|
||||
* TRUSTED_PROXIES
|
||||
*/
|
||||
readonly class ReverseForwardedAddressesMiddlewareDecorator implements MiddlewareInterface
|
||||
{
|
||||
public const string FORWARDED_FOR_HEADER = 'X-Forwarded-For';
|
||||
|
||||
public function __construct(private MiddlewareInterface $wrappedMiddleware)
|
||||
{
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
if ($request->hasHeader(self::FORWARDED_FOR_HEADER)) {
|
||||
$request = $request->withHeader(
|
||||
self::FORWARDED_FOR_HEADER,
|
||||
implode(',', array_reverse(explode(',', $request->getHeaderLine(self::FORWARDED_FOR_HEADER)))),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->wrappedMiddleware->process($request, $handler);
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,7 @@ use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
|
||||
abstract class AbstractInfinitePaginableListParams
|
||||
{
|
||||
private const FIRST_PAGE = 1;
|
||||
private const int FIRST_PAGE = 1;
|
||||
|
||||
public readonly int $page;
|
||||
public readonly int $itemsPerPage;
|
||||
|
||||
@ -12,21 +12,27 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
|
||||
use function Shlinkio\Shlink\Common\buildDateRange;
|
||||
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
|
||||
|
||||
final class ShortUrlsParams
|
||||
/**
|
||||
* Represents all the params that can be used to filter a list of short URLs
|
||||
*/
|
||||
final readonly class ShortUrlsParams
|
||||
{
|
||||
public const DEFAULT_ITEMS_PER_PAGE = 10;
|
||||
public const int DEFAULT_ITEMS_PER_PAGE = 10;
|
||||
|
||||
private function __construct(
|
||||
public readonly int $page,
|
||||
public readonly int $itemsPerPage,
|
||||
public readonly string|null $searchTerm,
|
||||
public readonly array $tags,
|
||||
public readonly Ordering $orderBy,
|
||||
public readonly DateRange|null $dateRange,
|
||||
public readonly bool $excludeMaxVisitsReached,
|
||||
public readonly bool $excludePastValidUntil,
|
||||
public readonly TagsMode $tagsMode = TagsMode::ANY,
|
||||
public readonly string|null $domain = null,
|
||||
public int $page,
|
||||
public int $itemsPerPage,
|
||||
public string|null $searchTerm,
|
||||
public array $tags,
|
||||
public Ordering $orderBy,
|
||||
public DateRange|null $dateRange,
|
||||
public bool $excludeMaxVisitsReached,
|
||||
public bool $excludePastValidUntil,
|
||||
public TagsMode $tagsMode = TagsMode::ANY,
|
||||
public string|null $domain = null,
|
||||
public array $excludeTags = [],
|
||||
public TagsMode $excludeTagsMode = TagsMode::ANY,
|
||||
public string|null $apiKeyName = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -61,6 +67,11 @@ final class ShortUrlsParams
|
||||
excludePastValidUntil: $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL),
|
||||
tagsMode: self::resolveTagsMode($inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE)),
|
||||
domain: $inputFilter->getValue(ShortUrlsParamsInputFilter::DOMAIN),
|
||||
excludeTags: (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_TAGS),
|
||||
excludeTagsMode: self::resolveTagsMode(
|
||||
$inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE),
|
||||
),
|
||||
apiKeyName: $inputFilter->getValue(ShortUrlsParamsInputFilter::API_KEY_NAME),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation;
|
||||
|
||||
use Laminas\InputFilter\Input;
|
||||
use Laminas\InputFilter\InputFilter;
|
||||
use Laminas\Validator\InArray;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
@ -19,14 +20,17 @@ class ShortUrlsParamsInputFilter extends InputFilter
|
||||
public const string PAGE = 'page';
|
||||
public const string SEARCH_TERM = 'searchTerm';
|
||||
public const string TAGS = 'tags';
|
||||
public const string TAGS_MODE = 'tagsMode';
|
||||
public const string EXCLUDE_TAGS = 'excludeTags';
|
||||
public const string EXCLUDE_TAGS_MODE = 'excludeTagsMode';
|
||||
public const string START_DATE = 'startDate';
|
||||
public const string END_DATE = 'endDate';
|
||||
public const string ITEMS_PER_PAGE = 'itemsPerPage';
|
||||
public const string TAGS_MODE = 'tagsMode';
|
||||
public const string ORDER_BY = 'orderBy';
|
||||
public const string EXCLUDE_MAX_VISITS_REACHED = 'excludeMaxVisitsReached';
|
||||
public const string EXCLUDE_PAST_VALID_UNTIL = 'excludePastValidUntil';
|
||||
public const string DOMAIN = 'domain';
|
||||
public const string API_KEY_NAME = 'apiKeyName';
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
@ -45,13 +49,10 @@ class ShortUrlsParamsInputFilter extends InputFilter
|
||||
$this->add(InputFactory::numeric(self::ITEMS_PER_PAGE, Paginator::ALL_ITEMS));
|
||||
|
||||
$this->add(InputFactory::tags(self::TAGS));
|
||||
$this->add($this->createTagsModeInput(self::TAGS_MODE));
|
||||
|
||||
$tagsMode = InputFactory::basic(self::TAGS_MODE);
|
||||
$tagsMode->getValidatorChain()->attach(new InArray([
|
||||
'haystack' => enumValues(TagsMode::class),
|
||||
'strict' => InArray::COMPARE_STRICT,
|
||||
]));
|
||||
$this->add($tagsMode);
|
||||
$this->add(InputFactory::tags(self::EXCLUDE_TAGS));
|
||||
$this->add($this->createTagsModeInput(self::EXCLUDE_TAGS_MODE));
|
||||
|
||||
$this->add(InputFactory::orderBy(self::ORDER_BY, enumValues(OrderableField::class)));
|
||||
|
||||
@ -59,5 +60,17 @@ class ShortUrlsParamsInputFilter extends InputFilter
|
||||
$this->add(InputFactory::boolean(self::EXCLUDE_PAST_VALID_UNTIL));
|
||||
|
||||
$this->add(InputFactory::basic(self::DOMAIN));
|
||||
$this->add(InputFactory::basic(self::API_KEY_NAME));
|
||||
}
|
||||
|
||||
private function createTagsModeInput(string $name): Input
|
||||
{
|
||||
$tagsMode = InputFactory::basic($name);
|
||||
$tagsMode->getValidatorChain()->attach(new InArray([
|
||||
'haystack' => enumValues(TagsMode::class),
|
||||
'strict' => InArray::COMPARE_STRICT,
|
||||
]));
|
||||
|
||||
return $tagsMode;
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,22 +15,33 @@ use function strtolower;
|
||||
class ShortUrlsCountFiltering
|
||||
{
|
||||
public readonly bool $searchIncludesDefaultDomain;
|
||||
public readonly string|null $apiKeyName;
|
||||
|
||||
/**
|
||||
* @param $defaultDomain - Used only to determine if search term includes default domain
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string|null $searchTerm = null,
|
||||
public readonly array $tags = [],
|
||||
public readonly TagsMode|null $tagsMode = null,
|
||||
public readonly TagsMode $tagsMode = TagsMode::ANY,
|
||||
public readonly DateRange|null $dateRange = null,
|
||||
public readonly bool $excludeMaxVisitsReached = false,
|
||||
public readonly bool $excludePastValidUntil = false,
|
||||
public readonly ApiKey|null $apiKey = null,
|
||||
string|null $defaultDomain = null,
|
||||
public readonly string|null $domain = null,
|
||||
public readonly array $excludeTags = [],
|
||||
public readonly TagsMode $excludeTagsMode = TagsMode::ANY,
|
||||
string|null $apiKeyName = null,
|
||||
) {
|
||||
$this->searchIncludesDefaultDomain = !empty($searchTerm) && !empty($defaultDomain) && str_contains(
|
||||
strtolower($defaultDomain),
|
||||
strtolower($searchTerm),
|
||||
);
|
||||
|
||||
// Filtering by API key name is only allowed if the API key used in the request is an admin one, or it matches
|
||||
// the API key name
|
||||
$this->apiKeyName = $apiKey?->name === $apiKeyName || ApiKey::isAdmin($apiKey) ? $apiKeyName : null;
|
||||
}
|
||||
|
||||
public static function fromParams(ShortUrlsParams $params, ApiKey|null $apiKey, string $defaultDomain): self
|
||||
@ -45,6 +56,9 @@ class ShortUrlsCountFiltering
|
||||
$apiKey,
|
||||
$defaultDomain,
|
||||
$params->domain,
|
||||
$params->excludeTags,
|
||||
$params->excludeTagsMode,
|
||||
$params->apiKeyName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user