Merge pull request #1433 from shlinkio/develop

Release 3.1.0
This commit is contained in:
Alejandro Celaya 2022-04-23 11:39:27 +02:00 committed by GitHub
commit 68e0aa1ea9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
64 changed files with 1021 additions and 228 deletions

View file

@ -23,7 +23,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: openswoole-4.9.1
extensions: openswoole-4.11.0
coverage: none
- run: composer install --no-interaction --prefer-dist
- run: composer ${{ matrix.command }}
@ -45,7 +45,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: openswoole-4.9.1
extensions: openswoole-4.11.0
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
@ -80,7 +80,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: openswoole-4.9.1, pdo_sqlsrv-5.10.0
extensions: openswoole-4.11.0, pdo_sqlsrv-5.10.0
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
@ -115,7 +115,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: openswoole-4.9.1
extensions: openswoole-4.11.0
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist

View file

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

View file

@ -23,7 +23,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: openswoole-4.9.1
extensions: openswoole-4.11.0
coverage: none
- run: composer install --no-interaction --prefer-dist
- run: composer swagger:inline

View file

@ -4,6 +4,37 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [3.1.0] - 2022-04-23
### Added
* [#1294](https://github.com/shlinkio/shlink/issues/1294) Allowed to provide a specific domain when importing URLs from YOURLS.
* [#1416](https://github.com/shlinkio/shlink/issues/1416) Added support to import URLs from Kutt.it.
* [#1418](https://github.com/shlinkio/shlink/issues/1418) Added support to customize the timezone used by Shlink, falling back to the default one set in PHP config.
The timezone can be set via the `TIMEZONE` env var, or using the installer tool.
* [#1309](https://github.com/shlinkio/shlink/issues/1309) Improved URL importing, ensuring individual errors do not make the whole process fail, and instead, failing URLs are skipped.
* [#1162](https://github.com/shlinkio/shlink/issues/1162) Added new endpoint to get visits by domain.
The endpoint is `GET /domains/{domain}/visits`, and it has the same capabilities as any other visits endpoint, allowing pagination and filtering.
### Changed
* [#1359](https://github.com/shlinkio/shlink/issues/1359) Hidden database commands.
* [#1385](https://github.com/shlinkio/shlink/issues/1385) Prevented a big error message from being logged when using Shlink without mercure.
* [#1398](https://github.com/shlinkio/shlink/issues/1398) Increased required mutation score for unit tests to 85%.
* [#1419](https://github.com/shlinkio/shlink/issues/1419) Input dates are now parsed to Shlink's configured timezone or default timezone before using them for database queries.
* [#1428](https://github.com/shlinkio/shlink/issues/1428) Updated native dependencies in docker image and base image to PHP v8.1.5.
### Deprecated
* [#1340](https://github.com/shlinkio/shlink/issues/1340) Deprecated webhooks. New events will only be added to other real-time updates approaches, and webhooks will be completely removed in Shlink 4.0.0.
### Removed
* *Nothing*
### Fixed
* [#1397](https://github.com/shlinkio/shlink/issues/1397) Fixed `db:create` command always reporting the schema exists if the `db:migrate` command has been run before by mistake.
* [#1402](https://github.com/shlinkio/shlink/issues/1402) Fixed the base path getting appended with the default domain by mistake, causing multiple side effects in several places.
## [3.0.3] - 2022-02-19
### Added
* *Nothing*

View file

@ -46,9 +46,7 @@ This is a simplified version of the project structure:
```
shlink
├── bin
│ ├── cli
│ ├── install
│ └── update
│ └── cli
├── config
│ ├── autoload
│ ├── params
@ -75,11 +73,11 @@ shlink
The purposes of every folder are:
* `bin`: It contains the CLI tools. The `cli` one is the main entry point to run shlink from the command line, while `install` and `update` are helper tools used to install and update shlink when not using the docker image.
* `bin`: It contains the CLI tools. The `cli` one is the main entry point to run shlink from the command line.
* `config`: Contains application-wide configurations, which are later merged with the ones provided by every module.
* `data`: Common runtime-generated git-ignored assets, like logs, caches, etc.
* `docs`: Any project documentation is stored here, like API spec definitions or architectural decision records.
* `module`: Contains a subfolder for every module in the project. Modules contain the source code, tests and configurations for every context in the project.
* `module`: Contains a sub-folder for every module in the project. Modules contain the source code, tests and configurations for every context in the project.
* `public`: Few assets (like `favicon.ico` or `robots.txt`) and the web entry point are stored here. This web entry point is not used when serving the app with openswoole.
## Project tests
@ -125,12 +123,6 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed,
* Run `./indocker composer ci` to run all previous commands together. This command is run during the project's continuous integration.
* Run `./indocker composer ci:parallel` to do the same as in previous case, but parallelizing non-conflicting tasks as much as possible.
> Note: Due to some limitations in the tooling used by shlink, the testing databases need to exist beforehand, both for db and api tests (except sqlite).
>
> However, they just need to be created empty, with no tables. Also, once created, they are automatically reset before every new execution.
>
> The testing database is always called `shlink_test`. You can create it using the database client of your choice. [DBeaver](https://dbeaver.io/) is a good multi-platform desktop database client which supports all the engines supported by shlink.
## Pull request process
**Important!**: Before starting to work on a pull request, make sure you always [open an issue](https://github.com/shlinkio/shlink/issues/new/choose) first.

View file

@ -1,8 +1,8 @@
FROM php:8.1.3-alpine3.15 as base
FROM php:8.1.5-alpine3.15 as base
ARG SHLINK_VERSION=latest
ENV SHLINK_VERSION ${SHLINK_VERSION}
ENV OPENSWOOLE_VERSION 4.9.1
ENV OPENSWOOLE_VERSION 4.11.0
ENV PDO_SQLSRV_VERSION 5.10.0
ENV MS_ODBC_SQL_VERSION 17.5.2.2
ENV LC_ALL "C"

View file

@ -36,12 +36,13 @@ The idea is that you can just generate a container using the image and provide t
First, make sure the host where you are going to run shlink fulfills these requirements:
* PHP 8.0 or 8.1
* The next PHP extensions: json, curl, pdo, intl, gd and gmp.
* The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath.
* apcu extension is recommended if you don't plan to use openswoole.
* xml extension is required if you want to generate QR codes in svg format.
* sockets and bcmath extensions are required if you want to integrate with a RabbitMQ instance.
* MySQL, MariaDB, PostgreSQL, Microsoft SQL Server or SQLite.
* [Openswoole](https://openswoole.com/) or the web server of your choice with PHP integration (Apache or Nginx recommended).
* MySQL, MariaDB, PostgreSQL, MicrosoftSQL or SQLite.
* You will also need the corresponding pdo variation for the database you are planning to use: `pdo_mysql`, `pdo_pgsql`, `pdo_sqlsrv` or `pdo_sqlite`.
* The [openswoole](https://openswoole.com/) PHP extension (if you plan to serve Shlink with openswoole) or the web server of your choice with PHP integration (like Apache or Nginx).
### Download

View file

@ -28,7 +28,7 @@
"laminas/laminas-config-aggregator": "^1.7",
"laminas/laminas-diactoros": "^2.8",
"laminas/laminas-inputfilter": "^2.13",
"laminas/laminas-servicemanager": "^3.10",
"laminas/laminas-servicemanager": "^3.11.2",
"laminas/laminas-stdlib": "^3.6",
"lcobucci/jwt": "^4.1",
"league/uri": "^6.4",
@ -50,8 +50,8 @@
"shlinkio/shlink-common": "^4.4",
"shlinkio/shlink-config": "^1.6",
"shlinkio/shlink-event-dispatcher": "^2.3",
"shlinkio/shlink-importer": "^2.5",
"shlinkio/shlink-installer": "^7.0.2",
"shlinkio/shlink-importer": "dev-main#af0e05e as 3.0",
"shlinkio/shlink-installer": "dev-develop#fbbc8f5 as 7.1",
"shlinkio/shlink-ip-geolocation": "^2.2",
"symfony/console": "^6.0",
"symfony/filesystem": "^6.0",
@ -61,11 +61,11 @@
"symfony/string": "^6.0"
},
"require-dev": {
"cebe/php-openapi": "^1.5",
"cebe/php-openapi": "^1.7",
"devster/ubench": "^2.1",
"dms/phpunit-arraysubset-asserts": "^0.3.0",
"infection/infection": "^0.26",
"openswoole/ide-helper": "~4.9.1",
"infection/infection": "^0.26.5",
"openswoole/ide-helper": "~4.11.0",
"phpspec/prophecy-phpunit": "^2.0",
"phpstan/phpstan": "^1.2",
"phpstan/phpstan-doctrine": "^1.0",
@ -74,7 +74,7 @@
"phpunit/phpunit": "^9.5",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.2.0",
"shlinkio/shlink-test-utils": "^3.0",
"shlinkio/shlink-test-utils": "^3.0.1",
"symfony/var-dumper": "^6.0",
"veewee/composer-run-parallel": "^1.1"
},
@ -139,7 +139,7 @@
"test:api": "bin/test/run-api-tests.sh",
"test:api:ci": "GENERATE_COVERAGE=yes composer test:api",
"infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --only-covering-test-cases --skip-initial-tests",
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80",
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=85",
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json",
"infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=80 --configuration=infection-api.json",
"infect:ci": "@parallel infect:ci:unit infect:ci:db infect:ci:api",

View file

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

View file

@ -27,6 +27,7 @@ return [
Option\Redirect\Regular404RedirectConfigOption::class,
Option\Visit\VisitsThresholdConfigOption::class,
Option\BasePathConfigOption::class,
Option\TimezoneConfigOption::class,
Option\Worker\TaskWorkerNumConfigOption::class,
Option\Worker\WebWorkerNumConfigOption::class,
Option\Redis\RedisServersConfigOption::class,

View file

@ -6,7 +6,7 @@ use Shlinkio\Shlink\Core\Config\EnvVars;
use const Shlinkio\Shlink\MIN_TASK_WORKERS;
return (static function () {
return (static function (): array {
$taskWorkers = (int) EnvVars::TASK_WORKER_NUM()->loadFromEnv(16);
return [

View file

@ -16,7 +16,7 @@ return (static function (): array {
return [
'url_shortener' => [
'domain' => [
'domain' => [ // TODO Refactor this structure to url_shortener.schema and url_shortener.default_domain
'schema' => ((bool) EnvVars::IS_HTTPS_ENABLED()->loadFromEnv(true)) ? 'https' : 'http',
'hostname' => EnvVars::DEFAULT_DOMAIN()->loadFromEnv(''),
],

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
// Deprecated. Webhooks are no longer supported. To be removed in Shlink 4.0.0
return (static function (): array {
$webhooks = EnvVars::VISITS_WEBHOOKS()->loadFromEnv();

View file

@ -5,7 +5,7 @@ declare(strict_types=1);
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Application as CliApp;
return (static function () {
return (static function (): CliApp {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/container.php';
return $container->get(CliApp::class);

View file

@ -19,3 +19,4 @@ const DEFAULT_QR_CODE_FORMAT = 'png';
const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true;
const MIN_TASK_WORKERS = 4;
const MIGRATIONS_TABLE = 'migrations';

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use Laminas\ServiceManager\ServiceManager;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Symfony\Component\Lock;
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
@ -11,6 +12,9 @@ chdir(dirname(__DIR__));
require 'vendor/autoload.php';
// This is one of the first files loaded. Configure the timezone here
date_default_timezone_set(EnvVars::TIMEZONE()->loadFromEnv(date_default_timezone_get()));
// This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name
// It needs to be placed here as individual config files will not be loaded once config is cached
if (! class_exists(LOCAL_LOCK_FACTORY)) {
@ -18,7 +22,7 @@ if (! class_exists(LOCAL_LOCK_FACTORY)) {
}
// Build container
return (function () {
return (static function (): ServiceManager {
$config = require __DIR__ . '/config.php';
$container = new ServiceManager($config['dependencies']);
$container->setService('config', $config);

View file

@ -3,9 +3,10 @@
declare(strict_types=1);
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Container\ContainerInterface;
return (static function () {
return (static function (): EntityManagerInterface {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/container.php';
return $container->get(EntityManager::class);

View file

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

View file

@ -1,4 +1,4 @@
FROM php:8.1.3-fpm-alpine3.15
FROM php:8.1.5-fpm-alpine3.15
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.21
@ -9,7 +9,6 @@ RUN apk update
# Install common php extensions
RUN docker-php-ext-install pdo_mysql
RUN docker-php-ext-install iconv
RUN docker-php-ext-install calendar
RUN apk add --no-cache oniguruma-dev

View file

@ -1,9 +1,9 @@
FROM php:8.1.3-alpine3.15
FROM php:8.1.5-alpine3.15
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.21
ENV INOTIFY_VERSION 3.0.0
ENV OPENSWOOLE_VERSION 4.9.1
ENV OPENSWOOLE_VERSION 4.11.0
ENV PDO_SQLSRV_VERSION 5.10.0
ENV MS_ODBC_SQL_VERSION 17.5.2.2
@ -11,7 +11,6 @@ RUN apk update
# Install common php extensions
RUN docker-php-ext-install pdo_mysql
RUN docker-php-ext-install iconv
RUN docker-php-ext-install calendar
RUN apk add --no-cache oniguruma-dev

View file

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

View file

@ -0,0 +1,172 @@
{
"get": {
"operationId": "getDomainVisits",
"tags": [
"Visits"
],
"summary": "List visits for domain",
"description": "Get the list of visits on any short URL which belongs to provided domain.",
"parameters": [
{
"$ref": "../parameters/version.json"
},
{
"name": "domain",
"in": "path",
"description": "The domain from which we want to get the visits, or **DEFAULT** keyword for default domain.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "startDate",
"in": "query",
"description": "The date (in ISO-8601 format) from which we want to get visits.",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "endDate",
"in": "query",
"description": "The date (in ISO-8601 format) until which we want to get visits.",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "page",
"in": "query",
"description": "The page to display. Defaults to 1",
"required": false,
"schema": {
"type": "number"
}
},
{
"name": "itemsPerPage",
"in": "query",
"description": "The amount of items to return on every page. Defaults to all the items",
"required": false,
"schema": {
"type": "number"
}
},
{
"name": "excludeBots",
"in": "query",
"description": "Tells if visits from potential bots should be excluded from the result set",
"required": false,
"schema": {
"type": "string",
"enum": ["true"]
}
}
],
"security": [
{
"ApiKey": []
}
],
"responses": {
"200": {
"description": "List of visits.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"visits": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "../definitions/Visit.json"
}
},
"pagination": {
"$ref": "../definitions/Pagination.json"
}
}
}
}
},
"example": {
"visits": {
"data": [
{
"referer": "https://twitter.com",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
"visitLocation": null,
"potentialBot": false
},
{
"referer": "https://t.co",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
"visitLocation": {
"cityName": "Cupertino",
"countryCode": "US",
"countryName": "United States",
"latitude": 37.3042,
"longitude": -122.0946,
"regionName": "California",
"timezone": "America/Los_Angeles"
},
"potentialBot": false
},
{
"referer": null,
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "some_web_crawler/1.4",
"visitLocation": null,
"potentialBot": true
}
],
"pagination": {
"currentPage": 5,
"pagesCount": 12,
"itemsPerPage": 10,
"itemsInCurrentPage": 10,
"totalItems": 115
}
}
}
}
}
},
"404": {
"description": "The domain does not exist.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
},
"example": {
"detail": "Domain with authority \"example.com\" could not be found",
"title": "Domain not found",
"type": "DOMAIN_NOT_FOUND",
"status": 404,
"authority": "example.com"
}
}
}
},
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View file

@ -95,6 +95,9 @@
"/rest/v{version}/tags/{tag}/visits": {
"$ref": "paths/v2_tags_{tag}_visits.json"
},
"/rest/v{version}/domains/{domain}/visits": {
"$ref": "paths/v2_domains_{domain}_visits.json"
},
"/rest/v{version}/visits/orphan": {
"$ref": "paths/v2_visits_orphan.json"
},

View file

@ -2,13 +2,15 @@
declare(strict_types=1);
use const Shlinkio\Shlink\MIGRATIONS_TABLE;
return [
'migrations_paths' => [
'ShlinkMigrations' => 'data/migrations',
],
'table_storage' => [
'table_name' => 'migrations',
'table_name' => MIGRATIONS_TABLE,
],
'custom_template' => 'data/migrations_template.txt',

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Db;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\SqlitePlatform;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
use Symfony\Component\Console\Input\InputInterface;
@ -14,6 +15,9 @@ use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Process\PhpExecutableFinder;
use function Functional\contains;
use function Functional\filter;
use const Shlinkio\Shlink\MIGRATIONS_TABLE;
class CreateDatabaseCommand extends AbstractDatabaseCommand
{
@ -35,6 +39,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
{
$this
->setName(self::NAME)
->setHidden()
->setDescription(
'Creates the database needed for shlink to work. It will do nothing if the database already exists',
);
@ -61,7 +66,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
private function checkDbExists(): void
{
if ($this->regularConn->getDatabasePlatform()->getName() === 'sqlite') {
if ($this->regularConn->getDriver()->getDatabasePlatform() instanceof SqlitePlatform) {
return;
}
@ -69,7 +74,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
// Otherwise, it will fail to connect and will not be able to create the new database
$schemaManager = $this->noDbNameConn->createSchemaManager();
$databases = $schemaManager->listDatabases();
$shlinkDatabase = $this->regularConn->getDatabase();
$shlinkDatabase = $this->regularConn->getParams()['dbname'] ?? null;
if ($shlinkDatabase !== null && ! contains($databases, $shlinkDatabase)) {
$schemaManager->createDatabase($shlinkDatabase);
@ -79,8 +84,9 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
private function schemaExists(): bool
{
// If at least one of the shlink tables exist, we will consider the database exists somehow.
// Any inconsistency should be taken care by the migrations
// We exclude the migrations table, in case db:migrate was run first by mistake.
// Any other inconsistency will be taken care by the migrations.
$schemaManager = $this->regularConn->createSchemaManager();
return ! empty($schemaManager->listTableNames());
return ! empty(filter($schemaManager->listTableNames(), fn (string $table) => $table !== MIGRATIONS_TABLE));
}
}

View file

@ -19,6 +19,7 @@ class MigrateDatabaseCommand extends AbstractDatabaseCommand
{
$this
->setName(self::NAME)
->setHidden()
->setDescription('Runs database migrations, which will ensure the shlink database is up to date.');
}

View file

@ -209,7 +209,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
}
if ($input->getOption('show-api-key')) {
$columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string =>
(string) $shortUrl->authorApiKey();
$shortUrl->authorApiKey()?->__toString() ?? '';
}
if ($input->getOption('show-api-key-name')) {
$columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): ?string =>

View file

@ -13,7 +13,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
{
private bool $olderDbExists;
private function __construct(string $message, int $code = 0, ?Throwable $previous = null)
private function __construct(string $message, int $code, ?Throwable $previous)
{
parent::__construct($message, $code, $previous);
}
@ -47,7 +47,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
$e = new self(sprintf(
'Build epoch with value "%s" from existing geolocation database, could not be parsed to integer.',
$buildEpoch,
));
), 0, null);
$e->olderDbExists = true;
return $e;

View file

@ -66,9 +66,8 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
{
$buildTimestamp = $this->resolveBuildTimestamp($meta);
$buildDate = Chronos::createFromTimestamp($buildTimestamp);
$now = Chronos::now();
return $now->gt($buildDate->addDays(35));
return Chronos::now()->gt($buildDate->addDays(35));
}
private function resolveBuildTimestamp(Metadata $meta): int

View file

@ -5,7 +5,9 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Db;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\SqlitePlatform;
use Doctrine\DBAL\Schema\AbstractSchemaManager;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
@ -19,6 +21,8 @@ use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\Process\PhpExecutableFinder;
use const Shlinkio\Shlink\MIGRATIONS_TABLE;
class CreateDatabaseCommandTest extends TestCase
{
use CliTestUtilsTrait;
@ -27,7 +31,7 @@ class CreateDatabaseCommandTest extends TestCase
private ObjectProphecy $processHelper;
private ObjectProphecy $regularConn;
private ObjectProphecy $schemaManager;
private ObjectProphecy $databasePlatform;
private ObjectProphecy $driver;
public function setUp(): void
{
@ -43,11 +47,12 @@ class CreateDatabaseCommandTest extends TestCase
$this->processHelper = $this->prophesize(ProcessRunnerInterface::class);
$this->schemaManager = $this->prophesize(AbstractSchemaManager::class);
$this->databasePlatform = $this->prophesize(AbstractPlatform::class);
$this->regularConn = $this->prophesize(Connection::class);
$this->regularConn->createSchemaManager()->willReturn($this->schemaManager->reveal());
$this->regularConn->getDatabasePlatform()->willReturn($this->databasePlatform->reveal());
$this->driver = $this->prophesize(Driver::class);
$this->regularConn->getDriver()->willReturn($this->driver->reveal());
$this->driver->getDatabasePlatform()->willReturn($this->prophesize(AbstractPlatform::class)->reveal());
$noDbNameConn = $this->prophesize(Connection::class);
$noDbNameConn->createSchemaManager()->willReturn($this->schemaManager->reveal());
@ -66,7 +71,7 @@ class CreateDatabaseCommandTest extends TestCase
public function successMessageIsPrintedIfDatabaseAlreadyExists(): void
{
$shlinkDatabase = 'shlink_database';
$getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase);
$getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]);
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', $shlinkDatabase, 'bar']);
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
});
@ -86,11 +91,11 @@ class CreateDatabaseCommandTest extends TestCase
public function databaseIsCreatedIfItDoesNotExist(): void
{
$shlinkDatabase = 'shlink_database';
$getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase);
$getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]);
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', 'bar']);
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
});
$listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table']);
$listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table', MIGRATIONS_TABLE]);
$this->commandTester->execute([]);
@ -100,15 +105,18 @@ class CreateDatabaseCommandTest extends TestCase
$listTables->shouldHaveBeenCalledOnce();
}
/** @test */
public function tablesAreCreatedIfDatabaseIsEmpty(): void
/**
* @test
* @dataProvider provideEmptyDatabase
*/
public function tablesAreCreatedIfDatabaseIsEmpty(array $tables): void
{
$shlinkDatabase = 'shlink_database';
$getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase);
$getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]);
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', $shlinkDatabase, 'bar']);
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
});
$listTables = $this->schemaManager->listTableNames()->willReturn([]);
$listTables = $this->schemaManager->listTableNames()->willReturn($tables);
$runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [
'/usr/local/bin/php',
CreateDatabaseCommand::DOCTRINE_SCRIPT,
@ -128,13 +136,19 @@ class CreateDatabaseCommandTest extends TestCase
$runCommand->shouldHaveBeenCalledOnce();
}
public function provideEmptyDatabase(): iterable
{
yield 'no tables' => [[]];
yield 'migrations table' => [[MIGRATIONS_TABLE]];
}
/** @test */
public function databaseCheckIsSkippedForSqlite(): void
{
$this->databasePlatform->getName()->willReturn('sqlite');
$this->driver->getDatabasePlatform()->willReturn($this->prophesize(SqlitePlatform::class)->reveal());
$shlinkDatabase = 'shlink_database';
$getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase);
$getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]);
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', 'bar']);
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
});

View file

@ -30,6 +30,7 @@ class GeolocationDbUpdaterTest extends TestCase
private ObjectProphecy $dbUpdater;
private ObjectProphecy $geoLiteDbReader;
private TrackingOptions $trackingOptions;
private ObjectProphecy $lock;
public function setUp(): void
{
@ -38,11 +39,11 @@ class GeolocationDbUpdaterTest extends TestCase
$this->trackingOptions = new TrackingOptions();
$locker = $this->prophesize(Lock\LockFactory::class);
$lock = $this->prophesize(Lock\LockInterface::class);
$lock->acquire(true)->willReturn(true);
$lock->release()->will(function (): void {
$this->lock = $this->prophesize(Lock\LockInterface::class);
$this->lock->acquire(true)->willReturn(true);
$this->lock->release()->will(function (): void {
});
$locker->createLock(Argument::type('string'))->willReturn($lock->reveal());
$locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal());
$this->geolocationDbUpdater = new GeolocationDbUpdater(
$this->dbUpdater->reveal(),
@ -75,6 +76,8 @@ class GeolocationDbUpdaterTest extends TestCase
$fileExists->shouldHaveBeenCalledOnce();
$getMeta->shouldNotHaveBeenCalled();
$download->shouldHaveBeenCalledOnce();
$this->lock->acquire(true)->shouldHaveBeenCalledOnce();
$this->lock->release()->shouldHaveBeenCalledOnce();
}
/**

View file

@ -12,6 +12,7 @@ use Laminas\InputFilter\InputFilter;
use PUGX\Shortid\Factory as ShortIdFactory;
use Shlinkio\Shlink\Common\Util\DateRange;
use function date_default_timezone_get;
use function Functional\reduce_left;
use function is_array;
use function print_r;
@ -32,7 +33,7 @@ function generateRandomShortCode(int $length): string
function parseDateFromQuery(array $query, string $dateName): ?Chronos
{
return empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName]);
return normalizeDate(empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName]));
}
function parseDateRangeFromQuery(array $query, string $startDateName, string $endDateName): DateRange
@ -43,29 +44,15 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en
return buildDateRange($startDate, $endDate);
}
function parseDateField(string|DateTimeInterface|Chronos|null $date): ?Chronos
function normalizeDate(string|DateTimeInterface|Chronos|null $date): ?Chronos
{
if ($date === null || $date instanceof Chronos) {
return $date;
}
$parsedDate = match (true) {
$date === null || $date instanceof Chronos => $date,
$date instanceof DateTimeInterface => Chronos::instance($date),
default => Chronos::parse($date),
};
if ($date instanceof DateTimeInterface) {
return Chronos::instance($date);
}
return Chronos::parse($date);
}
function determineTableName(string $tableName, array $emConfig = []): string
{
$schema = $emConfig['connection']['schema'] ?? null;
// $tablePrefix = $emConfig['connection']['table_prefix'] ?? null; // TODO
if ($schema === null) {
return $tableName;
}
return sprintf('%s.%s', $schema, $tableName);
return $parsedDate?->setTimezone(date_default_timezone_get());
}
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
@ -108,6 +95,18 @@ function isCrawler(string $userAgent): bool
return $detector->isCrawler($userAgent);
}
function determineTableName(string $tableName, array $emConfig = []): string
{
$schema = $emConfig['connection']['schema'] ?? null;
// $tablePrefix = $emConfig['connection']['table_prefix'] ?? null; // TODO
if ($schema === null) {
return $tableName;
}
return sprintf('%s.%s', $schema, $tableName);
}
function fieldWithUtf8Charset(FieldBuilder $field, array $emConfig, string $collation = 'unicode_ci'): FieldBuilder
{
return match ($emConfig['connection']['driver'] ?? null) {

View file

@ -13,7 +13,6 @@ class BasePathPrefixer
public function __invoke(array $config): array
{
$basePath = $config['router']['base_path'] ?? '';
$config['url_shortener']['domain']['hostname'] .= $basePath;
foreach (self::ELEMENTS_WITH_PATH as $configKey) {
$config[$configKey] = $this->prefixPathsWithBasePath($configKey, $config, $basePath);

View file

@ -12,7 +12,7 @@ use function array_values;
use function Functional\contains;
use function Shlinkio\Shlink\Config\env;
// TODO Convert to enum
// TODO Convert to enum after dropping PHP 8.0 support
/**
* @method static EnvVars DELETE_SHORT_URL_THRESHOLD()
@ -62,6 +62,7 @@ use function Shlinkio\Shlink\Config\env;
* @method static EnvVars DEFAULT_DOMAIN()
* @method static EnvVars AUTO_RESOLVE_TITLES()
* @method static EnvVars REDIRECT_APPEND_EXTRA_PATH()
* @method static EnvVars TIMEZONE()
* @method static EnvVars VISITS_WEBHOOKS()
* @method static EnvVars NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS()
*/
@ -114,7 +115,10 @@ final class EnvVars
public const DEFAULT_DOMAIN = 'DEFAULT_DOMAIN';
public const AUTO_RESOLVE_TITLES = 'AUTO_RESOLVE_TITLES';
public const REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH';
public const TIMEZONE = 'TIMEZONE';
/** @deprecated */
public const VISITS_WEBHOOKS = 'VISITS_WEBHOOKS';
/** @deprecated */
public const NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS = 'NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS';
/**

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain\Repository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Happyr\DoctrineSpecification\Spec;
use Shlinkio\Shlink\Core\Domain\Spec\IsDomain;
@ -40,8 +41,25 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain
{
$qb = $this->createQueryBuilder('d');
$qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d')
$qb = $this->createDomainQueryBuilder($authority, $apiKey);
$qb->select('d');
return $qb->getQuery()->getOneOrNullResult();
}
public function domainExists(string $authority, ?ApiKey $apiKey = null): bool
{
$qb = $this->createDomainQueryBuilder($authority, $apiKey);
$qb->select('COUNT(d.id)');
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
}
private function createDomainQueryBuilder(string $authority, ?ApiKey $apiKey): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(Domain::class, 'd')
->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d')
->where($qb->expr()->eq('d.authority', ':authority'))
->setParameter('authority', $authority)
->setMaxResults(1);
@ -51,7 +69,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
$this->applySpecification($qb, $spec, $alias);
}
return $qb->getQuery()->getOneOrNullResult();
return $qb;
}
private function determineExtraSpecs(?ApiKey $apiKey): iterable

View file

@ -17,4 +17,6 @@ interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificatio
public function findDomains(?ApiKey $apiKey = null): array;
public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain;
public function domainExists(string $authority, ?ApiKey $apiKey = null): bool;
}

View file

@ -21,6 +21,7 @@ use Throwable;
use function Functional\map;
/** @deprecated */
class NotifyVisitToWebHooks
{
public function __construct(

View file

@ -13,8 +13,11 @@ use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface;
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Importer\Params\ImportParams;
use Shlinkio\Shlink\Importer\Sources\ImportSources;
use Symfony\Component\Console\Style\OutputStyle;
use Symfony\Component\Console\Style\StyleInterface;
use Throwable;
use function sprintf;
@ -32,32 +35,36 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
}
/**
* @param iterable|ImportedShlinkUrl[] $shlinkUrls
* @param iterable<ImportedShlinkUrl> $shlinkUrls
*/
public function process(StyleInterface $io, iterable $shlinkUrls, array $params): void
public function process(StyleInterface $io, iterable $shlinkUrls, ImportParams $params): void
{
$importShortCodes = $params['import_short_codes'];
$source = $params['source'];
$importShortCodes = $params->importShortCodes();
$source = $params->source();
$iterable = $this->batchHelper->wrapIterable($shlinkUrls, $source === ImportSources::SHLINK ? 10 : 100);
/** @var ImportedShlinkUrl $importedUrl */
foreach ($iterable as $importedUrl) {
$skipOnShortCodeConflict = static function () use ($io, $importedUrl): bool {
$action = $io->choice(sprintf(
'Failed to import URL "%s" because its short-code "%s" is already in use. Do you want to generate '
. 'a new one or skip it?',
$importedUrl->longUrl(),
$importedUrl->shortCode(),
), ['Generate new short-code', 'Skip'], 1);
return $action === 'Skip';
};
$skipOnShortCodeConflict = static fn (): bool => $io->choice(sprintf(
'Failed to import URL "%s" because its short-code "%s" is already in use. Do you want to generate '
. 'a new one or skip it?',
$importedUrl->longUrl(),
$importedUrl->shortCode(),
), ['Generate new short-code', 'Skip'], 1) === 'Skip';
$longUrl = $importedUrl->longUrl();
try {
$shortUrlImporting = $this->resolveShortUrl($importedUrl, $importShortCodes, $skipOnShortCodeConflict);
} catch (NonUniqueSlugException) {
$io->text(sprintf('%s: <fg=red>Error</>', $longUrl));
continue;
} catch (Throwable $e) {
$io->text(sprintf('%s: <comment>Skipped</comment>. Reason: %s.', $longUrl, $e->getMessage()));
if ($io instanceof OutputStyle && $io->isVerbose()) {
$io->text($e->__toString());
}
continue;
}

View file

@ -29,7 +29,7 @@ final class ShortUrlImporting
}
/**
* @param iterable|ImportedShlinkVisit[] $visits
* @param iterable<ImportedShlinkVisit> $visits
*/
public function importVisits(iterable $visits, EntityManagerInterface $em): string
{

View file

@ -12,7 +12,7 @@ use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter;
use function array_key_exists;
use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
use function Shlinkio\Shlink\Core\parseDateField;
use function Shlinkio\Shlink\Core\normalizeDate;
final class ShortUrlEdit implements TitleResolutionModelInterface
{
@ -69,8 +69,8 @@ final class ShortUrlEdit implements TitleResolutionModelInterface
$this->forwardQueryPropWasProvided = array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data);
$this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL);
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
$this->validSince = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
$this->validUntil = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS);
$this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false;
$this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS);

View file

@ -12,7 +12,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
use function Shlinkio\Shlink\Core\parseDateField;
use function Shlinkio\Shlink\Core\normalizeDate;
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
@ -68,8 +68,8 @@ final class ShortUrlMeta implements TitleResolutionModelInterface
}
$this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL);
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
$this->validSince = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
$this->validUntil = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
$this->customSlug = $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG);
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS);
$this->findIfExists = $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS);

View file

@ -9,7 +9,7 @@ use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\parseDateField;
use function Shlinkio\Shlink\Core\normalizeDate;
final class ShortUrlsParams
{
@ -61,8 +61,8 @@ final class ShortUrlsParams
$this->searchTerm = $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM);
$this->tags = (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS);
$this->dateRange = buildDateRange(
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
normalizeDate($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
normalizeDate($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
);
$this->orderBy = Ordering::fromTuple($inputFilter->getValue(ShortUrlsParamsInputFilter::ORDER_BY));
$this->itemsPerPage = (int) (

View file

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Options;
use Laminas\Stdlib\AbstractOptions;
/** @deprecated */
class WebhookOptions extends AbstractOptions
{
protected $__strictMode__ = false; // phpcs:ignore

View file

@ -154,6 +154,47 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
return $qb;
}
/**
* @return Visit[]
*/
public function findVisitsByDomain(string $domain, VisitsListFiltering $filtering): array
{
$qb = $this->createVisitsByDomainQueryBuilder($domain, $filtering);
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset());
}
public function countVisitsByDomain(string $domain, VisitsCountFiltering $filtering): int
{
$qb = $this->createVisitsByDomainQueryBuilder($domain, $filtering);
$qb->select('COUNT(v.id)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
private function createVisitsByDomainQueryBuilder(string $domain, VisitsCountFiltering $filtering): QueryBuilder
{
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later.
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(Visit::class, 'v')
->join('v.shortUrl', 's');
if ($domain === 'DEFAULT') {
$qb->where($qb->expr()->isNull('s.domain'));
} else {
$qb->join('s.domain', 'd')
->where($qb->expr()->eq('d.authority', $this->getEntityManager()->getConnection()->quote($domain)));
}
if ($filtering->excludeBots()) {
$qb->andWhere($qb->expr()->eq('v.potentialBot', 'false'));
}
$this->applyDatesInline($qb, $filtering->dateRange());
$this->applySpecification($qb, $filtering->apiKey()?->inlinedSpec(), 'v');
return $qb;
}
public function findOrphanVisits(VisitsListFiltering $filtering): array
{
$qb = $this->createAllVisitsQueryBuilder($filtering);

View file

@ -45,6 +45,13 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification
public function countVisitsByTag(string $tag, VisitsCountFiltering $filtering): int;
/**
* @return Visit[]
*/
public function findVisitsByDomain(string $domain, VisitsListFiltering $filtering): array;
public function countVisitsByDomain(string $domain, VisitsCountFiltering $filtering): int;
/**
* @return Visit[]
*/

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class DomainVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{
public function __construct(
private VisitRepositoryInterface $visitRepository,
private string $domain,
private VisitsParams $params,
private ?ApiKey $apiKey,
) {
}
protected function doCount(): int
{
return $this->visitRepository->countVisitsByDomain(
$this->domain,
new VisitsCountFiltering(
$this->params->getDateRange(),
$this->params->excludeBots(),
$this->apiKey,
),
);
}
public function getSlice(int $offset, int $length): iterable
{
return $this->visitRepository->findVisitsByDomain(
$this->domain,
new VisitsListFiltering(
$this->params->getDateRange(),
$this->params->excludeBots(),
$this->apiKey,
$length,
$offset,
),
);
}
}

View file

@ -7,9 +7,12 @@ namespace Shlinkio\Shlink\Core\Visit;
use Doctrine\ORM\EntityManagerInterface;
use Pagerfanta\Adapter\AdapterInterface;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
@ -19,6 +22,7 @@ use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\DomainVisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\NonOrphanVisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\ShortUrlVisitsPaginatorAdapter;
@ -85,6 +89,24 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface
return $this->createPaginator(new TagVisitsPaginatorAdapter($repo, $tag, $params, $apiKey), $params);
}
/**
* @return Visit[]|Paginator
* @throws DomainNotFoundException
*/
public function visitsForDomain(string $domain, VisitsParams $params, ?ApiKey $apiKey = null): Paginator
{
/** @var DomainRepository $domainRepo */
$domainRepo = $this->em->getRepository(Domain::class);
if ($domain !== 'DEFAULT' && ! $domainRepo->domainExists($domain, $apiKey)) {
throw DomainNotFoundException::fromAuthority($domain);
}
/** @var VisitRepositoryInterface $repo */
$repo = $this->em->getRepository(Visit::class);
return $this->createPaginator(new DomainVisitsPaginatorAdapter($repo, $domain, $params, $apiKey), $params);
}
/**
* @return Visit[]|Paginator
*/

View file

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Visit;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
@ -33,6 +34,12 @@ interface VisitsStatsHelperInterface
*/
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
/**
* @return Visit[]|Paginator
* @throws DomainNotFoundException
*/
public function visitsForDomain(string $domain, VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
/**
* @return Visit[]|Paginator
*/

View file

@ -55,6 +55,10 @@ class DomainRepositoryTest extends DatabaseTestCase
self::assertEquals($detachedWithRedirects, $this->repo->findOneByAuthority('detached-with-redirects.com'));
self::assertNull($this->repo->findOneByAuthority('does-not-exist.com'));
self::assertEquals($detachedDomain, $this->repo->findOneByAuthority('detached.com'));
self::assertTrue($this->repo->domainExists('bar.com'));
self::assertTrue($this->repo->domainExists('detached-with-redirects.com'));
self::assertFalse($this->repo->domainExists('does-not-exist.com'));
self::assertTrue($this->repo->domainExists('detached.com'));
}
/** @test */
@ -115,6 +119,12 @@ class DomainRepositoryTest extends DatabaseTestCase
$this->repo->findOneByAuthority('detached-with-redirects.com', $detachedWithRedirectsApiKey),
);
self::assertNull($this->repo->findOneByAuthority('foo.com', $detachedWithRedirectsApiKey));
self::assertTrue($this->repo->domainExists('foo.com', $authorApiKey));
self::assertFalse($this->repo->domainExists('bar.com', $authorApiKey));
self::assertTrue($this->repo->domainExists('bar.com', $barDomainApiKey));
self::assertTrue($this->repo->domainExists('detached-with-redirects.com', $detachedWithRedirectsApiKey));
self::assertFalse($this->repo->domainExists('foo.com', $detachedWithRedirectsApiKey));
}
private function createShortUrl(Domain $domain, ?ApiKey $apiKey = null): ShortUrl

View file

@ -52,7 +52,7 @@ class VisitRepositoryTest extends DatabaseTestCase
{
$shortUrl = ShortUrl::createEmpty();
$this->getEntityManager()->persist($shortUrl);
$countIterable = function (iterable $results): int {
$countIterable = static function (iterable $results): int {
$resultsCount = 0;
foreach ($results as $value) {
$resultsCount++;
@ -256,6 +256,54 @@ class VisitRepositoryTest extends DatabaseTestCase
)));
}
/** @test */
public function findVisitsByDomainReturnsProperData(): void
{
$this->createShortUrlsAndVisits('doma.in');
$this->getEntityManager()->flush();
self::assertCount(0, $this->repo->findVisitsByDomain('invalid', new VisitsListFiltering()));
self::assertCount(6, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering()));
self::assertCount(3, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering()));
self::assertCount(1, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering(null, true)));
self::assertCount(2, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering(
DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')),
)));
self::assertCount(1, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering(
DateRange::withStartDate(Chronos::parse('2016-01-03')),
)));
self::assertCount(2, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering(
DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')),
)));
self::assertCount(4, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering(
DateRange::withStartDate(Chronos::parse('2016-01-03')),
)));
}
/** @test */
public function countVisitsByDomainReturnsProperData(): void
{
$this->createShortUrlsAndVisits('doma.in');
$this->getEntityManager()->flush();
self::assertEquals(0, $this->repo->countVisitsByDomain('invalid', new VisitsListFiltering()));
self::assertEquals(6, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering()));
self::assertEquals(3, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering()));
self::assertEquals(1, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering(null, true)));
self::assertEquals(2, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering(
DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')),
)));
self::assertEquals(1, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering(
DateRange::withStartDate(Chronos::parse('2016-01-03')),
)));
self::assertEquals(2, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering(
DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')),
)));
self::assertEquals(4, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering(
DateRange::withStartDate(Chronos::parse('2016-01-03')),
)));
}
/** @test */
public function countVisitsReturnsExpectedResultBasedOnApiKey(): void
{

View file

@ -24,42 +24,16 @@ class BasePathPrefixerTest extends TestCase
array $originalConfig,
array $expectedRoutes,
array $expectedMiddlewares,
string $expectedHostname,
): void {
[
'routes' => $routes,
'middleware_pipeline' => $middlewares,
'url_shortener' => $urlShortener,
] = ($this->prefixer)($originalConfig);
['routes' => $routes, 'middleware_pipeline' => $middlewares] = ($this->prefixer)($originalConfig);
self::assertEquals($expectedRoutes, $routes);
self::assertEquals($expectedMiddlewares, $middlewares);
self::assertEquals([
'domain' => [
'hostname' => $expectedHostname,
],
], $urlShortener);
}
public function provideConfig(): iterable
{
$urlShortener = [
'domain' => [
'hostname' => null,
],
];
yield 'without anything' => [['url_shortener' => $urlShortener], [], [], ''];
yield 'with empty options' => [
[
'routes' => [],
'middleware_pipeline' => [],
'url_shortener' => $urlShortener,
],
[],
[],
'',
];
yield 'with empty options' => [['routes' => []], [], []];
yield 'with non-empty options' => [
[
'routes' => [
@ -70,11 +44,6 @@ class BasePathPrefixerTest extends TestCase
['with' => 'no_path'],
['path' => '/rest', 'middleware' => []],
],
'url_shortener' => [
'domain' => [
'hostname' => 'doma.in',
],
],
'router' => ['base_path' => '/foo/bar'],
],
[
@ -85,7 +54,6 @@ class BasePathPrefixerTest extends TestCase
['with' => 'no_path'],
['path' => '/foo/bar/rest', 'middleware' => []],
],
'doma.in/foo/bar',
];
}
}

View file

@ -77,6 +77,7 @@ class EnvVarsTest extends TestCase
EnvVars::DEFAULT_DOMAIN,
EnvVars::AUTO_RESOLVE_TITLES,
EnvVars::REDIRECT_APPEND_EXTRA_PATH,
EnvVars::TIMEZONE,
EnvVars::VISITS_WEBHOOKS,
EnvVars::NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS,
], $list);

View file

@ -11,6 +11,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use RuntimeException;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Importer\ImportedLinksProcessor;
@ -20,6 +21,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver;
use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit;
use Shlinkio\Shlink\Importer\Params\ImportParams;
use Shlinkio\Shlink\Importer\Sources\ImportSources;
use Symfony\Component\Console\Style\StyleInterface;
@ -32,8 +34,6 @@ class ImportedLinksProcessorTest extends TestCase
{
use ProphecyTrait;
private const PARAMS = ['import_short_codes' => true, 'source' => ImportSources::BITLY];
private ImportedLinksProcessor $processor;
private ObjectProphecy $em;
private ObjectProphecy $shortCodeHelper;
@ -74,7 +74,7 @@ class ImportedLinksProcessorTest extends TestCase
$ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true);
$persist = $this->em->persist(Argument::type(ShortUrl::class));
$this->processor->process($this->io->reveal(), $urls, self::PARAMS);
$this->processor->process($this->io->reveal(), $urls, $this->buildParams());
$importedUrlExists->shouldHaveBeenCalledTimes($expectedCalls);
$ensureUniqueness->shouldHaveBeenCalledTimes($expectedCalls);
@ -82,6 +82,37 @@ class ImportedLinksProcessorTest extends TestCase
$this->io->text(Argument::type('string'))->shouldHaveBeenCalledTimes($expectedCalls);
}
/** @test */
public function newUrlsWithErrorsAreSkipped(): void
{
$urls = [
new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo', null),
new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar', 'foo'),
new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz', null),
];
$importedUrlExists = $this->repo->findOneByImportedUrl(Argument::cetera())->willReturn(null);
$ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true);
$persist = $this->em->persist(Argument::type(ShortUrl::class))->will(function (array $args): void {
/** @var ShortUrl $shortUrl */
[$shortUrl] = $args;
if ($shortUrl->getShortCode() === 'baz') {
throw new RuntimeException('Whatever error');
}
});
$this->processor->process($this->io->reveal(), $urls, $this->buildParams());
$importedUrlExists->shouldHaveBeenCalledTimes(3);
$ensureUniqueness->shouldHaveBeenCalledTimes(3);
$persist->shouldHaveBeenCalledTimes(3);
$this->io->text(Argument::containingString('<info>Imported</info>'))->shouldHaveBeenCalledTimes(2);
$this->io->text(
Argument::containingString('<comment>Skipped</comment>. Reason: Whatever error'),
)->shouldHaveBeenCalledOnce();
}
/** @test */
public function alreadyImportedUrlsAreSkipped(): void
{
@ -104,7 +135,7 @@ class ImportedLinksProcessorTest extends TestCase
$ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true);
$persist = $this->em->persist(Argument::type(ShortUrl::class));
$this->processor->process($this->io->reveal(), $urls, self::PARAMS);
$this->processor->process($this->io->reveal(), $urls, $this->buildParams());
$importedUrlExists->shouldHaveBeenCalledTimes(count($urls));
$ensureUniqueness->shouldHaveBeenCalledTimes(2);
@ -141,7 +172,7 @@ class ImportedLinksProcessorTest extends TestCase
});
$persist = $this->em->persist(Argument::type(ShortUrl::class));
$this->processor->process($this->io->reveal(), $urls, self::PARAMS);
$this->processor->process($this->io->reveal(), $urls, $this->buildParams());
$importedUrlExists->shouldHaveBeenCalledTimes(count($urls));
$failingEnsureUniqueness->shouldHaveBeenCalledTimes(5);
@ -167,7 +198,7 @@ class ImportedLinksProcessorTest extends TestCase
$persistUrl = $this->em->persist(Argument::type(ShortUrl::class));
$persistVisits = $this->em->persist(Argument::type(Visit::class));
$this->processor->process($this->io->reveal(), [$importedUrl], self::PARAMS);
$this->processor->process($this->io->reveal(), [$importedUrl], $this->buildParams());
$findExisting->shouldHaveBeenCalledOnce();
$ensureUniqueness->shouldHaveBeenCalledTimes($foundShortUrl === null ? 1 : 0);
@ -214,4 +245,12 @@ class ImportedLinksProcessorTest extends TestCase
])),
];
}
private function buildParams(): ImportParams
{
return ImportParams::fromSourceAndCallableMap(
ImportSources::BITLY,
['import_short_codes' => static fn () => true],
);
}
}

View file

@ -10,9 +10,12 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
@ -158,6 +161,69 @@ class VisitsStatsHelperTest extends TestCase
$getRepo->shouldHaveBeenCalledOnce();
}
/** @test */
public function throwsExceptionWhenRequestingVisitsForInvalidDomain(): void
{
$domain = 'foo.com';
$apiKey = ApiKey::create();
$repo = $this->prophesize(DomainRepository::class);
$domainExists = $repo->domainExists($domain, $apiKey)->willReturn(false);
$getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal());
$this->expectException(DomainNotFoundException::class);
$domainExists->shouldBeCalledOnce();
$getRepo->shouldBeCalledOnce();
$this->helper->visitsForDomain($domain, new VisitsParams(), $apiKey);
}
/**
* @test
* @dataProvider provideAdminApiKeys
*/
public function visitsForNonDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): void
{
$domain = 'foo.com';
$repo = $this->prophesize(DomainRepository::class);
$domainExists = $repo->domainExists($domain, $apiKey)->willReturn(true);
$getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal());
$list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()));
$repo2 = $this->prophesize(VisitRepository::class);
$repo2->findVisitsByDomain($domain, Argument::type(VisitsListFiltering::class))->willReturn($list);
$repo2->countVisitsByDomain($domain, Argument::type(VisitsCountFiltering::class))->willReturn(1);
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
$paginator = $this->helper->visitsForDomain($domain, new VisitsParams(), $apiKey);
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults()));
$domainExists->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
}
/**
* @test
* @dataProvider provideAdminApiKeys
*/
public function visitsForDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): void
{
$repo = $this->prophesize(DomainRepository::class);
$domainExists = $repo->domainExists(Argument::cetera());
$getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal());
$list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()));
$repo2 = $this->prophesize(VisitRepository::class);
$repo2->findVisitsByDomain('DEFAULT', Argument::type(VisitsListFiltering::class))->willReturn($list);
$repo2->countVisitsByDomain('DEFAULT', Argument::type(VisitsCountFiltering::class))->willReturn(1);
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
$paginator = $this->helper->visitsForDomain('DEFAULT', new VisitsParams(), $apiKey);
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults()));
$domainExists->shouldNotHaveBeenCalled();
$getRepo->shouldHaveBeenCalledOnce();
}
/** @test */
public function orphanVisitsAreReturnedAsExpected(): void
{

View file

@ -6,7 +6,9 @@ namespace Shlinkio\Shlink\Rest;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Mezzio\ProblemDetails\ProblemDetailsResponseFactory;
use Mezzio\Router\Middleware\ImplicitOptionsMiddleware;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Options;
@ -32,6 +34,7 @@ return [
Action\ShortUrl\ListShortUrlsAction::class => ConfigAbstractFactory::class,
Action\Visit\ShortUrlVisitsAction::class => ConfigAbstractFactory::class,
Action\Visit\TagVisitsAction::class => ConfigAbstractFactory::class,
Action\Visit\DomainVisitsAction::class => ConfigAbstractFactory::class,
Action\Visit\GlobalVisitsAction::class => ConfigAbstractFactory::class,
Action\Visit\OrphanVisitsAction::class => ConfigAbstractFactory::class,
Action\Visit\NonOrphanVisitsAction::class => ConfigAbstractFactory::class,
@ -49,6 +52,7 @@ return [
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ConfigAbstractFactory::class,
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => ConfigAbstractFactory::class,
Middleware\ShortUrl\OverrideDomainMiddleware::class => ConfigAbstractFactory::class,
Middleware\Mercure\NotConfiguredMercureErrorHandler::class => ConfigAbstractFactory::class,
],
],
@ -70,6 +74,10 @@ return [
],
Action\Visit\ShortUrlVisitsAction::class => [Visit\VisitsStatsHelper::class],
Action\Visit\TagVisitsAction::class => [Visit\VisitsStatsHelper::class],
Action\Visit\DomainVisitsAction::class => [
Visit\VisitsStatsHelper::class,
'config.url_shortener.domain.hostname',
],
Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class],
Action\Visit\OrphanVisitsAction::class => [
Visit\VisitsStatsHelper::class,
@ -90,6 +98,10 @@ return [
'config.url_shortener.default_short_codes_length',
],
Middleware\ShortUrl\OverrideDomainMiddleware::class => [DomainService::class],
Middleware\Mercure\NotConfiguredMercureErrorHandler::class => [
ProblemDetailsResponseFactory::class,
LoggerInterface::class,
],
],
];

View file

@ -4,49 +4,54 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest;
$contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class;
$dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class;
$overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class;
use Shlinkio\Shlink\Rest\Middleware\Mercure\NotConfiguredMercureErrorHandler;
return [
return (static function (): array {
$contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class;
$dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class;
$overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class;
'routes' => [
Action\HealthAction::getRouteDef(),
return [
// Short URLs
Action\ShortUrl\CreateShortUrlAction::getRouteDef([
$contentNegotiationMiddleware,
$dropDomainMiddleware,
$overrideDomainMiddleware,
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class,
]),
Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([
$contentNegotiationMiddleware,
$overrideDomainMiddleware,
]),
Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\ListShortUrlsAction::getRouteDef(),
'routes' => [
Action\HealthAction::getRouteDef(),
// Visits
Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
Action\Visit\TagVisitsAction::getRouteDef(),
Action\Visit\GlobalVisitsAction::getRouteDef(),
Action\Visit\OrphanVisitsAction::getRouteDef(),
Action\Visit\NonOrphanVisitsAction::getRouteDef(),
// Short URLs
Action\ShortUrl\CreateShortUrlAction::getRouteDef([
$contentNegotiationMiddleware,
$dropDomainMiddleware,
$overrideDomainMiddleware,
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class,
]),
Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([
$contentNegotiationMiddleware,
$overrideDomainMiddleware,
]),
Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\ListShortUrlsAction::getRouteDef(),
// Tags
Action\Tag\ListTagsAction::getRouteDef(),
Action\Tag\TagsStatsAction::getRouteDef(),
Action\Tag\DeleteTagsAction::getRouteDef(),
Action\Tag\UpdateTagAction::getRouteDef(),
// Visits
Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
Action\Visit\TagVisitsAction::getRouteDef(),
Action\Visit\DomainVisitsAction::getRouteDef(),
Action\Visit\GlobalVisitsAction::getRouteDef(),
Action\Visit\OrphanVisitsAction::getRouteDef(),
Action\Visit\NonOrphanVisitsAction::getRouteDef(),
// Domains
Action\Domain\ListDomainsAction::getRouteDef(),
Action\Domain\DomainRedirectsAction::getRouteDef(),
// Tags
Action\Tag\ListTagsAction::getRouteDef(),
Action\Tag\TagsStatsAction::getRouteDef(),
Action\Tag\DeleteTagsAction::getRouteDef(),
Action\Tag\UpdateTagAction::getRouteDef(),
Action\MercureInfoAction::getRouteDef(),
],
// Domains
Action\Domain\ListDomainsAction::getRouteDef(),
Action\Domain\DomainRedirectsAction::getRouteDef(),
];
Action\MercureInfoAction::getRouteDef([NotConfiguredMercureErrorHandler::class]),
],
];
})();

View file

@ -10,7 +10,6 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface;
use Shlinkio\Shlink\Rest\Exception\MercureException;
use Throwable;
use function sprintf;
@ -32,12 +31,7 @@ class MercureInfoAction extends AbstractRestAction
$days = $this->mercureConfig['jwt_days_duration'] ?? 1;
$expiresAt = Chronos::now()->addDays($days);
try {
$jwt = $this->jwtProvider->buildSubscriptionToken($expiresAt);
} catch (Throwable $e) {
throw MercureException::mercureNotConfigured($e);
}
$jwt = $this->jwtProvider->buildSubscriptionToken($expiresAt);
return new JsonResponse([
'mercureHubUrl' => sprintf('%s/.well-known/mercure', $hubUrl),

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\Visit;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
class DomainVisitsAction extends AbstractRestAction
{
use PagerfantaUtilsTrait;
protected const ROUTE_PATH = '/domains/{domain}/visits';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
public function __construct(private VisitsStatsHelperInterface $visitsHelper, private string $defaultDomain)
{
}
public function handle(Request $request): Response
{
$domain = $this->resolveDomainParam($request);
$params = VisitsParams::fromRawData($request->getQueryParams());
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
$visits = $this->visitsHelper->visitsForDomain($domain, $params, $apiKey);
return new JsonResponse([
'visits' => $this->serializePaginator($visits),
]);
}
private function resolveDomainParam(Request $request): string
{
$domainParam = $request->getAttribute('domain', '');
if ($domainParam === $this->defaultDomain) {
return 'DEFAULT';
}
return $domainParam;
}
}

View file

@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use Throwable;
class MercureException extends RuntimeException implements ProblemDetailsExceptionInterface
{
@ -16,9 +15,9 @@ class MercureException extends RuntimeException implements ProblemDetailsExcepti
private const TITLE = 'Mercure integration not configured';
private const TYPE = 'MERCURE_NOT_CONFIGURED';
public static function mercureNotConfigured(?Throwable $prev = null): self
public static function mercureNotConfigured(): self
{
$e = new self('This Shlink instance is not integrated with a mercure hub.', 1, $prev);
$e = new self('This Shlink instance is not integrated with a mercure hub.');
$e->detail = $e->getMessage();
$e->title = self::TITLE;

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Middleware\Mercure;
use Mezzio\ProblemDetails\ProblemDetailsResponseFactory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Rest\Exception\MercureException;
class NotConfiguredMercureErrorHandler implements MiddlewareInterface
{
public function __construct(private ProblemDetailsResponseFactory $respFactory, private LoggerInterface $logger)
{
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
try {
return $handler->handle($request);
} catch (MercureException $e) {
// Throwing this kind of exception makes a big error trace to be logged, for anyone who has decided to not
// use mercure.
// It happens every time the shlink-web-client is opened, so this mitigates the problem by just logging a
// simple warning, and casting the exception to a response on the fly.
$this->logger->warning($e->getMessage());
return $this->respFactory->createResponseFromThrowable($request, $e);
}
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Action;
use GuzzleHttp\RequestOptions;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use function sprintf;
class DomainVisitsTest extends ApiTestCase
{
/**
* @test
* @dataProvider provideDomains
*/
public function expectedVisitsAreReturned(
string $apiKey,
string $domain,
bool $excludeBots,
int $expectedVisitsAmount,
): void {
$resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/domains/%s/visits', $domain), [
RequestOptions::QUERY => $excludeBots ? ['excludeBots' => true] : [],
], $apiKey);
$payload = $this->getJsonResponsePayload($resp);
self::assertEquals(self::STATUS_OK, $resp->getStatusCode());
self::assertArrayHasKey('visits', $payload);
self::assertArrayHasKey('data', $payload['visits']);
self::assertCount($expectedVisitsAmount, $payload['visits']['data']);
}
public function provideDomains(): iterable
{
yield 'example.com with admin API key' => ['valid_api_key', 'example.com', false, 0];
yield 'DEFAULT with admin API key' => ['valid_api_key', 'DEFAULT', false, 7];
yield 'DEFAULT with admin API key and no bots' => ['valid_api_key', 'DEFAULT', true, 6];
yield 'DEFAULT with domain API key' => ['domain_api_key', 'DEFAULT', false, 0];
yield 'DEFAULT with author API key' => ['author_api_key', 'DEFAULT', false, 5];
yield 'DEFAULT with author API key and no bots' => ['author_api_key', 'DEFAULT', true, 4];
}
/**
* @test
* @dataProvider provideApiKeysAndTags
*/
public function notFoundErrorIsReturnedForInvalidTags(string $apiKey, string $domain): void
{
$resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/domains/%s/visits', $domain), [], $apiKey);
$payload = $this->getJsonResponsePayload($resp);
self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']);
self::assertEquals('DOMAIN_NOT_FOUND', $payload['type']);
self::assertEquals(sprintf('Domain with authority "%s" could not be found', $domain), $payload['detail']);
self::assertEquals('Domain not found', $payload['title']);
self::assertEquals($domain, $payload['authority']);
}
public function provideApiKeysAndTags(): iterable
{
yield 'admin API key with invalid domain' => ['valid_api_key', 'invalid_domain.com'];
yield 'domain API key with not-owned valid domain' => ['domain_api_key', 'this_domain_is_detached.com'];
yield 'author API key with valid domain not used in URLs' => ['author_api_key', 'this_domain_is_detached.com'];
}
}

View file

@ -11,7 +11,6 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use RuntimeException;
use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface;
use Shlinkio\Shlink\Rest\Action\MercureInfoAction;
use Shlinkio\Shlink\Rest\Exception\MercureException;
@ -49,24 +48,6 @@ class MercureInfoActionTest extends TestCase
yield 'host is null' => [['public_hub_url' => null]];
}
/**
* @test
* @dataProvider provideValidConfigs
*/
public function throwsExceptionWhenBuildingTokenFails(array $mercureConfig): void
{
$buildToken = $this->provider->buildSubscriptionToken(Argument::any())->willThrow(
new RuntimeException('Error'),
);
$action = new MercureInfoAction($this->provider->reveal(), $mercureConfig);
$this->expectException(MercureException::class);
$buildToken->shouldBeCalledOnce();
$action->handle(ServerRequestFactory::fromGlobals());
}
public function provideValidConfigs(): iterable
{
yield 'days not defined' => [['public_hub_url' => 'http://foobar.com']];

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action\Visit;
use Laminas\Diactoros\ServerRequestFactory;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\Rest\Action\Visit\DomainVisitsAction;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class DomainVisitsActionTest extends TestCase
{
use ProphecyTrait;
private DomainVisitsAction $action;
private ObjectProphecy $visitsHelper;
protected function setUp(): void
{
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$this->action = new DomainVisitsAction($this->visitsHelper->reveal(), 'the_default.com');
}
/**
* @test
* @dataProvider provideDomainAuthorities
*/
public function providingCorrectDomainReturnsVisits(string $providedDomain, string $expectedDomain): void
{
$apiKey = ApiKey::create();
$getVisits = $this->visitsHelper->visitsForDomain(
$expectedDomain,
Argument::type(VisitsParams::class),
$apiKey,
)->willReturn(new Paginator(new ArrayAdapter([])));
$response = $this->action->handle(
ServerRequestFactory::fromGlobals()->withAttribute('domain', $providedDomain)
->withAttribute(ApiKey::class, $apiKey),
);
self::assertEquals(200, $response->getStatusCode());
$getVisits->shouldHaveBeenCalledOnce();
}
public function provideDomainAuthorities(): iterable
{
yield 'no default domain' => ['foo.com', 'foo.com'];
yield 'default domain' => ['the_default.com', 'DEFAULT'];
yield 'DEFAULT keyword' => ['DEFAULT', 'DEFAULT'];
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Middleware\Mercure;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequestFactory;
use Mezzio\ProblemDetails\ProblemDetailsResponseFactory;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Rest\Exception\MercureException;
use Shlinkio\Shlink\Rest\Middleware\Mercure\NotConfiguredMercureErrorHandler;
class NotConfiguredMercureErrorHandlerTest extends TestCase
{
use ProphecyTrait;
private NotConfiguredMercureErrorHandler $middleware;
private ObjectProphecy $respFactory;
private ObjectProphecy $logger;
private ObjectProphecy $handler;
protected function setUp(): void
{
$this->respFactory = $this->prophesize(ProblemDetailsResponseFactory::class);
$this->logger = $this->prophesize(LoggerInterface::class);
$this->middleware = new NotConfiguredMercureErrorHandler($this->respFactory->reveal(), $this->logger->reveal());
$this->handler = $this->prophesize(RequestHandlerInterface::class);
}
/** @test */
public function requestHandlerIsInvokedWhenNotErrorOccurs(): void
{
$req = ServerRequestFactory::fromGlobals();
$handle = $this->handler->handle($req)->willReturn(new Response());
$this->middleware->process($req, $this->handler->reveal());
$handle->shouldHaveBeenCalledOnce();
$this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled();
$this->respFactory->createResponseFromThrowable(Argument::cetera())->shouldNotHaveBeenCalled();
}
/** @test */
public function exceptionIsParsedToResponse(): void
{
$req = ServerRequestFactory::fromGlobals();
$handle = $this->handler->handle($req)->willThrow(MercureException::mercureNotConfigured());
$createResp = $this->respFactory->createResponseFromThrowable(Argument::cetera())->willReturn(new Response());
$this->middleware->process($req, $this->handler->reveal());
$handle->shouldHaveBeenCalledOnce();
$createResp->shouldHaveBeenCalledOnce();
$this->logger->warning(Argument::cetera())->shouldHaveBeenCalledOnce();
}
}