mirror of
https://github.com/shlinkio/shlink.git
synced 2024-11-23 21:27:44 +03:00
commit
68e0aa1ea9
64 changed files with 1021 additions and 228 deletions
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
|
@ -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
|
||||
|
|
2
.github/workflows/publish-release.yml
vendored
2
.github/workflows/publish-release.yml
vendored
|
@ -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' }}
|
||||
|
|
2
.github/workflows/publish-swagger-spec.yml
vendored
2
.github/workflows/publish-swagger-spec.yml
vendored
|
@ -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
|
||||
|
|
31
CHANGELOG.md
31
CHANGELOG.md
|
@ -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*
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
11
config/autoload/app_options.local.php.dist
Normal file
11
config/autoload/app_options.local.php.dist
Normal file
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
'app_options' => [
|
||||
'version' => 'latest',
|
||||
],
|
||||
|
||||
];
|
|
@ -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,
|
||||
|
|
|
@ -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 [
|
||||
|
|
|
@ -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(''),
|
||||
],
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
172
docs/swagger/paths/v2_domains_{domain}_visits.json
Normal file
172
docs/swagger/paths/v2_domains_{domain}_visits.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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',
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.');
|
||||
}
|
||||
|
||||
|
|
|
@ -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 =>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ use Throwable;
|
|||
|
||||
use function Functional\map;
|
||||
|
||||
/** @deprecated */
|
||||
class NotifyVisitToWebHooks
|
||||
{
|
||||
public function __construct(
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -29,7 +29,7 @@ final class ShortUrlImporting
|
|||
}
|
||||
|
||||
/**
|
||||
* @param iterable|ImportedShlinkVisit[] $visits
|
||||
* @param iterable<ImportedShlinkVisit> $visits
|
||||
*/
|
||||
public function importVisits(iterable $visits, EntityManagerInterface $em): string
|
||||
{
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) (
|
||||
|
|
|
@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Options;
|
|||
|
||||
use Laminas\Stdlib\AbstractOptions;
|
||||
|
||||
/** @deprecated */
|
||||
class WebhookOptions extends AbstractOptions
|
||||
{
|
||||
protected $__strictMode__ = false; // phpcs:ignore
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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[]
|
||||
*/
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -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]),
|
||||
],
|
||||
|
||||
];
|
||||
})();
|
||||
|
|
|
@ -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),
|
||||
|
|
48
module/Rest/src/Action/Visit/DomainVisitsAction.php
Normal file
48
module/Rest/src/Action/Visit/DomainVisitsAction.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
68
module/Rest/test-api/Action/DomainVisitsTest.php
Normal file
68
module/Rest/test-api/Action/DomainVisitsTest.php
Normal 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'];
|
||||
}
|
||||
}
|
|
@ -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']];
|
||||
|
|
60
module/Rest/test/Action/Visit/DomainVisitsActionTest.php
Normal file
60
module/Rest/test/Action/Visit/DomainVisitsActionTest.php
Normal 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'];
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue