Compare commits

...

65 Commits

Author SHA1 Message Date
Alejandro Celaya
68e0aa1ea9 Merge pull request #1433 from shlinkio/develop
Release 3.1.0
2022-04-23 11:39:27 +02:00
Alejandro Celaya
ceaf64c9b3 Fixed typo in changelog 2022-04-23 11:35:55 +02:00
Alejandro Celaya
e015b1bec5 Improved changelog 2022-04-23 11:34:40 +02:00
Alejandro Celaya
ca9726c997 Merge pull request #1432 from acelaya-forks/feature/domain-visits
Feature/domain visits
2022-04-23 11:33:04 +02:00
Alejandro Celaya
85c79abd30 Updated changelog 2022-04-23 11:19:14 +02:00
Alejandro Celaya
54c1c7ad84 Created DomainVisits API test 2022-04-23 11:17:32 +02:00
Alejandro Celaya
af15e31b42 Created DomainVisitsActionTest 2022-04-23 11:07:10 +02:00
Alejandro Celaya
99b4f9f4dd Improved VisitsStatsHelperTest covering visitsForDomain method 2022-04-23 11:02:51 +02:00
Alejandro Celaya
9a0e5ea626 Created method to check if domain exists based on authority and API key 2022-04-23 10:58:33 +02:00
Alejandro Celaya
984205e02c Extended VisitRepositoryTest with domain visits functions 2022-04-23 10:45:42 +02:00
Alejandro Celaya
e11bf6ac67 Created endpoint to get visits for one specific domain 2022-04-23 10:32:07 +02:00
Alejandro Celaya
e029d91544 Documented new domain visits endpoint 2022-04-23 09:27:52 +02:00
Alejandro Celaya
011856cbfa Removed redundant var 2022-04-23 09:15:01 +02:00
Alejandro Celaya
9ce8164013 Merge pull request #1431 from acelaya-forks/feature/update-deps
Updated docker images and dependencies
2022-04-23 09:13:47 +02:00
Alejandro Celaya
dca6b7bbf5 Updated docker images and dependencies 2022-04-23 08:56:25 +02:00
Alejandro Celaya
2511ec3395 Merge pull request #1424 from acelaya-forks/feature/skip-invalid-imports
Added errorhandling for individual imported URLs, so that one failing…
2022-04-23 08:35:21 +02:00
Alejandro Celaya
9f6ffc7186 Added errorhandling for individual imported URLs, so that one failing doe snot make the whole process fail 2022-04-18 14:45:37 +02:00
Alejandro Celaya
622f0217fa Merge pull request #1421 from acelaya-forks/feature/cast-incoming-dates
Feature/cast incoming dates
2022-04-15 20:20:26 +02:00
Alejandro Celaya
09eba49bab Updated changelog 2022-04-15 20:06:51 +02:00
Alejandro Celaya
c20c3801a8 Ensured all input dates are changed to the default timezone before being used on any inner layer 2022-04-15 19:57:27 +02:00
Alejandro Celaya
f8208b7288 Merge pull request #1420 from acelaya-forks/feature/timezone
Feature/timezone
2022-04-15 09:27:17 +02:00
Alejandro Celaya
3db8a65ddb Fixed test 2022-04-14 16:00:15 +02:00
Alejandro Celaya
0495b6f298 Updated changelog 2022-04-14 14:24:01 +02:00
Alejandro Celaya
52c55f385d Added support to set the timezone via config/env vars 2022-04-14 14:22:48 +02:00
Alejandro Celaya
fe28d6fba0 Merge pull request #1417 from acelaya-forks/feature/kutt-import
Updated importer with support for Kutt.it
2022-04-14 12:51:56 +02:00
Alejandro Celaya
0294e49d4a Added dist local config for app options 2022-04-14 11:35:12 +02:00
Alejandro Celaya
cbaf51d3ef Updated importer with support for Kutt.it 2022-04-14 11:31:50 +02:00
Alejandro Celaya
efb604a381 Merge pull request #1415 from acelaya-forks/feature/yourls-domain
Feature/yourls domain
2022-04-13 12:54:12 +02:00
Alejandro Celaya
da87f05126 Updated changelog 2022-04-13 12:41:09 +02:00
Alejandro Celaya
21534b78cb Updated to latest shlink-importer, with support to import on a specific domain for YOURLS 2022-04-13 12:40:21 +02:00
Alejandro Celaya
ab65593f7d Merge pull request #1414 from acelaya-forks/feature/postgres-db-error
Feature/postgres db error
2022-04-12 19:27:35 +02:00
Alejandro Celaya
3a82691503 Small improvements on CreateDatabaseCommand 2022-04-10 19:48:32 +02:00
Alejandro Celaya
430e2ff0b5 Ensured db and api tests can be run without the need of creating the database beforehand 2022-04-09 17:46:13 +02:00
Alejandro Celaya
7d572e7988 Merge pull request #1404 from acelaya-forks/feature/fix-double-paths
Feature/fix double paths
2022-03-14 19:56:58 +01:00
Alejandro Celaya
1449e24b66 Improved some tests 2022-03-14 19:41:33 +01:00
Alejandro Celaya
6a671760da Updated changelog 2022-03-14 19:28:55 +01:00
Alejandro Celaya
613bdd82b0 Ensured base path is not prefixed more than it should 2022-03-14 19:26:02 +01:00
Alejandro Celaya
01bae358f9 Merge pull request #1399 from shlinkio/feature/improve-db-create
Feature/improve db create
2022-03-05 11:03:39 +01:00
Alejandro Celaya
3a8e560dc5 Increased required mutation score for unit tests to 85% 2022-03-05 10:51:48 +01:00
Alejandro Celaya
a0c538d9ee Updated changelog 2022-03-05 10:48:02 +01:00
Alejandro Celaya
07c30f86e9 Excluded migrations table when checking if the database schema exists 2022-03-05 10:41:13 +01:00
Alejandro Celaya
c22e38f9a0 Removed deprecated method call 2022-03-05 10:32:05 +01:00
Alejandro Celaya
7502e8a1e4 Merge pull request #1386 from acelaya-forks/feature/mercure-error
Feature/mercure error
2022-02-20 15:58:49 +01:00
Alejandro Celaya
5a25211371 Created NotConfiguredMercureErrorHandlerTest 2022-02-20 10:50:21 +01:00
Alejandro Celaya
6983f9b2bf Added middleware that mitigates big error traces being logged for those not using mercure 2022-02-20 10:36:54 +01:00
Alejandro Celaya
5affe64b61 Removed references in CONTRIBUTING.md file to no longer existing assets 2022-02-19 19:55:36 +01:00
Alejandro Celaya
c52f3c396b Fixed merge conflicts 2022-02-19 19:47:34 +01:00
Alejandro Celaya
e1ebbaa52f Merge pull request #1384 from acelaya-forks/feature/default-domain-role
Feature/default domain role
2022-02-19 19:42:12 +01:00
Alejandro Celaya
7abe6af5ec Updated changelog 2022-02-19 19:24:43 +01:00
Alejandro Celaya
c98ea6055b Ensured API keys cannot be generated with domain-only roles linked to default domain 2022-02-19 19:23:36 +01:00
Alejandro Celaya
3e3d255edf Merge pull request #1383 from acelaya-forks/feature/fixes
Updated docker images to PHP 8.1.3
2022-02-19 19:11:30 +01:00
Alejandro Celaya
816d4851e7 Updated docker images to PHP 8.1.3 2022-02-19 18:57:36 +01:00
Alejandro Celaya
79af315b9f Fixed merge conflicts 2022-02-10 21:48:21 +01:00
Alejandro Celaya
4110c702c0 Merge pull request #1376 from acelaya-forks/feature/release-3.0.2
Feature/release 3.0.2
2022-02-10 21:44:30 +01:00
Roy-Orbison
57eb29c3c8 Optimise RewriteRules/Conds
From upstream changes on Mezzio Skeleton.

Closes #1369.
2022-02-10 21:31:45 +01:00
Alejandro Celaya
5267c4eee6 Updated changelog 2022-02-10 21:31:30 +01:00
Alejandro Celaya
1453ebe8ca Updated to shlink-installer 7.0.1 2022-02-10 21:29:28 +01:00
Alejandro Celaya
3b5cea5768 Updated changelog 2022-02-07 18:49:22 +01:00
Roy-Orbison
a89f67348d Optimise RewriteRules/Conds
From upstream changes on Mezzio Skeleton.

Closes #1369.
2022-02-07 18:45:27 +01:00
Alejandro Celaya
af1ae0399c Fixed merge conflicts 2022-02-04 18:03:29 +01:00
Alejandro Celaya
ffffc68144 Updated readme 2022-02-01 07:36:44 +01:00
Alejandro Celaya
086de9f2a0 Merge pull request #1362 from acelaya-forks/feature/deprecate-webhooks
Deprecated webhooks
2022-01-31 12:41:44 +01:00
Alejandro Celaya
1b731aa4a3 Deprecated webhooks 2022-01-31 12:30:29 +01:00
Alejandro Celaya
12913f6b90 Merge pull request #1360 from acelaya-forks/feature/hide-db-commands
Marked database commands as hidden
2022-01-30 13:04:10 +01:00
Alejandro Celaya
1d4186392c Marked database commands as hidden 2022-01-30 12:15:53 +01:00
70 changed files with 1139 additions and 239 deletions

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,73 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [3.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*
### Changed
* [#1382](https://github.com/shlinkio/shlink/issues/1382) Updated docker image to PHP 8.1.3.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1377](https://github.com/shlinkio/shlink/issues/1377) Fixed installer always setting delete threshold with value 1.
* [#1379](https://github.com/shlinkio/shlink/issues/1379) Ensured API keys cannot be created with a domain-only role linked to default domain.
## [3.0.2] - 2022-02-10
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1373](https://github.com/shlinkio/shlink/issues/1373) Fixed incorrect config import when updating from Shlink 2.x using SQLite.
* [#1369](https://github.com/shlinkio/shlink/issues/1369) Fixed slow regexps in `.htaccess` file.
## [3.0.1] - 2022-02-04 ## [3.0.1] - 2022-02-04
### Added ### Added
* *Nothing* * *Nothing*

View File

@@ -46,9 +46,7 @@ This is a simplified version of the project structure:
``` ```
shlink shlink
├── bin ├── bin
── cli ── cli
│ ├── install
│ └── update
├── config ├── config
│ ├── autoload │ ├── autoload
│ ├── params │ ├── params
@@ -75,11 +73,11 @@ shlink
The purposes of every folder are: 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. * `config`: Contains application-wide configurations, which are later merged with the ones provided by every module.
* `data`: Common runtime-generated git-ignored assets, like logs, caches, etc. * `data`: Common runtime-generated git-ignored assets, like logs, caches, etc.
* `docs`: Any project documentation is stored here, like API spec definitions or architectural decision records. * `docs`: Any project documentation is stored here, like API spec definitions or architectural decision records.
* `module`: Contains a 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. * `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 ## 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` 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. * 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 ## 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. **Important!**: Before starting to work on a pull request, make sure you always [open an issue](https://github.com/shlinkio/shlink/issues/new/choose) first.

View File

@@ -1,8 +1,8 @@
FROM php:8.1.1-alpine3.15 as base FROM php:8.1.5-alpine3.15 as base
ARG SHLINK_VERSION=latest ARG SHLINK_VERSION=latest
ENV SHLINK_VERSION ${SHLINK_VERSION} 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 PDO_SQLSRV_VERSION 5.10.0
ENV MS_ODBC_SQL_VERSION 17.5.2.2 ENV MS_ODBC_SQL_VERSION 17.5.2.2
ENV LC_ALL "C" ENV LC_ALL "C"

View File

@@ -36,12 +36,13 @@ The idea is that you can just generate a container using the image and provide t
First, make sure the host where you are going to run shlink fulfills these requirements: First, make sure the host where you are going to run shlink fulfills these requirements:
* PHP 8.0 or 8.1 * 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. * 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. * 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. * sockets and bcmath extensions are required if you want to integrate with a RabbitMQ instance.
* MySQL, MariaDB, PostgreSQL, Microsoft SQL Server or SQLite. * MySQL, MariaDB, PostgreSQL, MicrosoftSQL or SQLite.
* [Openswoole](https://openswoole.com/) or the web server of your choice with PHP integration (Apache or Nginx recommended). * 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 ### Download

View File

@@ -28,7 +28,7 @@
"laminas/laminas-config-aggregator": "^1.7", "laminas/laminas-config-aggregator": "^1.7",
"laminas/laminas-diactoros": "^2.8", "laminas/laminas-diactoros": "^2.8",
"laminas/laminas-inputfilter": "^2.13", "laminas/laminas-inputfilter": "^2.13",
"laminas/laminas-servicemanager": "^3.10", "laminas/laminas-servicemanager": "^3.11.2",
"laminas/laminas-stdlib": "^3.6", "laminas/laminas-stdlib": "^3.6",
"lcobucci/jwt": "^4.1", "lcobucci/jwt": "^4.1",
"league/uri": "^6.4", "league/uri": "^6.4",
@@ -50,8 +50,8 @@
"shlinkio/shlink-common": "^4.4", "shlinkio/shlink-common": "^4.4",
"shlinkio/shlink-config": "^1.6", "shlinkio/shlink-config": "^1.6",
"shlinkio/shlink-event-dispatcher": "^2.3", "shlinkio/shlink-event-dispatcher": "^2.3",
"shlinkio/shlink-importer": "^2.5", "shlinkio/shlink-importer": "dev-main#af0e05e as 3.0",
"shlinkio/shlink-installer": "^7.0", "shlinkio/shlink-installer": "dev-develop#fbbc8f5 as 7.1",
"shlinkio/shlink-ip-geolocation": "^2.2", "shlinkio/shlink-ip-geolocation": "^2.2",
"symfony/console": "^6.0", "symfony/console": "^6.0",
"symfony/filesystem": "^6.0", "symfony/filesystem": "^6.0",
@@ -61,11 +61,11 @@
"symfony/string": "^6.0" "symfony/string": "^6.0"
}, },
"require-dev": { "require-dev": {
"cebe/php-openapi": "^1.5", "cebe/php-openapi": "^1.7",
"devster/ubench": "^2.1", "devster/ubench": "^2.1",
"dms/phpunit-arraysubset-asserts": "^0.3.0", "dms/phpunit-arraysubset-asserts": "^0.3.0",
"infection/infection": "^0.26", "infection/infection": "^0.26.5",
"openswoole/ide-helper": "~4.9.1", "openswoole/ide-helper": "~4.11.0",
"phpspec/prophecy-phpunit": "^2.0", "phpspec/prophecy-phpunit": "^2.0",
"phpstan/phpstan": "^1.2", "phpstan/phpstan": "^1.2",
"phpstan/phpstan-doctrine": "^1.0", "phpstan/phpstan-doctrine": "^1.0",
@@ -74,7 +74,7 @@
"phpunit/phpunit": "^9.5", "phpunit/phpunit": "^9.5",
"roave/security-advisories": "dev-master", "roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.2.0", "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", "symfony/var-dumper": "^6.0",
"veewee/composer-run-parallel": "^1.1" "veewee/composer-run-parallel": "^1.1"
}, },
@@ -139,7 +139,7 @@
"test:api": "bin/test/run-api-tests.sh", "test:api": "bin/test/run-api-tests.sh",
"test:api:ci": "GENERATE_COVERAGE=yes composer test:api", "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: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: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: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", "infect:ci": "@parallel infect:ci:unit infect:ci:db infect:ci:api",

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ return (static function (): array {
return [ return [
'url_shortener' => [ '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', 'schema' => ((bool) EnvVars::IS_HTTPS_ENABLED()->loadFromEnv(true)) ? 'https' : 'http',
'hostname' => EnvVars::DEFAULT_DOMAIN()->loadFromEnv(''), 'hostname' => EnvVars::DEFAULT_DOMAIN()->loadFromEnv(''),
], ],

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
use Laminas\ServiceManager\ServiceManager; use Laminas\ServiceManager\ServiceManager;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Symfony\Component\Lock; use Symfony\Component\Lock;
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY; use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
@@ -11,6 +12,9 @@ chdir(dirname(__DIR__));
require 'vendor/autoload.php'; 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 // 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 // It needs to be placed here as individual config files will not be loaded once config is cached
if (! class_exists(LOCAL_LOCK_FACTORY)) { if (! class_exists(LOCAL_LOCK_FACTORY)) {
@@ -18,7 +22,7 @@ if (! class_exists(LOCAL_LOCK_FACTORY)) {
} }
// Build container // Build container
return (function () { return (static function (): ServiceManager {
$config = require __DIR__ . '/config.php'; $config = require __DIR__ . '/config.php';
$container = new ServiceManager($config['dependencies']); $container = new ServiceManager($config['dependencies']);
$container->setService('config', $config); $container->setService('config', $config);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -95,6 +95,9 @@
"/rest/v{version}/tags/{tag}/visits": { "/rest/v{version}/tags/{tag}/visits": {
"$ref": "paths/v2_tags_{tag}_visits.json" "$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": { "/rest/v{version}/visits/orphan": {
"$ref": "paths/v2_visits_orphan.json" "$ref": "paths/v2_visits_orphan.json"
}, },

View File

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

View File

@@ -72,7 +72,7 @@ return [
TrackingOptions::class, TrackingOptions::class,
], ],
Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class], Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class],
ApiKey\RoleResolver::class => [DomainService::class], ApiKey\RoleResolver::class => [DomainService::class, 'config.url_shortener.domain.hostname'],
Command\ShortUrl\CreateShortUrlCommand::class => [ Command\ShortUrl\CreateShortUrlCommand::class => [
Service\UrlShortener::class, Service\UrlShortener::class,

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\ApiKey; namespace Shlinkio\Shlink\CLI\ApiKey;
use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@@ -12,24 +13,33 @@ use function is_string;
class RoleResolver implements RoleResolverInterface class RoleResolver implements RoleResolverInterface
{ {
public function __construct(private DomainServiceInterface $domainService) public function __construct(private DomainServiceInterface $domainService, private string $defaultDomain)
{ {
} }
public function determineRoles(InputInterface $input): array public function determineRoles(InputInterface $input): array
{ {
$domainAuthority = $input->getOption('domain-only'); $domainAuthority = $input->getOption(self::DOMAIN_ONLY_PARAM);
$author = $input->getOption('author-only'); $author = $input->getOption(self::AUTHOR_ONLY_PARAM);
$roleDefinitions = []; $roleDefinitions = [];
if ($author) { if ($author) {
$roleDefinitions[] = RoleDefinition::forAuthoredShortUrls(); $roleDefinitions[] = RoleDefinition::forAuthoredShortUrls();
} }
if (is_string($domainAuthority)) { if (is_string($domainAuthority)) {
$domain = $this->domainService->getOrCreate($domainAuthority); $roleDefinitions[] = $this->resolveRoleForAuthority($domainAuthority);
$roleDefinitions[] = RoleDefinition::forDomain($domain);
} }
return $roleDefinitions; return $roleDefinitions;
} }
private function resolveRoleForAuthority(string $domainAuthority): RoleDefinition
{
if ($domainAuthority === $this->defaultDomain) {
throw InvalidRoleConfigException::forDomainOnlyWithDefaultDomain();
}
$domain = $this->domainService->getOrCreate($domainAuthority);
return RoleDefinition::forDomain($domain);
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Exception;
use InvalidArgumentException;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use function sprintf;
class InvalidRoleConfigException extends InvalidArgumentException implements ExceptionInterface
{
public static function forDomainOnlyWithDefaultDomain(): self
{
return new self(sprintf(
'You cannot create an API key with the "%s" role attached to the default domain. '
. 'The role is currently limited to non-default domains.',
Role::DOMAIN_SPECIFIC,
));
}
}

View File

@@ -66,9 +66,8 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
{ {
$buildTimestamp = $this->resolveBuildTimestamp($meta); $buildTimestamp = $this->resolveBuildTimestamp($meta);
$buildDate = Chronos::createFromTimestamp($buildTimestamp); $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 private function resolveBuildTimestamp(Metadata $meta): int

View File

@@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolver; use Shlinkio\Shlink\CLI\ApiKey\RoleResolver;
use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
@@ -23,7 +24,7 @@ class RoleResolverTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$this->domainService = $this->prophesize(DomainServiceInterface::class); $this->domainService = $this->prophesize(DomainServiceInterface::class);
$this->resolver = new RoleResolver($this->domainService->reveal()); $this->resolver = new RoleResolver($this->domainService->reveal(), 'default.com');
} }
/** /**
@@ -94,4 +95,16 @@ class RoleResolverTest extends TestCase
1, 1,
]; ];
} }
/** @test */
public function exceptionIsThrownWhenTryingToAddDomainOnlyLinkedToDefaultDomain(): void
{
$input = $this->prophesize(InputInterface::class);
$input->getOption(RoleResolver::DOMAIN_ONLY_PARAM)->willReturn('default.com');
$input->getOption(RoleResolver::AUTHOR_ONLY_PARAM)->willReturn(null);
$this->expectException(InvalidRoleConfigException::class);
$this->resolver->determineRoles($input->reveal());
}
} }

View File

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

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use function sprintf;
class InvalidRoleConfigExceptionTest extends TestCase
{
/** @test */
public function forDomainOnlyWithDefaultDomainGeneratesExpectedException(): void
{
$e = InvalidRoleConfigException::forDomainOnlyWithDefaultDomain();
self::assertEquals(sprintf(
'You cannot create an API key with the "%s" role attached to the default domain. '
. 'The role is currently limited to non-default domains.',
Role::DOMAIN_SPECIFIC,
), $e->getMessage());
}
}

View File

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

View File

@@ -12,6 +12,7 @@ use Laminas\InputFilter\InputFilter;
use PUGX\Shortid\Factory as ShortIdFactory; use PUGX\Shortid\Factory as ShortIdFactory;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use function date_default_timezone_get;
use function Functional\reduce_left; use function Functional\reduce_left;
use function is_array; use function is_array;
use function print_r; use function print_r;
@@ -32,7 +33,7 @@ function generateRandomShortCode(int $length): string
function parseDateFromQuery(array $query, string $dateName): ?Chronos 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 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); 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) { $parsedDate = match (true) {
return $date; $date === null || $date instanceof Chronos => $date,
} $date instanceof DateTimeInterface => Chronos::instance($date),
default => Chronos::parse($date),
};
if ($date instanceof DateTimeInterface) { return $parsedDate?->setTimezone(date_default_timezone_get());
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);
} }
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
@@ -108,6 +95,18 @@ function isCrawler(string $userAgent): bool
return $detector->isCrawler($userAgent); 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 function fieldWithUtf8Charset(FieldBuilder $field, array $emConfig, string $collation = 'unicode_ci'): FieldBuilder
{ {
return match ($emConfig['connection']['driver'] ?? null) { return match ($emConfig['connection']['driver'] ?? null) {

View File

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

View File

@@ -12,7 +12,7 @@ use function array_values;
use function Functional\contains; use function Functional\contains;
use function Shlinkio\Shlink\Config\env; 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() * @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 DEFAULT_DOMAIN()
* @method static EnvVars AUTO_RESOLVE_TITLES() * @method static EnvVars AUTO_RESOLVE_TITLES()
* @method static EnvVars REDIRECT_APPEND_EXTRA_PATH() * @method static EnvVars REDIRECT_APPEND_EXTRA_PATH()
* @method static EnvVars TIMEZONE()
* @method static EnvVars VISITS_WEBHOOKS() * @method static EnvVars VISITS_WEBHOOKS()
* @method static EnvVars NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS() * @method static EnvVars NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS()
*/ */
@@ -114,7 +115,10 @@ final class EnvVars
public const DEFAULT_DOMAIN = 'DEFAULT_DOMAIN'; public const DEFAULT_DOMAIN = 'DEFAULT_DOMAIN';
public const AUTO_RESOLVE_TITLES = 'AUTO_RESOLVE_TITLES'; public const AUTO_RESOLVE_TITLES = 'AUTO_RESOLVE_TITLES';
public const REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH'; public const REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH';
public const TIMEZONE = 'TIMEZONE';
/** @deprecated */
public const VISITS_WEBHOOKS = 'VISITS_WEBHOOKS'; public const VISITS_WEBHOOKS = 'VISITS_WEBHOOKS';
/** @deprecated */
public const NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS = 'NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS'; public const NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS = 'NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS';
/** /**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; 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; 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->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL);
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); $this->validSince = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); $this->validUntil = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
$this->customSlug = $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG); $this->customSlug = $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG);
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS); $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS);
$this->findIfExists = $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS); $this->findIfExists = $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS);

View File

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

View File

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

View File

@@ -154,6 +154,47 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
return $qb; 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 public function findOrphanVisits(VisitsListFiltering $filtering): array
{ {
$qb = $this->createAllVisitsQueryBuilder($filtering); $qb = $this->createAllVisitsQueryBuilder($filtering);

View File

@@ -45,6 +45,13 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification
public function countVisitsByTag(string $tag, VisitsCountFiltering $filtering): int; 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[] * @return Visit[]
*/ */

View File

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

View File

@@ -7,9 +7,12 @@ namespace Shlinkio\Shlink\Core\Visit;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Pagerfanta\Adapter\AdapterInterface; use Pagerfanta\Adapter\AdapterInterface;
use Shlinkio\Shlink\Common\Paginator\Paginator; 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\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; 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\VisitRepository;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; 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\NonOrphanVisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\ShortUrlVisitsPaginatorAdapter; 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 $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 * @return Visit[]|Paginator
*/ */

View File

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

View File

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

View File

@@ -52,7 +52,7 @@ class VisitRepositoryTest extends DatabaseTestCase
{ {
$shortUrl = ShortUrl::createEmpty(); $shortUrl = ShortUrl::createEmpty();
$this->getEntityManager()->persist($shortUrl); $this->getEntityManager()->persist($shortUrl);
$countIterable = function (iterable $results): int { $countIterable = static function (iterable $results): int {
$resultsCount = 0; $resultsCount = 0;
foreach ($results as $value) { foreach ($results as $value) {
$resultsCount++; $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 */ /** @test */
public function countVisitsReturnsExpectedResultBasedOnApiKey(): void public function countVisitsReturnsExpectedResultBasedOnApiKey(): void
{ {

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use RuntimeException;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Importer\ImportedLinksProcessor; 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\Core\Util\DoctrineBatchHelperInterface;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit; use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit;
use Shlinkio\Shlink\Importer\Params\ImportParams;
use Shlinkio\Shlink\Importer\Sources\ImportSources; use Shlinkio\Shlink\Importer\Sources\ImportSources;
use Symfony\Component\Console\Style\StyleInterface; use Symfony\Component\Console\Style\StyleInterface;
@@ -32,8 +34,6 @@ class ImportedLinksProcessorTest extends TestCase
{ {
use ProphecyTrait; use ProphecyTrait;
private const PARAMS = ['import_short_codes' => true, 'source' => ImportSources::BITLY];
private ImportedLinksProcessor $processor; private ImportedLinksProcessor $processor;
private ObjectProphecy $em; private ObjectProphecy $em;
private ObjectProphecy $shortCodeHelper; private ObjectProphecy $shortCodeHelper;
@@ -74,7 +74,7 @@ class ImportedLinksProcessorTest extends TestCase
$ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); $ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true);
$persist = $this->em->persist(Argument::type(ShortUrl::class)); $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); $importedUrlExists->shouldHaveBeenCalledTimes($expectedCalls);
$ensureUniqueness->shouldHaveBeenCalledTimes($expectedCalls); $ensureUniqueness->shouldHaveBeenCalledTimes($expectedCalls);
@@ -82,6 +82,37 @@ class ImportedLinksProcessorTest extends TestCase
$this->io->text(Argument::type('string'))->shouldHaveBeenCalledTimes($expectedCalls); $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 */ /** @test */
public function alreadyImportedUrlsAreSkipped(): void public function alreadyImportedUrlsAreSkipped(): void
{ {
@@ -104,7 +135,7 @@ class ImportedLinksProcessorTest extends TestCase
$ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); $ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true);
$persist = $this->em->persist(Argument::type(ShortUrl::class)); $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)); $importedUrlExists->shouldHaveBeenCalledTimes(count($urls));
$ensureUniqueness->shouldHaveBeenCalledTimes(2); $ensureUniqueness->shouldHaveBeenCalledTimes(2);
@@ -141,7 +172,7 @@ class ImportedLinksProcessorTest extends TestCase
}); });
$persist = $this->em->persist(Argument::type(ShortUrl::class)); $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)); $importedUrlExists->shouldHaveBeenCalledTimes(count($urls));
$failingEnsureUniqueness->shouldHaveBeenCalledTimes(5); $failingEnsureUniqueness->shouldHaveBeenCalledTimes(5);
@@ -167,7 +198,7 @@ class ImportedLinksProcessorTest extends TestCase
$persistUrl = $this->em->persist(Argument::type(ShortUrl::class)); $persistUrl = $this->em->persist(Argument::type(ShortUrl::class));
$persistVisits = $this->em->persist(Argument::type(Visit::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(); $findExisting->shouldHaveBeenCalledOnce();
$ensureUniqueness->shouldHaveBeenCalledTimes($foundShortUrl === null ? 1 : 0); $ensureUniqueness->shouldHaveBeenCalledTimes($foundShortUrl === null ? 1 : 0);
@@ -214,4 +245,12 @@ class ImportedLinksProcessorTest extends TestCase
])), ])),
]; ];
} }
private function buildParams(): ImportParams
{
return ImportParams::fromSourceAndCallableMap(
ImportSources::BITLY,
['import_short_codes' => static fn () => true],
);
}
} }

View File

@@ -10,9 +10,12 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; 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\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
@@ -158,6 +161,69 @@ class VisitsStatsHelperTest extends TestCase
$getRepo->shouldHaveBeenCalledOnce(); $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 */ /** @test */
public function orphanVisitsAreReturnedAsExpected(): void public function orphanVisitsAreReturnedAsExpected(): void
{ {

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Exception;
use Fig\Http\Message\StatusCodeInterface; use Fig\Http\Message\StatusCodeInterface;
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use Throwable;
class MercureException extends RuntimeException implements ProblemDetailsExceptionInterface 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 TITLE = 'Mercure integration not configured';
private const TYPE = 'MERCURE_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->detail = $e->getMessage();
$e->title = self::TITLE; $e->title = self::TITLE;

View File

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

View File

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

View File

@@ -11,7 +11,6 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use RuntimeException;
use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface; use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface;
use Shlinkio\Shlink\Rest\Action\MercureInfoAction; use Shlinkio\Shlink\Rest\Action\MercureInfoAction;
use Shlinkio\Shlink\Rest\Exception\MercureException; use Shlinkio\Shlink\Rest\Exception\MercureException;
@@ -49,24 +48,6 @@ class MercureInfoActionTest extends TestCase
yield 'host is null' => [['public_hub_url' => null]]; 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 public function provideValidConfigs(): iterable
{ {
yield 'days not defined' => [['public_hub_url' => 'http://foobar.com']]; yield 'days not defined' => [['public_hub_url' => 'http://foobar.com']];

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ RewriteEngine On
RewriteCond %{REQUEST_FILENAME} -f [OR] RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -l [OR] RewriteCond %{REQUEST_FILENAME} -l [OR]
RewriteCond %{REQUEST_FILENAME} -d RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^.*$ - [NC,L] RewriteRule ^ - [NC,L]
# The following rewrites all other queries to index.php. The # The following rewrites all other queries to index.php. The
# condition ensures that if you are using Apache aliases to do # condition ensures that if you are using Apache aliases to do
@@ -12,6 +12,6 @@ RewriteRule ^.*$ - [NC,L]
# allow proper resolution of the index.php file; it will work # allow proper resolution of the index.php file; it will work
# in non-aliased environments as well, providing a safe, one-size # in non-aliased environments as well, providing a safe, one-size
# fits all solution. # fits all solution.
RewriteCond %{REQUEST_URI}::$1 ^(/.+)(.+)::\2$ RewriteCond $0::%{REQUEST_URI} ^([^:]*+(?::[^:]*+)*?)::(/.+?)\1$
RewriteRule ^(.*) - [E=BASE:%1] RewriteRule .+ - [E=BASE:%2]
RewriteRule ^(.*)$ %{ENV:BASE}index.php [NC,L] RewriteRule .* %{ENV:BASE}index.php [NC,L]