mirror of
https://github.com/shlinkio/shlink.git
synced 2025-03-14 04:00:57 +03:00
commit
5cec697be3
188 changed files with 2640 additions and 1014 deletions
2
.github/workflows/ci-db-tests.yml
vendored
2
.github/workflows/ci-db-tests.yml
vendored
|
@ -27,7 +27,7 @@ jobs:
|
|||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-extensions: openswoole-4.12.0, pdo_sqlsrv-5.10.1
|
||||
php-extensions: openswoole-4.12.1, pdo_sqlsrv-5.10.1
|
||||
extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }}
|
||||
- name: Create test database
|
||||
if: ${{ inputs.platform == 'ms' }}
|
||||
|
|
14
.github/workflows/ci-docker-image-build.yml
vendored
Normal file
14
.github/workflows/ci-docker-image-build.yml
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
name: Build docker image
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Dockerfile'
|
||||
|
||||
jobs:
|
||||
build-docker-image:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- run: docker build -t shlink-docker-image:temp .
|
2
.github/workflows/ci-mutation-tests.yml
vendored
2
.github/workflows/ci-mutation-tests.yml
vendored
|
@ -19,7 +19,7 @@ jobs:
|
|||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-extensions: openswoole-4.12.0
|
||||
php-extensions: openswoole-4.12.1
|
||||
extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
|
|
2
.github/workflows/ci-tests.yml
vendored
2
.github/workflows/ci-tests.yml
vendored
|
@ -25,7 +25,7 @@ jobs:
|
|||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-extensions: openswoole-4.12.0
|
||||
php-extensions: openswoole-4.12.1
|
||||
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
|
||||
- run: composer test:${{ inputs.test-group }}:ci
|
||||
- uses: actions/upload-artifact@v3
|
||||
|
|
38
.github/workflows/ci.yml
vendored
38
.github/workflows/ci.yml
vendored
|
@ -1,12 +1,28 @@
|
|||
name: Continuous integration
|
||||
|
||||
on:
|
||||
pull_request: null
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'LICENSE'
|
||||
- '.*'
|
||||
- '*.md'
|
||||
- '*.xml'
|
||||
- '*.yml*'
|
||||
- '*.json5'
|
||||
- '*.neon'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- 2.x
|
||||
paths-ignore:
|
||||
- 'LICENSE'
|
||||
- '.*'
|
||||
- '*.md'
|
||||
- '*.xml'
|
||||
- '*.yml*'
|
||||
- '*.json5'
|
||||
- '*.neon'
|
||||
|
||||
jobs:
|
||||
static-analysis:
|
||||
|
@ -20,7 +36,7 @@ jobs:
|
|||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-extensions: openswoole-4.12.0
|
||||
php-extensions: openswoole-4.12.1
|
||||
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ matrix.command }}
|
||||
- run: composer ${{ matrix.command }}
|
||||
|
||||
|
@ -44,6 +60,8 @@ jobs:
|
|||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.1', '8.2']
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
|
||||
|
@ -157,19 +175,3 @@ jobs:
|
|||
coverage-db
|
||||
coverage-api
|
||||
coverage-cli
|
||||
|
||||
build-docker-image:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 100
|
||||
- uses: marceloprado/has-changed-path@v1
|
||||
id: changed-dockerfile
|
||||
with:
|
||||
paths: ./Dockerfile
|
||||
- if: ${{ steps.changed-dockerfile.outputs.changed == 'true' }}
|
||||
run: docker build -t shlink-docker-image:temp .
|
||||
- if: ${{ steps.changed-dockerfile.outputs.changed != 'true' }}
|
||||
run: echo "Dockerfile didn't change. Skipped"
|
||||
|
|
|
@ -4,6 +4,14 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- develop
|
||||
paths-ignore:
|
||||
- 'LICENSE'
|
||||
- '.*'
|
||||
- '*.md'
|
||||
- '*.xml'
|
||||
- '*.yml*'
|
||||
- '*.json5'
|
||||
- '*.neon'
|
||||
tags:
|
||||
- 'v*'
|
||||
|
2
.github/workflows/publish-release.yml
vendored
2
.github/workflows/publish-release.yml
vendored
|
@ -17,7 +17,7 @@ jobs:
|
|||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-extensions: openswoole-4.12.0
|
||||
php-extensions: openswoole-4.12.1
|
||||
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
|
||||
install-deps: 'no'
|
||||
- if: ${{ matrix.swoole == 'yes' }}
|
||||
|
|
2
.github/workflows/publish-swagger-spec.yml
vendored
2
.github/workflows/publish-swagger-spec.yml
vendored
|
@ -20,7 +20,7 @@ jobs:
|
|||
- uses: './.github/actions/ci-setup'
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
php-extensions: openswoole-4.12.0
|
||||
php-extensions: openswoole-4.12.1
|
||||
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
|
||||
- run: composer swagger:inline
|
||||
- run: mkdir ${{ steps.determine_version.outputs.version }}
|
||||
|
|
51
CHANGELOG.md
51
CHANGELOG.md
|
@ -4,6 +4,45 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [3.5.0] - 2023-01-28
|
||||
### Added
|
||||
* [#1557](https://github.com/shlinkio/shlink/issues/1557) Added support to dynamically redirect to different long URLs based on the visitor's device type.
|
||||
|
||||
For the moment, only `android`, `ios` and `desktop` can have their own specific long URL, and when the visitor cannot be matched against any of them, the regular long URL will be used.
|
||||
|
||||
In the future, more granular device types could be added if appropriate (iOS tablet, android table, tablet, mobile phone, Linux, Mac, Windows, etc).
|
||||
|
||||
In order to match the visitor's device, the `User-Agent` header is used.
|
||||
|
||||
* [#1632](https://github.com/shlinkio/shlink/issues/1632) Added amount of bots, non-bots and total visits to the visits summary endpoint.
|
||||
* [#1633](https://github.com/shlinkio/shlink/issues/1633) Added amount of bots, non-bots and total visits to the tag stats endpoint.
|
||||
* [#1653](https://github.com/shlinkio/shlink/issues/1653) Added support for all HTTP methods in short URLs, together with two new redirect status codes, 307 and 308.
|
||||
|
||||
Existing Shlink instances will continue to work the same. However, if you decide to set the redirect status codes as 307 or 308, Shlink will also return a redirect for short URLs even when the request method is different from `GET`.
|
||||
|
||||
The status 308 is equivalent to 301, and 307 is equivalent to 302. The difference is that the spec requires the client to respect the original HTTP method when performing the redirect. With 301 and 302, some old clients might perform a `GET` request during the redirect, regardless the original request method.
|
||||
|
||||
* [#1662](https://github.com/shlinkio/shlink/issues/1662) Added support to provide openswoole-specific config options via env vars prefixed with `OPENSWOOLE_`.
|
||||
* [#1389](https://github.com/shlinkio/shlink/issues/1389) and [#706](https://github.com/shlinkio/shlink/issues/706) Added support for case-insensitive short URLs.
|
||||
|
||||
In order to achieve this, a new env var/config option has been implemented (`SHORT_URL_MODE`), which allows either `strict` or `loosely`.
|
||||
|
||||
Default value is `strict`, but if `loosely` is provided, then short URLs will be matched in a case-insensitive way, and new short URLs will be generated with short-codes in lowercase only.
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
||||
### Deprecated
|
||||
* [#1676](https://github.com/shlinkio/shlink/issues/1676) Deprecated `GET /short-urls/shorten` endpoint. Use `POST /short-urls` to create short URLs instead.
|
||||
* [#1678](https://github.com/shlinkio/shlink/issues/1678) Deprecated `validateUrl` option on URL creation/edition.
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#1639](https://github.com/shlinkio/shlink/issues/1639) Fixed 500 error returned when request body is not valid JSON, instead of a proper descriptive error.
|
||||
|
||||
|
||||
## [3.4.0] - 2022-12-16
|
||||
### Added
|
||||
* [#1612](https://github.com/shlinkio/shlink/issues/1612) Allowed to filter short URLs out of lists, when `validUntil` date is in the past or have reached their maximum amount of visits.
|
||||
|
@ -1428,7 +1467,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
|||
|
||||
Endpoints and commands which create short URLs support providing the `domain` now (via query param or CLI flag). If not provided, the short URLs will still be "attached" to the default domain.
|
||||
|
||||
Custom slugs can be created on multiple domains, allowing to share links like `https://doma.in/my-campaign` and `https://example.com/my-campaign`, under the same shlink instance.
|
||||
Custom slugs can be created on multiple domains, allowing to share links like `https://s.test/my-campaign` and `https://example.com/my-campaign`, under the same shlink instance.
|
||||
|
||||
When resolving a short URL to redirect end users, the following rules are applied:
|
||||
|
||||
|
@ -1891,7 +1930,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
|||
```json
|
||||
{
|
||||
"shortCode": "12Kb3",
|
||||
"shortUrl": "https://doma.in/12Kb3",
|
||||
"shortUrl": "https://s.test/12Kb3",
|
||||
"longUrl": "https://shlink.io",
|
||||
"dateCreated": "2016-05-01T20:34:16+02:00",
|
||||
"visitsCount": 1029,
|
||||
|
@ -1958,7 +1997,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
|||
* [#174](https://github.com/shlinkio/shlink/issues/174) Fixed geolocation not working due to a deprecation on used service.
|
||||
* [#172](https://github.com/shlinkio/shlink/issues/172) Documented missing filtering params for `[GET] /short-codes/{shortCode}/visits` API endpoint, which allow the list to be filtered by date range.
|
||||
|
||||
For example: `https://doma.in/rest/v1/short-urls/abc123/visits?startDate=2017-05-23&endDate=2017-10-05`
|
||||
For example: `https://s.test/rest/v1/short-urls/abc123/visits?startDate=2017-05-23&endDate=2017-10-05`
|
||||
|
||||
* [#169](https://github.com/shlinkio/shlink/issues/169) Fixed unhandled error when parsing `ShortUrlMeta` and date fields are already `DateTime` instances.
|
||||
|
||||
|
@ -2030,7 +2069,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
|||
|
||||
This eases integration with third party services.
|
||||
|
||||
With this feature, a simple request to a URL like `https://doma.in/rest/v1/short-codes/shorten?apiKey=[YOUR_API_KEY]&longUrl=[URL_TO_BE_SHORTENED]` would return the shortened one in JSON or plain text format.
|
||||
With this feature, a simple request to a URL like `https://s.test/rest/v1/short-codes/shorten?apiKey=[YOUR_API_KEY]&longUrl=[URL_TO_BE_SHORTENED]` would return the shortened one in JSON or plain text format.
|
||||
|
||||
### Changed
|
||||
* *Nothing*
|
||||
|
@ -2066,7 +2105,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
|||
### Added
|
||||
* [#125](https://github.com/shlinkio/shlink/issues/125) Implemented a path which returns a 1px image instead of a redirection.
|
||||
|
||||
Useful to track emails. Just add an image pointing to a URL like `https://doma.in/abc123/track` to any email and an invisible image will be generated tracking every time the email is opened.
|
||||
Useful to track emails. Just add an image pointing to a URL like `https://s.test/abc123/track` to any email and an invisible image will be generated tracking every time the email is opened.
|
||||
|
||||
* [#132](https://github.com/shlinkio/shlink/issues/132) Added infection to improve tests
|
||||
|
||||
|
@ -2347,7 +2386,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
|||
### Added
|
||||
* [#46](https://github.com/shlinkio/shlink/issues/46) Defined a route that returns a QR code representing the shortened URL.
|
||||
|
||||
In order to get the QR code URL, use a pattern like `https://doma.in/abc123/qr-code`
|
||||
In order to get the QR code URL, use a pattern like `https://s.test/abc123/qr-code`
|
||||
|
||||
* [#32](https://github.com/shlinkio/shlink/issues/32) Added support for other cache adapters by improving the Cache factory
|
||||
* [#14](https://github.com/shlinkio/shlink/issues/14) Added logger and enabled errors logging
|
||||
|
|
|
@ -4,7 +4,7 @@ ARG SHLINK_VERSION=latest
|
|||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||
ARG SHLINK_RUNTIME=openswoole
|
||||
ENV SHLINK_RUNTIME ${SHLINK_RUNTIME}
|
||||
ENV OPENSWOOLE_VERSION 4.12.0
|
||||
ENV OPENSWOOLE_VERSION 4.12.1
|
||||
ENV PDO_SQLSRV_VERSION 5.10.1
|
||||
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
|
||||
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
|
||||
|
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016-2021 Alejandro Celaya
|
||||
Copyright (c) 2016-2023 Alejandro Celaya
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||

|
||||
|
||||
[](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Continuous+integration%22)
|
||||
[](https://github.com/shlinkio/shlink/actions/workflows/ci.yml?query=workflow%3A%22Continuous+integration%22)
|
||||
[](https://app.codecov.io/gh/shlinkio/shlink)
|
||||
[](https://dashboard.stryker-mutator.io/reports/github.com/shlinkio/shlink/develop)
|
||||
[](https://packagist.org/packages/shlinkio/shlink)
|
||||
|
@ -36,7 +36,7 @@ The idea is that you can just generate a container using the image and provide t
|
|||
|
||||
First, make sure the host where you are going to run shlink fulfills these requirements:
|
||||
|
||||
* PHP 8.1
|
||||
* PHP 8.1 or 8.2
|
||||
* The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath.
|
||||
* apcu extension is recommended if you don't plan to use openswoole.
|
||||
* xml extension is required if you want to generate QR codes in svg format.
|
||||
|
|
|
@ -40,16 +40,17 @@
|
|||
"mezzio/mezzio-problem-details": "^1.7",
|
||||
"mezzio/mezzio-swoole": "^4.5",
|
||||
"mlocati/ip-lib": "^1.18",
|
||||
"mobiledetect/mobiledetectlib": "^3.74",
|
||||
"ocramius/proxy-manager": "^2.14",
|
||||
"pagerfanta/core": "^3.6",
|
||||
"php-middleware/request-id": "^4.1",
|
||||
"pugx/shortid-php": "^1.1",
|
||||
"ramsey/uuid": "^4.5",
|
||||
"shlinkio/shlink-common": "^5.2",
|
||||
"shlinkio/shlink-config": "^2.3",
|
||||
"shlinkio/shlink-common": "^5.3",
|
||||
"shlinkio/shlink-config": "^2.4",
|
||||
"shlinkio/shlink-event-dispatcher": "^2.6",
|
||||
"shlinkio/shlink-importer": "^5.0",
|
||||
"shlinkio/shlink-installer": "^8.2",
|
||||
"shlinkio/shlink-installer": "^8.3",
|
||||
"shlinkio/shlink-ip-geolocation": "^3.2",
|
||||
"spiral/roadrunner": "^2.11",
|
||||
"spiral/roadrunner-jobs": "^2.5",
|
||||
|
@ -73,7 +74,7 @@
|
|||
"phpunit/phpunit": "^9.5",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"shlinkio/php-coding-standard": "~2.3.0",
|
||||
"shlinkio/shlink-test-utils": "^3.3",
|
||||
"shlinkio/shlink-test-utils": "^3.4",
|
||||
"symfony/var-dumper": "^6.1",
|
||||
"veewee/composer-run-parallel": "^1.1"
|
||||
},
|
||||
|
@ -96,7 +97,8 @@
|
|||
"ShlinkioApiTest\\Shlink\\Rest\\": "module/Rest/test-api",
|
||||
"ShlinkioDbTest\\Shlink\\Rest\\": "module/Rest/test-db",
|
||||
"ShlinkioTest\\Shlink\\Core\\": "module/Core/test",
|
||||
"ShlinkioDbTest\\Shlink\\Core\\": "module/Core/test-db"
|
||||
"ShlinkioDbTest\\Shlink\\Core\\": "module/Core/test-db",
|
||||
"ShlinkioApiTest\\Shlink\\Core\\": "module/Core/test-api"
|
||||
},
|
||||
"files": [
|
||||
"config/test/constants.php"
|
||||
|
|
|
@ -45,6 +45,7 @@ return [
|
|||
Option\UrlShortener\AppendExtraPathConfigOption::class,
|
||||
Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class,
|
||||
Option\UrlShortener\EnableTrailingSlashConfigOption::class,
|
||||
Option\UrlShortener\ShortUrlModeConfigOption::class,
|
||||
Option\Tracking\IpAnonymizationConfigOption::class,
|
||||
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
|
||||
Option\Tracking\DisableTrackParamConfigOption::class,
|
||||
|
|
|
@ -16,7 +16,7 @@ return [
|
|||
],
|
||||
|
||||
'redirects' => [
|
||||
'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE->loadFromEnv(DEFAULT_REDIRECT_STATUS_CODE),
|
||||
'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE->loadFromEnv(DEFAULT_REDIRECT_STATUS_CODE->value),
|
||||
'redirect_cache_lifetime' => (int) EnvVars::REDIRECT_CACHE_LIFETIME->loadFromEnv(
|
||||
DEFAULT_REDIRECT_CACHE_LIFETIME,
|
||||
),
|
||||
|
|
|
@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
|
||||
use function Shlinkio\Shlink\Config\getOpenswooleConfigFromEnv;
|
||||
|
||||
use const Shlinkio\Shlink\MIN_TASK_WORKERS;
|
||||
|
||||
return (static function (): array {
|
||||
|
@ -21,6 +23,7 @@ return (static function (): array {
|
|||
'process-name' => 'shlink',
|
||||
|
||||
'options' => [
|
||||
...getOpenswooleConfigFromEnv(),
|
||||
'worker_num' => (int) EnvVars::WEB_WORKER_NUM->loadFromEnv(16),
|
||||
'task_worker_num' => max($taskWorkers, MIN_TASK_WORKERS),
|
||||
],
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
||||
|
||||
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
|
||||
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
|
||||
|
@ -12,6 +13,8 @@ return (static function (): array {
|
|||
(int) EnvVars::DEFAULT_SHORT_CODES_LENGTH->loadFromEnv(DEFAULT_SHORT_CODES_LENGTH),
|
||||
MIN_SHORT_CODES_LENGTH,
|
||||
);
|
||||
$modeFromEnv = EnvVars::SHORT_URL_MODE->loadFromEnv(ShortUrlMode::STRICT->value);
|
||||
$mode = ShortUrlMode::tryFrom($modeFromEnv) ?? ShortUrlMode::STRICT;
|
||||
|
||||
return [
|
||||
|
||||
|
@ -25,6 +28,7 @@ return (static function (): array {
|
|||
'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(false),
|
||||
'multi_segment_slugs_enabled' => (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false),
|
||||
'trailing_slash_enabled' => (bool) EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false),
|
||||
'mode' => $mode,
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -15,6 +15,7 @@ use function class_exists;
|
|||
use function Shlinkio\Shlink\Config\env;
|
||||
use function Shlinkio\Shlink\Config\openswooleIsInstalled;
|
||||
use function Shlinkio\Shlink\Config\runningInRoadRunner;
|
||||
use function Shlinkio\Shlink\Core\enumValues;
|
||||
|
||||
use const PHP_SAPI;
|
||||
|
||||
|
@ -23,7 +24,7 @@ $enableSwoole = PHP_SAPI === 'cli' && openswooleIsInstalled() && ! runningInRoad
|
|||
|
||||
return (new ConfigAggregator\ConfigAggregator([
|
||||
! $isTestEnv
|
||||
? new EnvVarLoaderProvider('config/params/generated_config.php', Core\Config\EnvVars::values())
|
||||
? new EnvVarLoaderProvider('config/params/generated_config.php', enumValues(Core\Config\EnvVars::class))
|
||||
: new ConfigAggregator\ArrayProvider([]),
|
||||
Mezzio\ConfigProvider::class,
|
||||
Mezzio\Router\ConfigProvider::class,
|
||||
|
@ -48,6 +49,7 @@ return (new ConfigAggregator\ConfigAggregator([
|
|||
// Routes have to be loaded last
|
||||
new ConfigAggregator\PhpFileProvider('config/autoload/routes.config.php'),
|
||||
], 'data/cache/app_config.php', [
|
||||
Core\Config\BasePathPrefixer::class,
|
||||
Core\Config\MultiSegmentSlugProcessor::class,
|
||||
Core\Config\PostProcessor\BasePathPrefixer::class,
|
||||
Core\Config\PostProcessor\MultiSegmentSlugProcessor::class,
|
||||
Core\Config\PostProcessor\ShortUrlMethodsProcessor::class,
|
||||
]))->getMergedConfig();
|
||||
|
|
|
@ -4,12 +4,12 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink;
|
||||
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Shlinkio\Shlink\Core\Util\RedirectStatus;
|
||||
|
||||
const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15;
|
||||
const DEFAULT_SHORT_CODES_LENGTH = 5;
|
||||
const MIN_SHORT_CODES_LENGTH = 4;
|
||||
const DEFAULT_REDIRECT_STATUS_CODE = StatusCodeInterface::STATUS_FOUND;
|
||||
const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; // Deprecated. Default to 307 for Shlink v4
|
||||
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
|
||||
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
|
||||
const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside a html title tag
|
||||
|
|
|
@ -6,3 +6,10 @@ namespace ShlinkioTest\Shlink;
|
|||
|
||||
const API_TESTS_HOST = '127.0.0.1';
|
||||
const API_TESTS_PORT = 9999;
|
||||
|
||||
const ANDROID_USER_AGENT = 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
. 'Chrome/109.0.5414.86 Mobile Safari/537.36';
|
||||
const IOS_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 '
|
||||
. '(KHTML, like Gecko) FxiOS/109.0 Mobile/15E148 Safari/605.1.15';
|
||||
const DESKTOP_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like '
|
||||
. 'Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/109.0.1518.61';
|
||||
|
|
|
@ -84,7 +84,7 @@ $buildDbConnection = static function (): array {
|
|||
return match ($driver) {
|
||||
'sqlite' => [
|
||||
'driver' => 'pdo_sqlite',
|
||||
'path' => sys_get_temp_dir() . '/shlink-tests.db',
|
||||
'memory' => true,
|
||||
],
|
||||
'postgres' => [
|
||||
'driver' => 'pdo_pgsql',
|
||||
|
@ -131,7 +131,7 @@ return [
|
|||
'url_shortener' => [
|
||||
'domain' => [
|
||||
'schema' => 'http',
|
||||
'hostname' => 'doma.in',
|
||||
'hostname' => 's.test',
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<VirtualHost *:80>
|
||||
ServerName doma.in
|
||||
ServerName s.test
|
||||
DocumentRoot "/path/to/shlink/public"
|
||||
|
||||
<Directory "/path/to/shlink/public">
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
server {
|
||||
server_name doma.in;
|
||||
server_name s.test;
|
||||
listen 80;
|
||||
root /path/to/shlink/public;
|
||||
index index.php;
|
||||
|
|
|
@ -3,7 +3,7 @@ MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
|||
|
||||
ENV APCU_VERSION 5.1.21
|
||||
ENV INOTIFY_VERSION 3.0.0
|
||||
ENV OPENSWOOLE_VERSION 4.12.0
|
||||
ENV OPENSWOOLE_VERSION 4.12.1
|
||||
ENV PDO_SQLSRV_VERSION 5.10.1
|
||||
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
|
||||
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
|
||||
|
|
53
data/migrations/Version20230103105343.php
Normal file
53
data/migrations/Version20230103105343.php
Normal file
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Platforms\MySQLPlatform;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20230103105343 extends AbstractMigration
|
||||
{
|
||||
private const TABLE_NAME = 'device_long_urls';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->skipIf($schema->hasTable(self::TABLE_NAME));
|
||||
|
||||
$table = $schema->createTable(self::TABLE_NAME);
|
||||
$table->addColumn('id', Types::BIGINT, [
|
||||
'unsigned' => true,
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
]);
|
||||
$table->setPrimaryKey(['id']);
|
||||
|
||||
$table->addColumn('device_type', Types::STRING, ['length' => 255]);
|
||||
$table->addColumn('long_url', Types::STRING, ['length' => 2048]);
|
||||
$table->addColumn('short_url_id', Types::BIGINT, [
|
||||
'unsigned' => true,
|
||||
'notnull' => true,
|
||||
]);
|
||||
|
||||
$table->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [
|
||||
'onDelete' => 'CASCADE',
|
||||
'onUpdate' => 'RESTRICT',
|
||||
]);
|
||||
|
||||
$table->addUniqueIndex(['device_type', 'short_url_id'], 'UQ_device_type_per_short_url');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->skipIf(! $schema->hasTable(self::TABLE_NAME));
|
||||
$schema->dropTable(self::TABLE_NAME);
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
|
||||
}
|
||||
}
|
|
@ -102,7 +102,7 @@ services:
|
|||
|
||||
shlink_db_mysql:
|
||||
container_name: shlink_db_mysql
|
||||
image: mysql:5.7
|
||||
image: mysql:8.0
|
||||
ports:
|
||||
- "3307:3306"
|
||||
volumes:
|
||||
|
@ -175,7 +175,7 @@ services:
|
|||
|
||||
shlink_mercure:
|
||||
container_name: shlink_mercure
|
||||
image: dunglas/mercure:v0.13
|
||||
image: dunglas/mercure:v0.14
|
||||
ports:
|
||||
- "3080:80"
|
||||
environment:
|
||||
|
|
|
@ -11,7 +11,7 @@ It exposes a shlink instance served with [openswoole](https://openswoole.com/),
|
|||
|
||||
The most basic way to run Shlink's docker image is by providing these mandatory env vars.
|
||||
|
||||
* `DEFAULT_DOMAIN`: The default short domain used for this shlink instance. For example **doma.in**.
|
||||
* `DEFAULT_DOMAIN`: The default short domain used for this shlink instance. For example **s.test**.
|
||||
* `IS_HTTPS_ENABLED`: Either **true** or **false**. Tells if Shlink is being served with HTTPs or not.
|
||||
* `GEOLITE_LICENSE_KEY`: Your GeoLite2 license key. [Learn more](https://shlink.io/documentation/geolite-license-key/) about this.
|
||||
|
||||
|
@ -21,7 +21,7 @@ To run shlink on top of a local docker service, and using an internal SQLite dat
|
|||
docker run \
|
||||
--name shlink \
|
||||
-p 8080:8080 \
|
||||
-e DEFAULT_DOMAIN=doma.in \
|
||||
-e DEFAULT_DOMAIN=s.test \
|
||||
-e IS_HTTPS_ENABLED=true \
|
||||
-e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 \
|
||||
shlinkio/shlink:stable
|
||||
|
|
77
docs/adr/2023-01-06-support-any-http-method-in-short-urls.md
Normal file
77
docs/adr/2023-01-06-support-any-http-method-in-short-urls.md
Normal file
|
@ -0,0 +1,77 @@
|
|||
# Support any HTTP method in short URLs
|
||||
|
||||
* Status: Accepted
|
||||
* Date: 2023-01-06
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
There has been a report that Shlink behaves as if a short URL was not found when the request HTTP method is not `GET`.
|
||||
|
||||
They want it to accept other methods so that they can do things like POSTing stuff that then gets "redirected" to the original URL.
|
||||
|
||||
This presents two main problems:
|
||||
|
||||
* Changing this could be considered a breaking change, in case someone is relying on this behavior (Shlink to only redirect on `GET`).
|
||||
* Shlink currently supports two redirect statuses ([301](https://httpwg.org/specs/rfc9110.html#status.301) and [302](https://httpwg.org/specs/rfc9110.html#status.302)), which can be configured by the server admin.
|
||||
|
||||
For historical reasons, a client might switch from the original method to `GET` when any of these is returned, not resulting in the desired behavior anyway.
|
||||
|
||||
Instead, statuses [308](https://httpwg.org/specs/rfc9110.html#status.308) and [307](https://httpwg.org/specs/rfc9110.html#status.307) should be used.
|
||||
|
||||
## Considered options
|
||||
|
||||
There's actually two problems to solve here. Some combinations are implicitly required:
|
||||
|
||||
* **To support other HTTP methods in short URLs**
|
||||
* Start supporting all HTTP methods.
|
||||
* Introduce a feature flag to allow users decide if they want to support all methods or just `GET`.
|
||||
* **To support other redirects statuses (308 and 307)**
|
||||
* Switch to status 308 and 307 and stop using 301 and 302.
|
||||
* Allow users to configure which of the 4 status codes they want to use, insteadof just supporting 301 and 302.
|
||||
* Allow users to configure between two combinations: 301+308 and 302+307, using 301 or 302 for `GET` requests, and 308 or 307 for the rest.
|
||||
|
||||
> **Note**
|
||||
> I asked on social networks, and these were the results (not too many answers though):
|
||||
> * https://fosstodon.org/@shlinkio/109626773392324128
|
||||
> * https://twitter.com/shlinkio/status/1610347091741507585
|
||||
|
||||
## Decision outcome
|
||||
|
||||
Because of backwards compatibility, it feels like the bets option is allowing to configure between 301, 302, 308 and 307.
|
||||
|
||||
This has the benefit that we can keep existing behavior intact. Existing instances will continue working only on `GET`, with statuses 301 or 302.
|
||||
|
||||
Anyone who wants to opt-in, can switch to 308 or 307, and the short URLs will transparently work on other HTTP methods in that case.
|
||||
|
||||
The only drawback is that this difference in the behavior when 308 or 307 are configured needs to be documented, and explained in shlink-installer.
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Start supporting all HTTP methods
|
||||
|
||||
* Good: Because the change in code is pretty simple.
|
||||
* Bad: Because it would be potentially a breaking change for anyone trusting current behavior for anything.
|
||||
|
||||
### Support HTTP methods via feature flag
|
||||
|
||||
* Good: because it would be safer for existing instances and opt-in for anyone interested in this change of behavior.
|
||||
* Bad: Because it requires more changes in code.
|
||||
* Bad: Because it requires a new config entry in the shlink-installer.
|
||||
|
||||
### Switch to statuses 308 and 307
|
||||
|
||||
* Good: Because we keep supporting just two status codes.
|
||||
* Bad: Because it requires applying mapping/transformation to convert old configurations.
|
||||
* Bad: Because it requires changes in shlink-installer.
|
||||
|
||||
### Allow users to configure between 301, 302, 308 and 307
|
||||
|
||||
* Good: Because it's fully backwards compatible with existing configs.
|
||||
* Good: Because it would implicitly allow enabling all HTTP methods if 308 or 307 are selected, and keep only `GET` for 301 and 302, without the need for a separated feature flag.
|
||||
* Bad: Because it requires dynamically supporting only `GET` or all methods, depending on the selected status.
|
||||
|
||||
### Allow users to configure between 301+308 or 302+307
|
||||
|
||||
* Good: Because it would allow a more explicit redirects config, where values are not 301 and 302, but something like "permanent" and "temporary".
|
||||
* Bad: Because it implicitly changes the behavior of existing instances, making them respond to redirects with a method other than `GET`, and with a status code other than the one they explicitly configured.
|
||||
* Bad: because existing `REDIRECT_STATUS_CODE` env var might not make sense anymore, requiring a new one and logic to map from one to another.
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome.
|
||||
|
||||
* [2023-01-06 Support any HTTP method in short URLs](2023-01-06-support-any-http-method-in-short-urls.md)
|
||||
* [2022-08-05 Support multi-segment custom slugs](2022-08-05-support-multi-segment-custom-slugs.md)
|
||||
* [2022-01-15 Update env vars behavior to have precedence over installer options](2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md)
|
||||
* [2021-08-05 Migrate to a new caching library](2021-08-05-migrate-to-a-new-caching-library.md)
|
||||
|
|
|
@ -111,12 +111,19 @@
|
|||
"type": "string",
|
||||
"description": "The original long URL."
|
||||
},
|
||||
"deviceLongUrls": {
|
||||
"$ref": "#/components/schemas/DeviceLongUrls"
|
||||
},
|
||||
"dateCreated": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The date in which the short URL was created in ISO format."
|
||||
},
|
||||
"visitsSummary": {
|
||||
"$ref": "#/components/schemas/VisitsSummary"
|
||||
},
|
||||
"visitsCount": {
|
||||
"deprecated": true,
|
||||
"type": "integer",
|
||||
"description": "The number of visits that this short URL has received."
|
||||
},
|
||||
|
@ -146,10 +153,19 @@
|
|||
},
|
||||
"example": {
|
||||
"shortCode": "12C18",
|
||||
"shortUrl": "https://doma.in/12C18",
|
||||
"shortUrl": "https://s.test/12C18",
|
||||
"longUrl": "https://store.steampowered.com",
|
||||
"deviceLongUrls": {
|
||||
"android": "https://store.steampowered.com/android",
|
||||
"ios": "https://store.steampowered.com/ios",
|
||||
"desktop": null
|
||||
},
|
||||
"dateCreated": "2016-08-21T20:34:16+02:00",
|
||||
"visitsCount": 328,
|
||||
"visitsSummary": {
|
||||
"total": 328,
|
||||
"nonBots": 285,
|
||||
"bots": 43
|
||||
},
|
||||
"tags": [
|
||||
"games",
|
||||
"tech"
|
||||
|
@ -189,6 +205,42 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"VisitsSummary": {
|
||||
"type": "object",
|
||||
"required": ["total", "nonBots", "bots"],
|
||||
"properties": {
|
||||
"total": {
|
||||
"description": "The total amount of visits",
|
||||
"type": "number"
|
||||
},
|
||||
"nonBots": {
|
||||
"description": "The amount of visits which were not identified as bots",
|
||||
"type": "number"
|
||||
},
|
||||
"bots": {
|
||||
"description": "The amount of visits that were identified as potential bots",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"DeviceLongUrls": {
|
||||
"type": "object",
|
||||
"required": ["android", "ios", "desktop"],
|
||||
"properties": {
|
||||
"android": {
|
||||
"description": "The long URL to redirect to when the short URL is visited from a device running Android",
|
||||
"type": "string"
|
||||
},
|
||||
"ios": {
|
||||
"description": "The long URL to redirect to when the short URL is visited from a device running iOS",
|
||||
"type": "string"
|
||||
},
|
||||
"desktop": {
|
||||
"description": "The long URL to redirect to when the short URL is visited from a desktop browser",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Visit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -266,7 +318,7 @@
|
|||
"timezone": "America/Los_Angeles"
|
||||
},
|
||||
"potentialBot": false,
|
||||
"visitedUrl": "https://doma.in",
|
||||
"visitedUrl": "https://s.test",
|
||||
"type": "base_url"
|
||||
}
|
||||
},
|
||||
|
|
20
docs/swagger/definitions/DeviceLongUrls.json
Normal file
20
docs/swagger/definitions/DeviceLongUrls.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"android": {
|
||||
"description": "The long URL to redirect to when the short URL is visited from a device running Android",
|
||||
"type": "string",
|
||||
"nullable": false
|
||||
},
|
||||
"ios": {
|
||||
"description": "The long URL to redirect to when the short URL is visited from a device running iOS",
|
||||
"type": "string",
|
||||
"nullable": false
|
||||
},
|
||||
"desktop": {
|
||||
"description": "The long URL to redirect to when the short URL is visited from a desktop browser",
|
||||
"type": "string",
|
||||
"nullable": false
|
||||
}
|
||||
}
|
||||
}
|
17
docs/swagger/definitions/DeviceLongUrlsEdit.json
Normal file
17
docs/swagger/definitions/DeviceLongUrlsEdit.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"type": "object",
|
||||
"allOf": [{
|
||||
"$ref": "./DeviceLongUrls.json"
|
||||
}],
|
||||
"properties": {
|
||||
"android": {
|
||||
"nullable": true
|
||||
},
|
||||
"ios": {
|
||||
"nullable": true
|
||||
},
|
||||
"desktop": {
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
7
docs/swagger/definitions/DeviceLongUrlsResp.json
Normal file
7
docs/swagger/definitions/DeviceLongUrlsResp.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "object",
|
||||
"required": ["android", "ios", "desktop"],
|
||||
"allOf": [{
|
||||
"$ref": "./DeviceLongUrlsEdit.json"
|
||||
}]
|
||||
}
|
|
@ -4,6 +4,7 @@
|
|||
"shortCode",
|
||||
"shortUrl",
|
||||
"longUrl",
|
||||
"deviceLongUrls",
|
||||
"dateCreated",
|
||||
"visitsCount",
|
||||
"visitsSummary",
|
||||
|
@ -27,6 +28,9 @@
|
|||
"type": "string",
|
||||
"description": "The original long URL."
|
||||
},
|
||||
"deviceLongUrls": {
|
||||
"$ref": "./DeviceLongUrlsResp.json"
|
||||
},
|
||||
"dateCreated": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
|
@ -38,7 +42,7 @@
|
|||
"description": "**[DEPRECATED]** Use `visitsSummary.total` instead."
|
||||
},
|
||||
"visitsSummary": {
|
||||
"$ref": "./ShortUrlVisitsSummary.json"
|
||||
"$ref": "./VisitsSummary.json"
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
|
|
|
@ -5,6 +5,9 @@
|
|||
"description": "The long URL this short URL will redirect to",
|
||||
"type": "string"
|
||||
},
|
||||
"deviceLongUrls": {
|
||||
"$ref": "./DeviceLongUrlsEdit.json"
|
||||
},
|
||||
"validSince": {
|
||||
"description": "The date (in ISO-8601 format) from which this short code will be valid",
|
||||
"type": "string",
|
||||
|
@ -21,7 +24,8 @@
|
|||
"nullable": true
|
||||
},
|
||||
"validateUrl": {
|
||||
"description": "Tells if the long URL (if provided) should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
|
||||
"deprecated": true,
|
||||
"description": "**[DEPRECATED]** Tells if the long URL should or should not be validated as a reachable URL. Defaults to `false`",
|
||||
"type": "boolean"
|
||||
},
|
||||
"tags": {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"type": "object",
|
||||
"required": ["tag", "shortUrlsCount", "visitsSummary", "visitsCount"],
|
||||
"properties": {
|
||||
"tag": {
|
||||
"type": "string",
|
||||
|
@ -9,9 +10,13 @@
|
|||
"type": "number",
|
||||
"description": "The amount of short URLs using this tag"
|
||||
},
|
||||
"userAgent": {
|
||||
"visitsSummary": {
|
||||
"$ref": "./VisitsSummary.json"
|
||||
},
|
||||
"visitsCount": {
|
||||
"deprecated": true,
|
||||
"type": "number",
|
||||
"description": "The combined amount of visits received by short URLs with this tag"
|
||||
"description": "**[DEPRECATED]** Use visitsSummary.total instead"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,14 +1,22 @@
|
|||
{
|
||||
"type": "object",
|
||||
"required": ["visitsCount", "orphanVisitsCount"],
|
||||
"required": ["nonOrphanVisits", "orphanVisits", "visitsCount", "orphanVisitsCount"],
|
||||
"properties": {
|
||||
"nonOrphanVisits": {
|
||||
"$ref": "./VisitsSummary.json"
|
||||
},
|
||||
"orphanVisits": {
|
||||
"$ref": "./VisitsSummary.json"
|
||||
},
|
||||
"visitsCount": {
|
||||
"deprecated": true,
|
||||
"type": "number",
|
||||
"description": "The total amount of visits received on any short URL."
|
||||
"description": "**[DEPRECATED]** Use nonOrphanVisits.total instead"
|
||||
},
|
||||
"orphanVisitsCount": {
|
||||
"deprecated": true,
|
||||
"type": "number",
|
||||
"description": "The total amount of visits that could not be matched to a short URL (visits to the base URL, an invalid short URL or any other kind of 404)."
|
||||
"description": "**[DEPRECATED]** Use orphanVisits.total instead"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"required": ["total", "nonBots", "bots"],
|
||||
"properties": {
|
||||
"total": {
|
||||
"description": "The total amount of visits that this short URL has received.",
|
||||
"description": "The total amount of visits.",
|
||||
"type": "integer"
|
||||
},
|
||||
"nonBots": {
|
|
@ -161,8 +161,13 @@
|
|||
"data": [
|
||||
{
|
||||
"shortCode": "12C18",
|
||||
"shortUrl": "https://doma.in/12C18",
|
||||
"shortUrl": "https://s.test/12C18",
|
||||
"longUrl": "https://store.steampowered.com",
|
||||
"deviceLongUrls": {
|
||||
"android": null,
|
||||
"ios": null,
|
||||
"desktop": null
|
||||
},
|
||||
"dateCreated": "2016-08-21T20:34:16+02:00",
|
||||
"visitsSummary": {
|
||||
"total": 328,
|
||||
|
@ -184,8 +189,13 @@
|
|||
},
|
||||
{
|
||||
"shortCode": "12Kb3",
|
||||
"shortUrl": "https://doma.in/12Kb3",
|
||||
"shortUrl": "https://s.test/12Kb3",
|
||||
"longUrl": "https://shlink.io",
|
||||
"deviceLongUrls": {
|
||||
"android": null,
|
||||
"ios": "https://shlink.io/ios",
|
||||
"desktop": null
|
||||
},
|
||||
"dateCreated": "2016-05-01T20:34:16+02:00",
|
||||
"visitsSummary": {
|
||||
"total": 1029,
|
||||
|
@ -208,6 +218,11 @@
|
|||
"shortCode": "123bA",
|
||||
"shortUrl": "https://example.com/123bA",
|
||||
"longUrl": "https://www.google.com",
|
||||
"deviceLongUrls": {
|
||||
"android": null,
|
||||
"ios": null,
|
||||
"desktop": null
|
||||
},
|
||||
"dateCreated": "2015-10-01T20:34:16+02:00",
|
||||
"visitsSummary": {
|
||||
"total": 25,
|
||||
|
@ -281,6 +296,9 @@
|
|||
"type": "object",
|
||||
"required": ["longUrl"],
|
||||
"properties": {
|
||||
"deviceLongUrls": {
|
||||
"$ref": "../definitions/DeviceLongUrls.json"
|
||||
},
|
||||
"customSlug": {
|
||||
"description": "A unique custom slug to be used instead of the generated short code",
|
||||
"type": "string"
|
||||
|
@ -296,10 +314,6 @@
|
|||
"shortCodeLength": {
|
||||
"description": "The length for generated short code. It has to be at least 4 and defaults to 5. It will be ignored when customSlug is provided",
|
||||
"type": "number"
|
||||
},
|
||||
"validateUrl": {
|
||||
"description": "Tells if the long URL should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -318,8 +332,13 @@
|
|||
},
|
||||
"example": {
|
||||
"shortCode": "12C18",
|
||||
"shortUrl": "https://doma.in/12C18",
|
||||
"shortUrl": "https://s.test/12C18",
|
||||
"longUrl": "https://store.steampowered.com",
|
||||
"deviceLongUrls": {
|
||||
"android": null,
|
||||
"ios": null,
|
||||
"desktop": null
|
||||
},
|
||||
"dateCreated": "2016-08-21T20:34:16+02:00",
|
||||
"visitsSummary": {
|
||||
"total": 0,
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
{
|
||||
"get": {
|
||||
"operationId": "shortenUrl",
|
||||
"deprecated": true,
|
||||
"tags": [
|
||||
"Short URLs"
|
||||
],
|
||||
"summary": "Create a short URL",
|
||||
"description": "Creates a short URL in a single API call. Useful for third party integrations.",
|
||||
"description": "**[Deprecated]** Use [Create short URL](#/Short%20URLs/createShortUrl) instead",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
|
@ -52,7 +53,12 @@
|
|||
},
|
||||
"example": {
|
||||
"longUrl": "https://github.com/shlinkio/shlink",
|
||||
"shortUrl": "https://doma.in/abc123",
|
||||
"deviceLongUrls": {
|
||||
"android": null,
|
||||
"ios": null,
|
||||
"desktop": null
|
||||
},
|
||||
"shortUrl": "https://s.test/abc123",
|
||||
"shortCode": "abc123",
|
||||
"dateCreated": "2016-08-21T20:34:16+02:00",
|
||||
"visitsSummary": {
|
||||
|
@ -78,7 +84,7 @@
|
|||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "https://doma.in/abc123"
|
||||
"example": "https://s.test/abc123"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -38,8 +38,13 @@
|
|||
},
|
||||
"example": {
|
||||
"shortCode": "12Kb3",
|
||||
"shortUrl": "https://doma.in/12Kb3",
|
||||
"shortUrl": "https://s.test/12Kb3",
|
||||
"longUrl": "https://shlink.io",
|
||||
"deviceLongUrls": {
|
||||
"android": null,
|
||||
"ios": null,
|
||||
"desktop": null
|
||||
},
|
||||
"dateCreated": "2016-05-01T20:34:16+02:00",
|
||||
"visitsSummary": {
|
||||
"total": 1029,
|
||||
|
@ -160,8 +165,13 @@
|
|||
},
|
||||
"example": {
|
||||
"shortCode": "12Kb3",
|
||||
"shortUrl": "https://doma.in/12Kb3",
|
||||
"shortUrl": "https://s.test/12Kb3",
|
||||
"longUrl": "https://shlink.io",
|
||||
"deviceLongUrls": {
|
||||
"android": "https://shlink.io/android",
|
||||
"ios": null,
|
||||
"desktop": null
|
||||
},
|
||||
"dateCreated": "2016-05-01T20:34:16+02:00",
|
||||
"visitsSummary": {
|
||||
"total": 1029,
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
{
|
||||
"name": "orderBy",
|
||||
"in": "query",
|
||||
"description": "To determine how to order the results.<br /><br />**Important!** Ordering by `shortUrlsCount` or `visitsCount` has a [known performance issue](https://github.com/shlinkio/shlink/issues/1346) which makes loading a subset of the list take as much as loading the whole list.<br />If you plan to order by any of these fields, it's worth loading the whole list with no pagination.",
|
||||
"description": "To determine how to order the results.<br /><br />**Important!** Ordering by `shortUrlsCount`, `visits` or `nonBotVisits` has a [known performance issue](https://github.com/shlinkio/shlink/issues/1346) which makes loading a subset of the list take as much as loading the whole list.<br />If you plan to order by any of these fields, it's worth loading the whole list with no pagination.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
|
@ -54,8 +54,10 @@
|
|||
"tag-DESC",
|
||||
"shortUrlsCount-ASC",
|
||||
"shortUrlsCount-DESC",
|
||||
"visitsCount-ASC",
|
||||
"visitsCount-DESC"
|
||||
"visits-ASC",
|
||||
"visits-DESC",
|
||||
"nonBotVisits-ASC",
|
||||
"nonBotVisits-DESC"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -73,7 +75,6 @@
|
|||
"required": ["data"],
|
||||
"properties": {
|
||||
"data": {
|
||||
"description": "The tag stats will be returned only if the withStats param was provided with value 'true'",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "../definitions/TagInfo.json"
|
||||
|
@ -92,12 +93,20 @@
|
|||
{
|
||||
"tag": "games",
|
||||
"shortUrlsCount": 10,
|
||||
"visitsCount": 521
|
||||
"visitsSummary": {
|
||||
"total": 521,
|
||||
"nonBots": 521,
|
||||
"bots": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "shlink",
|
||||
"shortUrlsCount": 7,
|
||||
"visitsCount": 1087
|
||||
"visitsSummary": {
|
||||
"total": 1087,
|
||||
"nonBots": 1000,
|
||||
"bots": 87
|
||||
}
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
|
|
|
@ -31,8 +31,16 @@
|
|||
},
|
||||
"example": {
|
||||
"visits": {
|
||||
"visitsCount": 1569874,
|
||||
"orphanVisitsCount": 71345
|
||||
"nonOrphanVisits": {
|
||||
"total": 64994,
|
||||
"nonBots": 64986,
|
||||
"bots": 8
|
||||
},
|
||||
"orphanVisits": {
|
||||
"total": 37,
|
||||
"nonBots": 34,
|
||||
"bots": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -95,7 +95,7 @@
|
|||
"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,
|
||||
"visitedUrl": "https://doma.in",
|
||||
"visitedUrl": "https://s.test",
|
||||
"type": "base_url"
|
||||
},
|
||||
{
|
||||
|
@ -112,7 +112,7 @@
|
|||
"timezone": "America/Los_Angeles"
|
||||
},
|
||||
"potentialBot": false,
|
||||
"visitedUrl": "https://doma.in/foo",
|
||||
"visitedUrl": "https://s.test/foo",
|
||||
"type": "invalid_short_url"
|
||||
},
|
||||
{
|
||||
|
@ -121,7 +121,7 @@
|
|||
"userAgent": "some_web_crawler/1.4",
|
||||
"visitLocation": null,
|
||||
"potentialBot": true,
|
||||
"visitedUrl": "https://doma.in/foo/bar/baz",
|
||||
"visitedUrl": "https://s.test/foo/bar/baz",
|
||||
"type": "regular_404"
|
||||
}
|
||||
],
|
||||
|
|
|
@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
|
@ -103,7 +102,7 @@ class CreateShortUrlCommand extends Command
|
|||
'validate-url',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Forces the long URL to be validated, regardless what is globally configured.',
|
||||
'[DEPRECATED] Makes the URL to be validated as publicly accessible.',
|
||||
)
|
||||
->addOption(
|
||||
'crawlable',
|
||||
|
@ -175,8 +174,7 @@ class CreateShortUrlCommand extends Command
|
|||
ShortUrlInputFilter::TAGS => $tags,
|
||||
ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'),
|
||||
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
|
||||
EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $this->options->multiSegmentSlugsEnabled,
|
||||
]));
|
||||
], $this->options));
|
||||
|
||||
$io->writeln([
|
||||
sprintf('Processed long URL: <info>%s</info>', $longUrl),
|
||||
|
|
|
@ -46,7 +46,7 @@ class ListTagsCommand extends Command
|
|||
|
||||
return map(
|
||||
$tags,
|
||||
static fn (TagInfo $tagInfo) => [$tagInfo->tag, $tagInfo->shortUrlsCount, $tagInfo->visitsCount],
|
||||
static fn (TagInfo $tagInfo) => [$tagInfo->tag, $tagInfo->shortUrlsCount, $tagInfo->visitsSummary->total],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
|
|||
|
||||
private function __construct(string $message, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, 0, $previous);
|
||||
parent::__construct($message, previous: $previous);
|
||||
}
|
||||
|
||||
public static function withOlderDb(?Throwable $prev = null): self
|
||||
|
|
|
@ -27,11 +27,11 @@ class ListShortUrlsTest extends CliTestCase
|
|||
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
|
||||
+--------------------+---------------+-------------------------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
|
||||
| ghi789 | | http://example.com/ghi789 | https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/ | 2019-01-01T00:00:30+00:00 | 0 |
|
||||
| custom | | http://doma.in/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
|
||||
| def456 | | http://doma.in/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
|
||||
| custom | | http://s.test/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
|
||||
| def456 | | http://s.test/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
|
||||
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
|
||||
| abc123 | My cool title | http://doma.in/abc123 | https://shlink.io | 2018-05-01T00:00:00+00:00 | 3 |
|
||||
| ghi789 | | http://doma.in/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
|
||||
| abc123 | My cool title | http://s.test/abc123 | https://shlink.io | 2018-05-01T00:00:00+00:00 | 3 |
|
||||
| ghi789 | | http://s.test/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
|
||||
+--------------------+---------------+-------------------------------------------+---------------------------- Page 1 of 1 ------------------------------------------------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
yield 'start date' => [['--start-date=2019-01'], <<<OUTPUT
|
||||
|
@ -39,8 +39,8 @@ class ListShortUrlsTest extends CliTestCase
|
|||
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
|
||||
+------------+-------+---------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
|
||||
| ghi789 | | http://example.com/ghi789 | https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/ | 2019-01-01T00:00:30+00:00 | 0 |
|
||||
| custom | | http://doma.in/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
|
||||
| def456 | | http://doma.in/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
|
||||
| custom | | http://s.test/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
|
||||
| def456 | | http://s.test/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
|
||||
+------------+-------+---------------------------+-------------------------------------------- Page 1 of 1 --------------------------------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
yield 'end date' => [['-e 2018-12-01'], <<<OUTPUT
|
||||
|
@ -48,16 +48,16 @@ class ListShortUrlsTest extends CliTestCase
|
|||
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
|
||||
+--------------------+---------------+-------------------------------------------+----------------------------------+---------------------------+--------------+
|
||||
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
|
||||
| abc123 | My cool title | http://doma.in/abc123 | https://shlink.io | 2018-05-01T00:00:00+00:00 | 3 |
|
||||
| ghi789 | | http://doma.in/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
|
||||
| abc123 | My cool title | http://s.test/abc123 | https://shlink.io | 2018-05-01T00:00:00+00:00 | 3 |
|
||||
| ghi789 | | http://s.test/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
|
||||
+--------------------+---------------+----------------------------------- Page 1 of 1 ------------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
yield 'start and end date' => [['-s 2018-06-20', '--end-date=2019-01-01T00:00:20+00:00'], <<<OUTPUT
|
||||
+--------------------+-------+-------------------------------------------+-----------------------------------------------------------------------------------------------------+---------------------------+--------------+
|
||||
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
|
||||
+--------------------+-------+-------------------------------------------+-----------------------------------------------------------------------------------------------------+---------------------------+--------------+
|
||||
| custom | | http://doma.in/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
|
||||
| def456 | | http://doma.in/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
|
||||
| custom | | http://s.test/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
|
||||
| def456 | | http://s.test/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
|
||||
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
|
||||
+--------------------+-------+-------------------------------------------+----------------------------- Page 1 of 1 -----------------------------------------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
|
@ -66,8 +66,8 @@ class ListShortUrlsTest extends CliTestCase
|
|||
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
|
||||
+--------------------+-------+-------------------------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+
|
||||
| ghi789 | | http://example.com/ghi789 | https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/ | 2019-01-01T00:00:30+00:00 | 0 |
|
||||
| custom | | http://doma.in/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
|
||||
| def456 | | http://doma.in/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
|
||||
| custom | | http://s.test/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
|
||||
| def456 | | http://s.test/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 |
|
||||
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
|
||||
+--------------------+-------+-------------------------------------------+-------------------------------- Page 1 of 1 --------------------------------------------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
|
|
|
@ -40,11 +40,11 @@ class GetDomainVisitsCommandTest extends TestCase
|
|||
/** @test */
|
||||
public function outputIsProperlyGenerated(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createEmpty();
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
|
||||
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
);
|
||||
$domain = 'doma.in';
|
||||
$domain = 's.test';
|
||||
$this->visitsHelper->expects($this->once())->method('visitsForDomain')->with(
|
||||
$domain,
|
||||
$this->anything(),
|
||||
|
|
|
@ -48,7 +48,7 @@ class CreateShortUrlCommandTest extends TestCase
|
|||
/** @test */
|
||||
public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createEmpty();
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn($shortUrl);
|
||||
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
|
||||
'stringified_short_url',
|
||||
|
@ -98,11 +98,10 @@ class CreateShortUrlCommandTest extends TestCase
|
|||
/** @test */
|
||||
public function properlyProcessesProvidedTags(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createEmpty();
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->with(
|
||||
$this->callback(function (ShortUrlCreation $meta) {
|
||||
$tags = $meta->getTags();
|
||||
Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $tags);
|
||||
$this->callback(function (ShortUrlCreation $creation) {
|
||||
Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $creation->tags);
|
||||
return true;
|
||||
}),
|
||||
)->willReturn($shortUrl);
|
||||
|
@ -128,10 +127,10 @@ class CreateShortUrlCommandTest extends TestCase
|
|||
{
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->with(
|
||||
$this->callback(function (ShortUrlCreation $meta) use ($expectedDomain) {
|
||||
Assert::assertEquals($expectedDomain, $meta->getDomain());
|
||||
Assert::assertEquals($expectedDomain, $meta->domain);
|
||||
return true;
|
||||
}),
|
||||
)->willReturn(ShortUrl::createEmpty());
|
||||
)->willReturn(ShortUrl::createFake());
|
||||
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
|
||||
|
||||
$input['longUrl'] = 'http://domain.com/foo/bar';
|
||||
|
@ -154,7 +153,7 @@ class CreateShortUrlCommandTest extends TestCase
|
|||
*/
|
||||
public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedValidateUrl): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createEmpty();
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$this->urlShortener->expects($this->once())->method('shorten')->with(
|
||||
$this->callback(function (ShortUrlCreation $meta) use ($expectedValidateUrl) {
|
||||
Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl());
|
||||
|
|
|
@ -94,7 +94,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
|
|||
/** @test */
|
||||
public function outputIsProperlyGenerated(): void
|
||||
{
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate(
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('bar', 'foo', '', ''))->locate(
|
||||
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
);
|
||||
$shortCode = 'abc123';
|
||||
|
|
|
@ -40,7 +40,7 @@ class GetTagVisitsCommandTest extends TestCase
|
|||
/** @test */
|
||||
public function outputIsProperlyGenerated(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createEmpty();
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
|
||||
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
);
|
||||
|
|
|
@ -40,7 +40,7 @@ class GetNonOrphanVisitsCommandTest extends TestCase
|
|||
/** @test */
|
||||
public function outputIsProperlyGenerated(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::createEmpty();
|
||||
$shortUrl = ShortUrl::createFake();
|
||||
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
|
||||
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
|
||||
);
|
||||
|
|
|
@ -66,7 +66,7 @@ class LocateVisitsCommandTest extends TestCase
|
|||
bool $expectWarningPrint,
|
||||
array $args,
|
||||
): void {
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', ''));
|
||||
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
|
||||
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
|
||||
|
||||
|
@ -113,7 +113,7 @@ class LocateVisitsCommandTest extends TestCase
|
|||
*/
|
||||
public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void
|
||||
{
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance());
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance());
|
||||
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
|
||||
|
||||
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
|
||||
|
@ -140,7 +140,7 @@ class LocateVisitsCommandTest extends TestCase
|
|||
/** @test */
|
||||
public function errorWhileLocatingIpIsDisplayed(): void
|
||||
{
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', ''));
|
||||
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
|
||||
|
||||
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
|
||||
|
|
|
@ -136,8 +136,8 @@ return [
|
|||
Options\DeleteShortUrlsOptions::class,
|
||||
ShortUrl\ShortUrlResolver::class,
|
||||
],
|
||||
ShortUrl\ShortUrlResolver::class => ['em'],
|
||||
ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em'],
|
||||
ShortUrl\ShortUrlResolver::class => ['em', Options\UrlShortenerOptions::class],
|
||||
ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Options\UrlShortenerOptions::class],
|
||||
Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'],
|
||||
|
||||
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
||||
use Doctrine\ORM\Mapping\Builder\FieldBuilder;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
|
||||
return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
|
||||
$builder->setTable(determineTableName('device_long_urls', $emConfig));
|
||||
|
||||
$builder->createField('id', Types::BIGINT)
|
||||
->columnName('id')
|
||||
->makePrimaryKey()
|
||||
->generatedValue('IDENTITY')
|
||||
->option('unsigned', true)
|
||||
->build();
|
||||
|
||||
(new FieldBuilder($builder, [
|
||||
'fieldName' => 'deviceType',
|
||||
'type' => Types::STRING,
|
||||
'enumType' => DeviceType::class,
|
||||
]))->columnName('device_type')
|
||||
->length(255)
|
||||
->build();
|
||||
|
||||
fieldWithUtf8Charset($builder->createField('longUrl', Types::STRING), $emConfig)
|
||||
->columnName('long_url')
|
||||
->length(2048)
|
||||
->build();
|
||||
|
||||
$builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class)
|
||||
->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE')
|
||||
->build();
|
||||
};
|
|
@ -24,7 +24,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
|||
->build();
|
||||
|
||||
fieldWithUtf8Charset($builder->createField('longUrl', Types::STRING), $emConfig)
|
||||
->columnName('original_url')
|
||||
->columnName('original_url') // Rename to long_url some day? ¯\_(ツ)_/¯
|
||||
->length(2048)
|
||||
->build();
|
||||
|
||||
|
@ -67,6 +67,13 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
|||
->fetchExtraLazy()
|
||||
->build();
|
||||
|
||||
$builder->createOneToMany('deviceLongUrls', ShortUrl\Entity\DeviceLongUrl::class)
|
||||
->mappedBy('shortUrl')
|
||||
->cascadePersist()
|
||||
->orphanRemoval()
|
||||
->setIndexBy('deviceType')
|
||||
->build();
|
||||
|
||||
$builder->createManyToMany('tags', Tag\Entity\Tag::class)
|
||||
->setJoinTable(determineTableName('short_urls_in_tags', $emConfig))
|
||||
->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE')
|
||||
|
|
|
@ -4,33 +4,40 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use BackedEnum;
|
||||
use Cake\Chronos\Chronos;
|
||||
use Cake\Chronos\ChronosInterface;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\ORM\Mapping\Builder\FieldBuilder;
|
||||
use Jaybizzle\CrawlerDetect\CrawlerDetect;
|
||||
use Laminas\Filter\Word\CamelCaseToSeparator;
|
||||
use Laminas\Filter\Word\CamelCaseToUnderscore;
|
||||
use Laminas\InputFilter\InputFilter;
|
||||
use PUGX\Shortid\Factory as ShortIdFactory;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
||||
|
||||
use function date_default_timezone_get;
|
||||
use function Functional\map;
|
||||
use function Functional\reduce_left;
|
||||
use function is_array;
|
||||
use function print_r;
|
||||
use function Shlinkio\Shlink\Common\buildDateRange;
|
||||
use function sprintf;
|
||||
use function str_repeat;
|
||||
use function strtolower;
|
||||
use function ucfirst;
|
||||
|
||||
function generateRandomShortCode(int $length): string
|
||||
function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string
|
||||
{
|
||||
static $shortIdFactory;
|
||||
if ($shortIdFactory === null) {
|
||||
$shortIdFactory = new ShortIdFactory();
|
||||
}
|
||||
|
||||
$alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
$alphabet = $mode === ShortUrlMode::STRICT
|
||||
? '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
: '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
return $shortIdFactory->generate($length, $alphabet)->serialize();
|
||||
}
|
||||
|
||||
|
@ -143,7 +150,33 @@ function camelCaseToHumanFriendly(string $value): string
|
|||
return ucfirst($filter->filter($value));
|
||||
}
|
||||
|
||||
function camelCaseToSnakeCase(string $value): string
|
||||
{
|
||||
static $filter;
|
||||
if ($filter === null) {
|
||||
$filter = new CamelCaseToUnderscore();
|
||||
}
|
||||
|
||||
return strtolower($filter->filter($value));
|
||||
}
|
||||
|
||||
function toProblemDetailsType(string $errorCode): string
|
||||
{
|
||||
return sprintf('https://shlink.io/api/error/%s', $errorCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string<BackedEnum> $enum
|
||||
* @return string[]
|
||||
*/
|
||||
function enumValues(string $enum): array
|
||||
{
|
||||
static $cache;
|
||||
if ($cache === null) {
|
||||
$cache = [];
|
||||
}
|
||||
|
||||
return $cache[$enum] ?? (
|
||||
$cache[$enum] = map($enum::cases(), static fn (BackedEnum $type) => (string) $type->value)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -18,15 +18,15 @@ class RedirectAction extends AbstractTrackingAction implements StatusCodeInterfa
|
|||
public function __construct(
|
||||
ShortUrlResolverInterface $urlResolver,
|
||||
RequestTrackerInterface $requestTracker,
|
||||
private ShortUrlRedirectionBuilderInterface $redirectionBuilder,
|
||||
private RedirectResponseHelperInterface $redirectResponseHelper,
|
||||
private readonly ShortUrlRedirectionBuilderInterface $redirectionBuilder,
|
||||
private readonly RedirectResponseHelperInterface $redirectResponseHelper,
|
||||
) {
|
||||
parent::__construct($urlResolver, $requestTracker);
|
||||
}
|
||||
|
||||
protected function createSuccessResp(ShortUrl $shortUrl, ServerRequestInterface $request): Response
|
||||
{
|
||||
$longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request->getQueryParams());
|
||||
$longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request);
|
||||
return $this->redirectResponseHelper->buildRedirectResponse($longUrl);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
use function Functional\map;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
|
||||
enum EnvVars: string
|
||||
|
@ -44,6 +43,7 @@ enum EnvVars: string
|
|||
case REDIRECT_CACHE_LIFETIME = 'REDIRECT_CACHE_LIFETIME';
|
||||
case BASE_PATH = 'BASE_PATH';
|
||||
case SHORT_URL_TRAILING_SLASH = 'SHORT_URL_TRAILING_SLASH';
|
||||
case SHORT_URL_MODE = 'SHORT_URL_MODE';
|
||||
case PORT = 'PORT';
|
||||
case TASK_WORKER_NUM = 'TASK_WORKER_NUM';
|
||||
case WEB_WORKER_NUM = 'WEB_WORKER_NUM';
|
||||
|
@ -77,13 +77,4 @@ enum EnvVars: string
|
|||
{
|
||||
return $this->loadFromEnv() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
static $values;
|
||||
return $values ?? ($values = map(self::cases(), static fn (EnvVars $envVar) => $envVar->value));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Config;
|
||||
namespace Shlinkio\Shlink\Core\Config\PostProcessor;
|
||||
|
||||
use function Functional\map;
|
||||
|
|
@ -2,15 +2,15 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Config;
|
||||
namespace Shlinkio\Shlink\Core\Config\PostProcessor;
|
||||
|
||||
use function Functional\map;
|
||||
use function str_replace;
|
||||
|
||||
class MultiSegmentSlugProcessor
|
||||
{
|
||||
private const SINGLE_SHORT_CODE_PATTERN = '{shortCode}';
|
||||
private const MULTI_SHORT_CODE_PATTERN = '{shortCode:.+}';
|
||||
private const SINGLE_SEGMENT_PATTERN = '{shortCode}';
|
||||
private const MULTI_SEGMENT_PATTERN = '{shortCode:.+}';
|
||||
|
||||
public function __invoke(array $config): array
|
||||
{
|
||||
|
@ -21,7 +21,7 @@ class MultiSegmentSlugProcessor
|
|||
|
||||
$config['routes'] = map($config['routes'] ?? [], static function (array $route): array {
|
||||
['path' => $path] = $route;
|
||||
$route['path'] = str_replace(self::SINGLE_SHORT_CODE_PATTERN, self::MULTI_SHORT_CODE_PATTERN, $path);
|
||||
$route['path'] = str_replace(self::SINGLE_SEGMENT_PATTERN, self::MULTI_SEGMENT_PATTERN, $path);
|
||||
return $route;
|
||||
});
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Config\PostProcessor;
|
||||
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use Mezzio\Router\Route;
|
||||
use Shlinkio\Shlink\Core\Action\RedirectAction;
|
||||
use Shlinkio\Shlink\Core\Util\RedirectStatus;
|
||||
|
||||
use function array_values;
|
||||
use function count;
|
||||
use function Functional\partition;
|
||||
|
||||
use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
|
||||
|
||||
class ShortUrlMethodsProcessor
|
||||
{
|
||||
public function __invoke(array $config): array
|
||||
{
|
||||
[$redirectRoutes, $rest] = partition(
|
||||
$config['routes'] ?? [],
|
||||
static fn (array $route) => $route['name'] === RedirectAction::class,
|
||||
);
|
||||
if (count($redirectRoutes) === 0) {
|
||||
return $config;
|
||||
}
|
||||
|
||||
[$redirectRoute] = array_values($redirectRoutes);
|
||||
$redirectStatus = RedirectStatus::tryFrom(
|
||||
$config['redirects']['redirect_status_code'] ?? 0,
|
||||
) ?? DEFAULT_REDIRECT_STATUS_CODE;
|
||||
$redirectRoute['allowed_methods'] = $redirectStatus->isLegacyStatus()
|
||||
? [RequestMethodInterface::METHOD_GET]
|
||||
: Route::HTTP_METHOD_ANY;
|
||||
|
||||
$config['routes'] = [...$rest, $redirectRoute];
|
||||
return $config;
|
||||
}
|
||||
}
|
|
@ -51,7 +51,7 @@ class DomainService implements DomainServiceInterface
|
|||
$repo = $this->em->getRepository(Domain::class);
|
||||
$groups = group(
|
||||
$repo->findDomains($apiKey),
|
||||
fn (Domain $domain) => $domain->getAuthority() === $this->defaultDomain ? 'default' : 'domains',
|
||||
fn (Domain $domain) => $domain->authority === $this->defaultDomain ? 'default' : 'domains',
|
||||
);
|
||||
|
||||
return [first($groups['default'] ?? []), $groups['domains'] ?? []];
|
||||
|
|
|
@ -15,7 +15,7 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec
|
|||
private ?string $regular404Redirect = null;
|
||||
private ?string $invalidShortUrlRedirect = null;
|
||||
|
||||
private function __construct(private string $authority)
|
||||
private function __construct(public readonly string $authority)
|
||||
{
|
||||
}
|
||||
|
||||
|
@ -24,14 +24,9 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec
|
|||
return new self($authority);
|
||||
}
|
||||
|
||||
public function getAuthority(): string
|
||||
{
|
||||
return $this->authority;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): string
|
||||
{
|
||||
return $this->getAuthority();
|
||||
return $this->authority;
|
||||
}
|
||||
|
||||
public function invalidShortUrlRedirect(): ?string
|
||||
|
|
|
@ -20,7 +20,7 @@ final class DomainItem implements JsonSerializable
|
|||
|
||||
public static function forNonDefaultDomain(Domain $domain): self
|
||||
{
|
||||
return new self($domain->getAuthority(), $domain, false);
|
||||
return new self($domain->authority, $domain, false);
|
||||
}
|
||||
|
||||
public static function forDefaultDomain(string $defaultDomain, NotFoundRedirectConfigInterface $config): self
|
||||
|
|
29
module/Core/src/Exception/MalformedBodyException.php
Normal file
29
module/Core/src/Exception/MalformedBodyException.php
Normal file
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Exception;
|
||||
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use JsonException;
|
||||
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
|
||||
use function Shlinkio\Shlink\Core\toProblemDetailsType;
|
||||
|
||||
class MalformedBodyException extends InvalidArgumentException implements ProblemDetailsExceptionInterface
|
||||
{
|
||||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
public static function forInvalidJson(JsonException $prev): self
|
||||
{
|
||||
$e = new self('Provided request does not contain a valid JSON body.', previous: $prev);
|
||||
|
||||
$e->detail = $e->getMessage();
|
||||
$e->title = 'Malformed request body';
|
||||
$e->type = toProblemDetailsType('malformed-request-body');
|
||||
$e->status = StatusCodeInterface::STATUS_BAD_REQUEST;
|
||||
|
||||
return $e;
|
||||
}
|
||||
}
|
28
module/Core/src/Model/DeviceType.php
Normal file
28
module/Core/src/Model/DeviceType.php
Normal file
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Detection\MobileDetect;
|
||||
|
||||
enum DeviceType: string
|
||||
{
|
||||
case ANDROID = 'android';
|
||||
case IOS = 'ios';
|
||||
case DESKTOP = 'desktop';
|
||||
|
||||
public static function matchFromUserAgent(string $userAgent): ?self
|
||||
{
|
||||
$detect = new MobileDetect(null, $userAgent); // @phpstan-ignore-line
|
||||
|
||||
return match (true) {
|
||||
// $detect->is('iOS') && $detect->isTablet() => self::IOS, // TODO To detect iPad only
|
||||
// $detect->is('iOS') && ! $detect->isTablet() => self::IOS, // TODO To detect iPhone only
|
||||
// $detect->is('androidOS') && $detect->isTablet() => self::ANDROID, // TODO To detect Android tablets
|
||||
// $detect->is('androidOS') && ! $detect->isTablet() => self::ANDROID, // TODO To detect Android phones
|
||||
$detect->is('iOS') => self::IOS, // Detects both iPhone and iPad
|
||||
$detect->is('androidOS') => self::ANDROID, // Detects both android phones and android tablets
|
||||
! $detect->isMobile() && ! $detect->isTablet() => self::DESKTOP,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -4,23 +4,22 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\Options;
|
||||
|
||||
use function Functional\contains;
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Shlinkio\Shlink\Core\Util\RedirectStatus;
|
||||
|
||||
use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||
use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
|
||||
|
||||
final class RedirectOptions
|
||||
{
|
||||
public readonly int $redirectStatusCode;
|
||||
public readonly RedirectStatus $redirectStatusCode;
|
||||
public readonly int $redirectCacheLifetime;
|
||||
|
||||
public function __construct(
|
||||
int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE,
|
||||
int $redirectStatusCode = StatusCodeInterface::STATUS_FOUND,
|
||||
int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME,
|
||||
) {
|
||||
$this->redirectStatusCode = contains([301, 302], $redirectStatusCode)
|
||||
? $redirectStatusCode
|
||||
: DEFAULT_REDIRECT_STATUS_CODE;
|
||||
$this->redirectStatusCode = RedirectStatus::tryFrom($redirectStatusCode) ?? DEFAULT_REDIRECT_STATUS_CODE;
|
||||
$this->redirectCacheLifetime = $redirectCacheLifetime > 0
|
||||
? $redirectCacheLifetime
|
||||
: DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||
|
|
|
@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\Options;
|
||||
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
||||
|
||||
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
|
||||
|
||||
final class UrlShortenerOptions
|
||||
|
@ -16,6 +18,12 @@ final class UrlShortenerOptions
|
|||
public readonly bool $appendExtraPath = false,
|
||||
public readonly bool $multiSegmentSlugsEnabled = false,
|
||||
public readonly bool $trailingSlashEnabled = false,
|
||||
public readonly ShortUrlMode $mode = ShortUrlMode::STRICT,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isLooselyMode(): bool
|
||||
{
|
||||
return $this->mode === ShortUrlMode::LOOSELY;
|
||||
}
|
||||
}
|
||||
|
|
34
module/Core/src/ShortUrl/Entity/DeviceLongUrl.php
Normal file
34
module/Core/src/ShortUrl/Entity/DeviceLongUrl.php
Normal file
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Entity;
|
||||
|
||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\DeviceLongUrlPair;
|
||||
|
||||
class DeviceLongUrl extends AbstractEntity
|
||||
{
|
||||
private function __construct(
|
||||
private readonly ShortUrl $shortUrl, // No need to read this field. It's used by doctrine
|
||||
public readonly DeviceType $deviceType,
|
||||
private string $longUrl,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromShortUrlAndPair(ShortUrl $shortUrl, DeviceLongUrlPair $pair): self
|
||||
{
|
||||
return new self($shortUrl, $pair->deviceType, $pair->longUrl);
|
||||
}
|
||||
|
||||
public function longUrl(): string
|
||||
{
|
||||
return $this->longUrl;
|
||||
}
|
||||
|
||||
public function updateLongUrl(string $longUrl): void
|
||||
{
|
||||
$this->longUrl = $longUrl;
|
||||
}
|
||||
}
|
|
@ -12,8 +12,11 @@ use Doctrine\Common\Collections\Selectable;
|
|||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
|
||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\DeviceLongUrlPair;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver;
|
||||
|
@ -23,7 +26,10 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitType;
|
|||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
use function array_fill_keys;
|
||||
use function count;
|
||||
use function Functional\map;
|
||||
use function Shlinkio\Shlink\Core\enumValues;
|
||||
use function Shlinkio\Shlink\Core\generateRandomShortCode;
|
||||
use function Shlinkio\Shlink\Core\normalizeDate;
|
||||
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
|
||||
|
@ -35,6 +41,8 @@ class ShortUrl extends AbstractEntity
|
|||
private Chronos $dateCreated;
|
||||
/** @var Collection<int, Visit> */
|
||||
private Collection $visits;
|
||||
/** @var Collection<string, DeviceLongUrl> */
|
||||
private Collection $deviceLongUrls;
|
||||
/** @var Collection<int, Tag> */
|
||||
private Collection $tags;
|
||||
private ?Chronos $validSince = null;
|
||||
|
@ -55,11 +63,18 @@ class ShortUrl extends AbstractEntity
|
|||
{
|
||||
}
|
||||
|
||||
public static function createEmpty(): self
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public static function createFake(): self
|
||||
{
|
||||
return self::create(ShortUrlCreation::createEmpty());
|
||||
return self::withLongUrl('foo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param non-empty-string $longUrl
|
||||
* @internal
|
||||
*/
|
||||
public static function withLongUrl(string $longUrl): self
|
||||
{
|
||||
return self::create(ShortUrlCreation::fromRawData([ShortUrlInputFilter::LONG_URL => $longUrl]));
|
||||
|
@ -75,19 +90,26 @@ class ShortUrl extends AbstractEntity
|
|||
$instance->longUrl = $creation->getLongUrl();
|
||||
$instance->dateCreated = Chronos::now();
|
||||
$instance->visits = new ArrayCollection();
|
||||
$instance->tags = $relationResolver->resolveTags($creation->getTags());
|
||||
$instance->validSince = $creation->getValidSince();
|
||||
$instance->validUntil = $creation->getValidUntil();
|
||||
$instance->maxVisits = $creation->getMaxVisits();
|
||||
$instance->deviceLongUrls = new ArrayCollection(map(
|
||||
$creation->deviceLongUrls,
|
||||
fn (DeviceLongUrlPair $pair) => DeviceLongUrl::fromShortUrlAndPair($instance, $pair),
|
||||
));
|
||||
$instance->tags = $relationResolver->resolveTags($creation->tags);
|
||||
$instance->validSince = $creation->validSince;
|
||||
$instance->validUntil = $creation->validUntil;
|
||||
$instance->maxVisits = $creation->maxVisits;
|
||||
$instance->customSlugWasProvided = $creation->hasCustomSlug();
|
||||
$instance->shortCodeLength = $creation->getShortCodeLength();
|
||||
$instance->shortCode = $creation->getCustomSlug() ?? generateRandomShortCode($instance->shortCodeLength);
|
||||
$instance->domain = $relationResolver->resolveDomain($creation->getDomain());
|
||||
$instance->authorApiKey = $creation->getApiKey();
|
||||
$instance->title = $creation->getTitle();
|
||||
$instance->titleWasAutoResolved = $creation->titleWasAutoResolved();
|
||||
$instance->crawlable = $creation->isCrawlable();
|
||||
$instance->forwardQuery = $creation->forwardQuery();
|
||||
$instance->shortCodeLength = $creation->shortCodeLength;
|
||||
$instance->shortCode = $creation->customSlug ?? generateRandomShortCode(
|
||||
$instance->shortCodeLength,
|
||||
$creation->shortUrlMode,
|
||||
);
|
||||
$instance->domain = $relationResolver->resolveDomain($creation->domain);
|
||||
$instance->authorApiKey = $creation->apiKey;
|
||||
$instance->title = $creation->title;
|
||||
$instance->titleWasAutoResolved = $creation->titleWasAutoResolved;
|
||||
$instance->crawlable = $creation->crawlable;
|
||||
$instance->forwardQuery = $creation->forwardQuery;
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
@ -120,11 +142,68 @@ class ShortUrl extends AbstractEntity
|
|||
return $instance;
|
||||
}
|
||||
|
||||
public function update(
|
||||
ShortUrlEdition $shortUrlEdit,
|
||||
?ShortUrlRelationResolverInterface $relationResolver = null,
|
||||
): void {
|
||||
if ($shortUrlEdit->validSinceWasProvided()) {
|
||||
$this->validSince = $shortUrlEdit->validSince;
|
||||
}
|
||||
if ($shortUrlEdit->validUntilWasProvided()) {
|
||||
$this->validUntil = $shortUrlEdit->validUntil;
|
||||
}
|
||||
if ($shortUrlEdit->maxVisitsWasProvided()) {
|
||||
$this->maxVisits = $shortUrlEdit->maxVisits;
|
||||
}
|
||||
if ($shortUrlEdit->longUrlWasProvided()) {
|
||||
$this->longUrl = $shortUrlEdit->longUrl ?? $this->longUrl;
|
||||
}
|
||||
if ($shortUrlEdit->tagsWereProvided()) {
|
||||
$relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver();
|
||||
$this->tags = $relationResolver->resolveTags($shortUrlEdit->tags);
|
||||
}
|
||||
if ($shortUrlEdit->crawlableWasProvided()) {
|
||||
$this->crawlable = $shortUrlEdit->crawlable;
|
||||
}
|
||||
if (
|
||||
$this->title === null
|
||||
|| $shortUrlEdit->titleWasProvided()
|
||||
|| ($this->titleWasAutoResolved && $shortUrlEdit->titleWasAutoResolved())
|
||||
) {
|
||||
$this->title = $shortUrlEdit->title;
|
||||
$this->titleWasAutoResolved = $shortUrlEdit->titleWasAutoResolved();
|
||||
}
|
||||
if ($shortUrlEdit->forwardQueryWasProvided()) {
|
||||
$this->forwardQuery = $shortUrlEdit->forwardQuery;
|
||||
}
|
||||
|
||||
// Update device long URLs, removing, editing or creating where appropriate
|
||||
foreach ($shortUrlEdit->devicesToRemove as $deviceType) {
|
||||
$this->deviceLongUrls->remove($deviceType->value);
|
||||
}
|
||||
foreach ($shortUrlEdit->deviceLongUrls as $deviceLongUrlPair) {
|
||||
$key = $deviceLongUrlPair->deviceType->value;
|
||||
$deviceLongUrl = $this->deviceLongUrls->get($key);
|
||||
|
||||
if ($deviceLongUrl !== null) {
|
||||
$deviceLongUrl->updateLongUrl($deviceLongUrlPair->longUrl);
|
||||
} else {
|
||||
$this->deviceLongUrls->set($key, DeviceLongUrl::fromShortUrlAndPair($this, $deviceLongUrlPair));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getLongUrl(): string
|
||||
{
|
||||
return $this->longUrl;
|
||||
}
|
||||
|
||||
public function longUrlForDevice(?DeviceType $deviceType): string
|
||||
{
|
||||
$deviceLongUrl = $deviceType === null ? null : $this->deviceLongUrls->get($deviceType->value);
|
||||
return $deviceLongUrl?->longUrl() ?? $this->longUrl;
|
||||
}
|
||||
|
||||
public function getShortCode(): string
|
||||
{
|
||||
return $this->shortCode;
|
||||
|
@ -218,46 +297,10 @@ class ShortUrl extends AbstractEntity
|
|||
return $this->forwardQuery;
|
||||
}
|
||||
|
||||
public function update(
|
||||
ShortUrlEdition $shortUrlEdit,
|
||||
?ShortUrlRelationResolverInterface $relationResolver = null,
|
||||
): void {
|
||||
if ($shortUrlEdit->validSinceWasProvided()) {
|
||||
$this->validSince = $shortUrlEdit->validSince();
|
||||
}
|
||||
if ($shortUrlEdit->validUntilWasProvided()) {
|
||||
$this->validUntil = $shortUrlEdit->validUntil();
|
||||
}
|
||||
if ($shortUrlEdit->maxVisitsWasProvided()) {
|
||||
$this->maxVisits = $shortUrlEdit->maxVisits();
|
||||
}
|
||||
if ($shortUrlEdit->longUrlWasProvided()) {
|
||||
$this->longUrl = $shortUrlEdit->longUrl() ?? $this->longUrl;
|
||||
}
|
||||
if ($shortUrlEdit->tagsWereProvided()) {
|
||||
$relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver();
|
||||
$this->tags = $relationResolver->resolveTags($shortUrlEdit->tags());
|
||||
}
|
||||
if ($shortUrlEdit->crawlableWasProvided()) {
|
||||
$this->crawlable = $shortUrlEdit->crawlable();
|
||||
}
|
||||
if (
|
||||
$this->title === null
|
||||
|| $shortUrlEdit->titleWasProvided()
|
||||
|| ($this->titleWasAutoResolved && $shortUrlEdit->titleWasAutoResolved())
|
||||
) {
|
||||
$this->title = $shortUrlEdit->title();
|
||||
$this->titleWasAutoResolved = $shortUrlEdit->titleWasAutoResolved();
|
||||
}
|
||||
if ($shortUrlEdit->forwardQueryWasProvided()) {
|
||||
$this->forwardQuery = $shortUrlEdit->forwardQuery();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ShortCodeCannotBeRegeneratedException
|
||||
*/
|
||||
public function regenerateShortCode(): void
|
||||
public function regenerateShortCode(ShortUrlMode $mode): void
|
||||
{
|
||||
// In ShortUrls where a custom slug was provided, throw error, unless it is an imported one
|
||||
if ($this->customSlugWasProvided && $this->importSource === null) {
|
||||
|
@ -269,7 +312,7 @@ class ShortUrl extends AbstractEntity
|
|||
throw ShortCodeCannotBeRegeneratedException::forShortUrlAlreadyPersisted();
|
||||
}
|
||||
|
||||
$this->shortCode = generateRandomShortCode($this->shortCodeLength);
|
||||
$this->shortCode = generateRandomShortCode($this->shortCodeLength, $mode);
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
|
@ -292,4 +335,14 @@ class ShortUrl extends AbstractEntity
|
|||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function deviceLongUrls(): array
|
||||
{
|
||||
$data = array_fill_keys(enumValues(DeviceType::class), null);
|
||||
foreach ($this->deviceLongUrls as $deviceUrl) {
|
||||
$data[$deviceUrl->deviceType->value] = $deviceUrl->longUrl();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,14 +5,17 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository;
|
||||
|
||||
class ShortCodeUniquenessHelper implements ShortCodeUniquenessHelperInterface
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly UrlShortenerOptions $options,
|
||||
) {
|
||||
}
|
||||
|
||||
public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool
|
||||
|
@ -29,7 +32,7 @@ class ShortCodeUniquenessHelper implements ShortCodeUniquenessHelperInterface
|
|||
return false;
|
||||
}
|
||||
|
||||
$shortUrlToBeCreated->regenerateShortCode();
|
||||
$shortUrlToBeCreated->regenerateShortCode($this->options->mode);
|
||||
return $this->ensureShortCodeUniqueness($shortUrlToBeCreated, $hasCustomSlug);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,8 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
|
|||
use GuzzleHttp\Psr7\Query;
|
||||
use Laminas\Stdlib\ArrayUtils;
|
||||
use League\Uri\Uri;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
|
||||
|
@ -14,13 +16,18 @@ use function sprintf;
|
|||
|
||||
class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface
|
||||
{
|
||||
public function __construct(private TrackingOptions $trackingOptions)
|
||||
public function __construct(private readonly TrackingOptions $trackingOptions)
|
||||
{
|
||||
}
|
||||
|
||||
public function buildShortUrlRedirect(ShortUrl $shortUrl, array $currentQuery, ?string $extraPath = null): string
|
||||
{
|
||||
$uri = Uri::createFromString($shortUrl->getLongUrl());
|
||||
public function buildShortUrlRedirect(
|
||||
ShortUrl $shortUrl,
|
||||
ServerRequestInterface $request,
|
||||
?string $extraPath = null,
|
||||
): string {
|
||||
$currentQuery = $request->getQueryParams();
|
||||
$device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent'));
|
||||
$uri = Uri::createFromString($shortUrl->longUrlForDevice($device));
|
||||
$shouldForwardQuery = $shortUrl->forwardQuery();
|
||||
|
||||
return $uri
|
||||
|
|
|
@ -4,9 +4,14 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
|
||||
interface ShortUrlRedirectionBuilderInterface
|
||||
{
|
||||
public function buildShortUrlRedirect(ShortUrl $shortUrl, array $currentQuery, ?string $extraPath = null): string;
|
||||
public function buildShortUrlRedirect(
|
||||
ShortUrl $shortUrl,
|
||||
ServerRequestInterface $request,
|
||||
?string $extraPath = null,
|
||||
): string;
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ use function sprintf;
|
|||
|
||||
class ShortUrlStringifier implements ShortUrlStringifierInterface
|
||||
{
|
||||
public function __construct(private array $domainConfig, private string $basePath = '')
|
||||
public function __construct(private readonly array $domainConfig, private readonly string $basePath = '')
|
||||
{
|
||||
}
|
||||
|
||||
|
@ -28,6 +28,6 @@ class ShortUrlStringifier implements ShortUrlStringifierInterface
|
|||
|
||||
private function resolveDomain(ShortUrl $shortUrl): string
|
||||
{
|
||||
return $shortUrl->getDomain()?->getAuthority() ?? $this->domainConfig['hostname'] ?? '';
|
||||
return $shortUrl->getDomain()?->authority ?? $this->domainConfig['hostname'] ?? '';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,14 +4,23 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Helper;
|
||||
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
|
||||
|
||||
class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface
|
||||
{
|
||||
public function __construct(private UrlValidatorInterface $urlValidator)
|
||||
public function __construct(private readonly UrlValidatorInterface $urlValidator)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated TODO Rename to processTitle once URL validation is removed with Shlink 4.0.0
|
||||
* Move relevant logic from URL validator here.
|
||||
* @template T of TitleResolutionModelInterface
|
||||
* @param T $data
|
||||
* @return T
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface
|
||||
{
|
||||
if ($data->hasTitle()) {
|
||||
|
|
|
@ -9,6 +9,10 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
|||
interface ShortUrlTitleResolutionHelperInterface
|
||||
{
|
||||
/**
|
||||
* @deprecated TODO Rename to processTitle once URL validation is removed with Shlink 4.0.0
|
||||
* @template T of TitleResolutionModelInterface
|
||||
* @param T $data
|
||||
* @return T
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface;
|
||||
|
|
|
@ -10,7 +10,8 @@ interface TitleResolutionModelInterface
|
|||
|
||||
public function getLongUrl(): string;
|
||||
|
||||
/** @deprecated */
|
||||
public function doValidateUrl(): bool;
|
||||
|
||||
public function withResolvedTitle(string $title): self;
|
||||
public function withResolvedTitle(string $title): static;
|
||||
}
|
||||
|
|
|
@ -68,7 +68,6 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface
|
|||
int $shortCodeSegments = 1,
|
||||
): ResponseInterface {
|
||||
$uri = $request->getUri();
|
||||
$query = $request->getQueryParams();
|
||||
[$potentialShortCode, $extraPath] = $this->resolvePotentialShortCodeAndExtraPath($uri, $shortCodeSegments);
|
||||
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($potentialShortCode, $uri->getAuthority());
|
||||
|
||||
|
@ -76,7 +75,7 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface
|
|||
$shortUrl = $this->resolver->resolveEnabledShortUrl($identifier);
|
||||
$this->requestTracker->trackIfApplicable($shortUrl, $request);
|
||||
|
||||
$longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query, $extraPath);
|
||||
$longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request, $extraPath);
|
||||
return $this->redirectResponseHelper->buildRedirectResponse($longUrl);
|
||||
} catch (ShortUrlNotFoundException) {
|
||||
if ($extraPath === null || ! $this->urlShortenerOptions->multiSegmentSlugsEnabled) {
|
||||
|
|
47
module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php
Normal file
47
module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php
Normal file
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
|
||||
|
||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
|
||||
use function array_values;
|
||||
use function Functional\group;
|
||||
use function Functional\map;
|
||||
use function trim;
|
||||
|
||||
final class DeviceLongUrlPair
|
||||
{
|
||||
private function __construct(public readonly DeviceType $deviceType, public readonly string $longUrl)
|
||||
{
|
||||
}
|
||||
|
||||
public static function fromRawTypeAndLongUrl(string $type, string $longUrl): self
|
||||
{
|
||||
return new self(DeviceType::from($type), trim($longUrl));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array with two values.
|
||||
* * The first one is a list of mapped instances for those entries in the map with non-null value
|
||||
* * The second is a list of DeviceTypes which have been provided with value null
|
||||
*
|
||||
* @param array<string, string> $map
|
||||
* @return array{array<string, self>, DeviceType[]}
|
||||
*/
|
||||
public static function fromMapToChangeSet(array $map): array
|
||||
{
|
||||
$typesWithNullUrl = group($map, static fn (?string $longUrl) => $longUrl === null ? 'remove' : 'keep');
|
||||
$deviceTypesToRemove = array_values(map(
|
||||
$typesWithNullUrl['remove'] ?? [],
|
||||
static fn ($_, string $deviceType) => DeviceType::from($deviceType),
|
||||
));
|
||||
$pairsToKeep = map(
|
||||
$typesWithNullUrl['keep'] ?? [],
|
||||
fn (string $longUrl, string $deviceType) => self::fromRawTypeAndLongUrl($deviceType, $longUrl),
|
||||
);
|
||||
|
||||
return [$pairsToKeep, $deviceTypesToRemove];
|
||||
}
|
||||
}
|
|
@ -3,7 +3,6 @@
|
|||
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
|
||||
|
||||
use function Functional\contains;
|
||||
use function Functional\map;
|
||||
|
||||
enum OrderableField: string
|
||||
{
|
||||
|
@ -14,14 +13,6 @@ enum OrderableField: string
|
|||
case VISITS = 'visits';
|
||||
case NON_BOT_VISITS = 'nonBotVisits';
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return map(self::cases(), static fn (OrderableField $field) => $field->value);
|
||||
}
|
||||
|
||||
public static function isBasicField(string $value): bool
|
||||
{
|
||||
return contains(
|
||||
|
|
|
@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model;
|
|||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
@ -19,72 +20,91 @@ use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
|
|||
|
||||
final class ShortUrlCreation implements TitleResolutionModelInterface
|
||||
{
|
||||
private string $longUrl;
|
||||
private ?Chronos $validSince = null;
|
||||
private ?Chronos $validUntil = null;
|
||||
private ?string $customSlug = null;
|
||||
private ?int $maxVisits = null;
|
||||
private ?bool $findIfExists = null;
|
||||
private ?string $domain = null;
|
||||
private int $shortCodeLength = 5;
|
||||
private bool $validateUrl = false;
|
||||
private ?ApiKey $apiKey = null;
|
||||
private array $tags = [];
|
||||
private ?string $title = null;
|
||||
private bool $titleWasAutoResolved = false;
|
||||
private bool $crawlable = false;
|
||||
private bool $forwardQuery = true;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public static function createEmpty(): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->longUrl = '';
|
||||
|
||||
return $instance;
|
||||
/**
|
||||
* @param string[] $tags
|
||||
* @param DeviceLongUrlPair[] $deviceLongUrls
|
||||
*/
|
||||
private function __construct(
|
||||
public readonly string $longUrl,
|
||||
public readonly ShortUrlMode $shortUrlMode,
|
||||
public readonly array $deviceLongUrls = [],
|
||||
public readonly ?Chronos $validSince = null,
|
||||
public readonly ?Chronos $validUntil = null,
|
||||
public readonly ?string $customSlug = null,
|
||||
public readonly ?int $maxVisits = null,
|
||||
public readonly bool $findIfExists = false,
|
||||
public readonly ?string $domain = null,
|
||||
public readonly int $shortCodeLength = 5,
|
||||
/** @deprecated */
|
||||
public readonly bool $validateUrl = false,
|
||||
public readonly ?ApiKey $apiKey = null,
|
||||
public readonly array $tags = [],
|
||||
public readonly ?string $title = null,
|
||||
public readonly bool $titleWasAutoResolved = false,
|
||||
public readonly bool $crawlable = false,
|
||||
public readonly bool $forwardQuery = true,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public static function fromRawData(array $data): self
|
||||
public static function fromRawData(array $data, ?UrlShortenerOptions $options = null): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->validateAndInit($data);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function validateAndInit(array $data): void
|
||||
{
|
||||
$inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data);
|
||||
$options = $options ?? new UrlShortenerOptions();
|
||||
$inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data, $options);
|
||||
if (! $inputFilter->isValid()) {
|
||||
throw ValidationException::fromInputFilter($inputFilter);
|
||||
}
|
||||
|
||||
$this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL);
|
||||
$this->validSince = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
|
||||
$this->validUntil = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
|
||||
$this->customSlug = $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG);
|
||||
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS);
|
||||
$this->findIfExists = $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS);
|
||||
$this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false;
|
||||
$this->domain = getNonEmptyOptionalValueFromInputFilter($inputFilter, ShortUrlInputFilter::DOMAIN);
|
||||
$this->shortCodeLength = getOptionalIntFromInputFilter(
|
||||
$inputFilter,
|
||||
ShortUrlInputFilter::SHORT_CODE_LENGTH,
|
||||
) ?? DEFAULT_SHORT_CODES_LENGTH;
|
||||
$this->apiKey = $inputFilter->getValue(ShortUrlInputFilter::API_KEY);
|
||||
$this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS);
|
||||
$this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE);
|
||||
$this->crawlable = $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE);
|
||||
$this->forwardQuery = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true;
|
||||
[$deviceLongUrls] = DeviceLongUrlPair::fromMapToChangeSet(
|
||||
$inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [],
|
||||
);
|
||||
|
||||
return new self(
|
||||
longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL),
|
||||
shortUrlMode: $options->mode,
|
||||
deviceLongUrls: $deviceLongUrls,
|
||||
validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)),
|
||||
validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)),
|
||||
customSlug: $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG),
|
||||
maxVisits: getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS),
|
||||
findIfExists: $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS) ?? false,
|
||||
domain: getNonEmptyOptionalValueFromInputFilter($inputFilter, ShortUrlInputFilter::DOMAIN),
|
||||
shortCodeLength: getOptionalIntFromInputFilter(
|
||||
$inputFilter,
|
||||
ShortUrlInputFilter::SHORT_CODE_LENGTH,
|
||||
) ?? DEFAULT_SHORT_CODES_LENGTH,
|
||||
validateUrl: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false,
|
||||
apiKey: $inputFilter->getValue(ShortUrlInputFilter::API_KEY),
|
||||
tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS),
|
||||
title: $inputFilter->getValue(ShortUrlInputFilter::TITLE),
|
||||
crawlable: $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE),
|
||||
forwardQuery: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
public function withResolvedTitle(string $title): static
|
||||
{
|
||||
return new self(
|
||||
longUrl: $this->longUrl,
|
||||
shortUrlMode: $this->shortUrlMode,
|
||||
deviceLongUrls: $this->deviceLongUrls,
|
||||
validSince: $this->validSince,
|
||||
validUntil: $this->validUntil,
|
||||
customSlug: $this->customSlug,
|
||||
maxVisits: $this->maxVisits,
|
||||
findIfExists: $this->findIfExists,
|
||||
domain: $this->domain,
|
||||
shortCodeLength: $this->shortCodeLength,
|
||||
validateUrl: $this->validateUrl,
|
||||
apiKey: $this->apiKey,
|
||||
tags: $this->tags,
|
||||
title: $title,
|
||||
titleWasAutoResolved: true,
|
||||
crawlable: $this->crawlable,
|
||||
forwardQuery: $this->forwardQuery,
|
||||
);
|
||||
}
|
||||
|
||||
public function getLongUrl(): string
|
||||
|
@ -92,115 +112,39 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
|
|||
return $this->longUrl;
|
||||
}
|
||||
|
||||
public function getValidSince(): ?Chronos
|
||||
{
|
||||
return $this->validSince;
|
||||
}
|
||||
|
||||
public function hasValidSince(): bool
|
||||
{
|
||||
return $this->validSince !== null;
|
||||
}
|
||||
|
||||
public function getValidUntil(): ?Chronos
|
||||
{
|
||||
return $this->validUntil;
|
||||
}
|
||||
|
||||
public function hasValidUntil(): bool
|
||||
{
|
||||
return $this->validUntil !== null;
|
||||
}
|
||||
|
||||
public function getCustomSlug(): ?string
|
||||
{
|
||||
return $this->customSlug;
|
||||
}
|
||||
|
||||
public function hasCustomSlug(): bool
|
||||
{
|
||||
return $this->customSlug !== null;
|
||||
}
|
||||
|
||||
public function getMaxVisits(): ?int
|
||||
{
|
||||
return $this->maxVisits;
|
||||
}
|
||||
|
||||
public function hasMaxVisits(): bool
|
||||
{
|
||||
return $this->maxVisits !== null;
|
||||
}
|
||||
|
||||
public function findIfExists(): bool
|
||||
{
|
||||
return (bool) $this->findIfExists;
|
||||
}
|
||||
|
||||
public function hasDomain(): bool
|
||||
{
|
||||
return $this->domain !== null;
|
||||
}
|
||||
|
||||
public function getDomain(): ?string
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
public function getShortCodeLength(): int
|
||||
{
|
||||
return $this->shortCodeLength;
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
public function doValidateUrl(): bool
|
||||
{
|
||||
return $this->validateUrl;
|
||||
}
|
||||
|
||||
public function getApiKey(): ?ApiKey
|
||||
{
|
||||
return $this->apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getTags(): array
|
||||
{
|
||||
return $this->tags;
|
||||
}
|
||||
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function hasTitle(): bool
|
||||
{
|
||||
return $this->title !== null;
|
||||
}
|
||||
|
||||
public function titleWasAutoResolved(): bool
|
||||
{
|
||||
return $this->titleWasAutoResolved;
|
||||
}
|
||||
|
||||
public function withResolvedTitle(string $title): self
|
||||
{
|
||||
$copy = clone $this;
|
||||
$copy->title = $title;
|
||||
$copy->titleWasAutoResolved = true;
|
||||
|
||||
return $copy;
|
||||
}
|
||||
|
||||
public function isCrawlable(): bool
|
||||
{
|
||||
return $this->crawlable;
|
||||
}
|
||||
|
||||
public function forwardQuery(): bool
|
||||
{
|
||||
return $this->forwardQuery;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model;
|
|||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
|
||||
|
||||
|
@ -16,77 +17,102 @@ use function Shlinkio\Shlink\Core\normalizeOptionalDate;
|
|||
|
||||
final class ShortUrlEdition implements TitleResolutionModelInterface
|
||||
{
|
||||
private bool $longUrlPropWasProvided = false;
|
||||
private ?string $longUrl = null;
|
||||
private bool $validSincePropWasProvided = false;
|
||||
private ?Chronos $validSince = null;
|
||||
private bool $validUntilPropWasProvided = false;
|
||||
private ?Chronos $validUntil = null;
|
||||
private bool $maxVisitsPropWasProvided = false;
|
||||
private ?int $maxVisits = null;
|
||||
private bool $tagsPropWasProvided = false;
|
||||
private array $tags = [];
|
||||
private bool $titlePropWasProvided = false;
|
||||
private ?string $title = null;
|
||||
private bool $titleWasAutoResolved = false;
|
||||
private bool $validateUrl = false;
|
||||
private bool $crawlablePropWasProvided = false;
|
||||
private bool $crawlable = false;
|
||||
private bool $forwardQueryPropWasProvided = false;
|
||||
private bool $forwardQuery = true;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
/**
|
||||
* @param string[] $tags
|
||||
* @param DeviceLongUrlPair[] $deviceLongUrls
|
||||
* @param DeviceType[] $devicesToRemove
|
||||
*/
|
||||
private function __construct(
|
||||
private readonly bool $longUrlPropWasProvided = false,
|
||||
public readonly ?string $longUrl = null,
|
||||
public readonly array $deviceLongUrls = [],
|
||||
public readonly array $devicesToRemove = [],
|
||||
private readonly bool $validSincePropWasProvided = false,
|
||||
public readonly ?Chronos $validSince = null,
|
||||
private readonly bool $validUntilPropWasProvided = false,
|
||||
public readonly ?Chronos $validUntil = null,
|
||||
private readonly bool $maxVisitsPropWasProvided = false,
|
||||
public readonly ?int $maxVisits = null,
|
||||
private readonly bool $tagsPropWasProvided = false,
|
||||
public readonly array $tags = [],
|
||||
private readonly bool $titlePropWasProvided = false,
|
||||
public readonly ?string $title = null,
|
||||
public readonly bool $titleWasAutoResolved = false,
|
||||
/** @deprecated */
|
||||
public readonly bool $validateUrl = false,
|
||||
private readonly bool $crawlablePropWasProvided = false,
|
||||
public readonly bool $crawlable = false,
|
||||
private readonly bool $forwardQueryPropWasProvided = false,
|
||||
public readonly bool $forwardQuery = true,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public static function fromRawData(array $data): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->validateAndInit($data);
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function validateAndInit(array $data): void
|
||||
{
|
||||
$inputFilter = ShortUrlInputFilter::withNonRequiredLongUrl($data);
|
||||
if (! $inputFilter->isValid()) {
|
||||
throw ValidationException::fromInputFilter($inputFilter);
|
||||
}
|
||||
|
||||
$this->longUrlPropWasProvided = array_key_exists(ShortUrlInputFilter::LONG_URL, $data);
|
||||
$this->validSincePropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_SINCE, $data);
|
||||
$this->validUntilPropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data);
|
||||
$this->maxVisitsPropWasProvided = array_key_exists(ShortUrlInputFilter::MAX_VISITS, $data);
|
||||
$this->tagsPropWasProvided = array_key_exists(ShortUrlInputFilter::TAGS, $data);
|
||||
$this->titlePropWasProvided = array_key_exists(ShortUrlInputFilter::TITLE, $data);
|
||||
$this->crawlablePropWasProvided = array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data);
|
||||
$this->forwardQueryPropWasProvided = array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data);
|
||||
[$deviceLongUrls, $devicesToRemove] = DeviceLongUrlPair::fromMapToChangeSet(
|
||||
$inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [],
|
||||
);
|
||||
|
||||
$this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL);
|
||||
$this->validSince = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
|
||||
$this->validUntil = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
|
||||
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS);
|
||||
$this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false;
|
||||
$this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS);
|
||||
$this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE);
|
||||
$this->crawlable = $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE);
|
||||
$this->forwardQuery = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true;
|
||||
return new self(
|
||||
longUrlPropWasProvided: array_key_exists(ShortUrlInputFilter::LONG_URL, $data),
|
||||
longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL),
|
||||
deviceLongUrls: $deviceLongUrls,
|
||||
devicesToRemove: $devicesToRemove,
|
||||
validSincePropWasProvided: array_key_exists(ShortUrlInputFilter::VALID_SINCE, $data),
|
||||
validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)),
|
||||
validUntilPropWasProvided: array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data),
|
||||
validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)),
|
||||
maxVisitsPropWasProvided: array_key_exists(ShortUrlInputFilter::MAX_VISITS, $data),
|
||||
maxVisits: getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS),
|
||||
tagsPropWasProvided: array_key_exists(ShortUrlInputFilter::TAGS, $data),
|
||||
tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS),
|
||||
titlePropWasProvided: array_key_exists(ShortUrlInputFilter::TITLE, $data),
|
||||
title: $inputFilter->getValue(ShortUrlInputFilter::TITLE),
|
||||
validateUrl: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false,
|
||||
crawlablePropWasProvided: array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data),
|
||||
crawlable: $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE),
|
||||
forwardQueryPropWasProvided: array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data),
|
||||
forwardQuery: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
public function longUrl(): ?string
|
||||
public function withResolvedTitle(string $title): static
|
||||
{
|
||||
return $this->longUrl;
|
||||
return new self(
|
||||
longUrlPropWasProvided: $this->longUrlPropWasProvided,
|
||||
longUrl: $this->longUrl,
|
||||
deviceLongUrls: $this->deviceLongUrls,
|
||||
devicesToRemove: $this->devicesToRemove,
|
||||
validSincePropWasProvided: $this->validSincePropWasProvided,
|
||||
validSince: $this->validSince,
|
||||
validUntilPropWasProvided: $this->validUntilPropWasProvided,
|
||||
validUntil: $this->validUntil,
|
||||
maxVisitsPropWasProvided: $this->maxVisitsPropWasProvided,
|
||||
maxVisits: $this->maxVisits,
|
||||
tagsPropWasProvided: $this->tagsPropWasProvided,
|
||||
tags: $this->tags,
|
||||
titlePropWasProvided: $this->titlePropWasProvided,
|
||||
title: $title,
|
||||
titleWasAutoResolved: true,
|
||||
validateUrl: $this->validateUrl,
|
||||
crawlablePropWasProvided: $this->crawlablePropWasProvided,
|
||||
crawlable: $this->crawlable,
|
||||
forwardQueryPropWasProvided: $this->forwardQueryPropWasProvided,
|
||||
forwardQuery: $this->forwardQuery,
|
||||
);
|
||||
}
|
||||
|
||||
public function getLongUrl(): string
|
||||
{
|
||||
return $this->longUrl() ?? '';
|
||||
return $this->longUrl ?? '';
|
||||
}
|
||||
|
||||
public function longUrlWasProvided(): bool
|
||||
|
@ -94,54 +120,26 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
|
|||
return $this->longUrlPropWasProvided && $this->longUrl !== null;
|
||||
}
|
||||
|
||||
public function validSince(): ?Chronos
|
||||
{
|
||||
return $this->validSince;
|
||||
}
|
||||
|
||||
public function validSinceWasProvided(): bool
|
||||
{
|
||||
return $this->validSincePropWasProvided;
|
||||
}
|
||||
|
||||
public function validUntil(): ?Chronos
|
||||
{
|
||||
return $this->validUntil;
|
||||
}
|
||||
|
||||
public function validUntilWasProvided(): bool
|
||||
{
|
||||
return $this->validUntilPropWasProvided;
|
||||
}
|
||||
|
||||
public function maxVisits(): ?int
|
||||
{
|
||||
return $this->maxVisits;
|
||||
}
|
||||
|
||||
public function maxVisitsWasProvided(): bool
|
||||
{
|
||||
return $this->maxVisitsPropWasProvided;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function tags(): array
|
||||
{
|
||||
return $this->tags;
|
||||
}
|
||||
|
||||
public function tagsWereProvided(): bool
|
||||
{
|
||||
return $this->tagsPropWasProvided;
|
||||
}
|
||||
|
||||
public function title(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function titleWasProvided(): bool
|
||||
{
|
||||
return $this->titlePropWasProvided;
|
||||
|
@ -157,35 +155,17 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
|
|||
return $this->titleWasAutoResolved;
|
||||
}
|
||||
|
||||
public function withResolvedTitle(string $title): self
|
||||
{
|
||||
$copy = clone $this;
|
||||
$copy->title = $title;
|
||||
$copy->titleWasAutoResolved = true;
|
||||
|
||||
return $copy;
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
public function doValidateUrl(): bool
|
||||
{
|
||||
return $this->validateUrl;
|
||||
}
|
||||
|
||||
public function crawlable(): bool
|
||||
{
|
||||
return $this->crawlable;
|
||||
}
|
||||
|
||||
public function crawlableWasProvided(): bool
|
||||
{
|
||||
return $this->crawlablePropWasProvided;
|
||||
}
|
||||
|
||||
public function forwardQuery(): bool
|
||||
{
|
||||
return $this->forwardQuery;
|
||||
}
|
||||
|
||||
public function forwardQueryWasProvided(): bool
|
||||
{
|
||||
return $this->forwardQueryPropWasProvided;
|
||||
|
|
|
@ -45,7 +45,7 @@ final class ShortUrlIdentifier
|
|||
public static function fromShortUrl(ShortUrl $shortUrl): self
|
||||
{
|
||||
$domain = $shortUrl->getDomain();
|
||||
$domainAuthority = $domain?->getAuthority();
|
||||
$domainAuthority = $domain?->authority;
|
||||
|
||||
return new self($shortUrl->getShortCode(), $domainAuthority);
|
||||
}
|
||||
|
|
9
module/Core/src/ShortUrl/Model/ShortUrlMode.php
Normal file
9
module/Core/src/ShortUrl/Model/ShortUrlMode.php
Normal file
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
|
||||
|
||||
enum ShortUrlMode: string
|
||||
{
|
||||
case STRICT = 'strict';
|
||||
case LOOSELY = 'loosely';
|
||||
}
|
|
@ -4,15 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
|
||||
|
||||
use function Functional\map;
|
||||
|
||||
enum TagsMode: string
|
||||
{
|
||||
case ANY = 'any';
|
||||
case ALL = 'all';
|
||||
|
||||
public static function values(): array
|
||||
{
|
||||
return map(self::cases(), static fn (TagsMode $mode) => $mode->value);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation;
|
||||
|
||||
use Laminas\Filter\FilterInterface;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
|
||||
use function is_string;
|
||||
use function str_replace;
|
||||
use function strtolower;
|
||||
use function trim;
|
||||
|
||||
class CustomSlugFilter implements FilterInterface
|
||||
{
|
||||
public function __construct(private readonly UrlShortenerOptions $options)
|
||||
{
|
||||
}
|
||||
|
||||
public function filter(mixed $value): mixed
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$value = $this->options->isLooselyMode() ? strtolower($value) : $value;
|
||||
return (match ($this->options->multiSegmentSlugsEnabled) {
|
||||
true => trim(str_replace(' ', '-', $value), '/'),
|
||||
false => str_replace([' ', '/'], '-', $value),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation;
|
||||
|
||||
use Laminas\Validator\AbstractValidator;
|
||||
use Laminas\Validator\ValidatorInterface;
|
||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
|
||||
use function array_keys;
|
||||
use function array_values;
|
||||
use function Functional\contains;
|
||||
use function Functional\every;
|
||||
use function is_array;
|
||||
use function Shlinkio\Shlink\Core\enumValues;
|
||||
|
||||
class DeviceLongUrlsValidator extends AbstractValidator
|
||||
{
|
||||
private const NOT_ARRAY = 'NOT_ARRAY';
|
||||
private const INVALID_DEVICE = 'INVALID_DEVICE';
|
||||
private const INVALID_LONG_URL = 'INVALID_LONG_URL';
|
||||
|
||||
protected array $messageTemplates = [
|
||||
self::NOT_ARRAY => 'Provided value is not an array.',
|
||||
self::INVALID_DEVICE => 'You have provided at least one invalid device identifier.',
|
||||
self::INVALID_LONG_URL => 'At least one of the long URLs are invalid.',
|
||||
];
|
||||
|
||||
public function __construct(private readonly ValidatorInterface $longUrlValidators)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function isValid(mixed $value): bool
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
$this->error(self::NOT_ARRAY);
|
||||
return false;
|
||||
}
|
||||
|
||||
$validValues = enumValues(DeviceType::class);
|
||||
$keys = array_keys($value);
|
||||
if (! every($keys, static fn ($key) => contains($validValues, $key))) {
|
||||
$this->error(self::INVALID_DEVICE);
|
||||
return false;
|
||||
}
|
||||
|
||||
$longUrls = array_values($value);
|
||||
$result = every($longUrls, $this->longUrlValidators->isValid(...));
|
||||
if (! $result) {
|
||||
$this->error(self::INVALID_LONG_URL);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
|
@ -4,22 +4,22 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation;
|
||||
|
||||
use DateTime;
|
||||
use DateTimeInterface;
|
||||
use Laminas\Filter;
|
||||
use Laminas\InputFilter\Input;
|
||||
use Laminas\InputFilter\InputFilter;
|
||||
use Laminas\Validator;
|
||||
use Shlinkio\Shlink\Common\Validation;
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
use function is_string;
|
||||
use function str_replace;
|
||||
use function substr;
|
||||
use function trim;
|
||||
|
||||
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
|
||||
|
||||
/**
|
||||
* @todo Pass forCreation/forEdition, instead of withRequiredLongUrl/withNonRequiredLongUrl.
|
||||
* Make it also dynamically add the relevant fields
|
||||
*/
|
||||
class ShortUrlInputFilter extends InputFilter
|
||||
{
|
||||
use Validation\InputFactoryTrait;
|
||||
|
@ -32,6 +32,8 @@ class ShortUrlInputFilter extends InputFilter
|
|||
public const DOMAIN = 'domain';
|
||||
public const SHORT_CODE_LENGTH = 'shortCodeLength';
|
||||
public const LONG_URL = 'longUrl';
|
||||
public const DEVICE_LONG_URLS = 'deviceLongUrls';
|
||||
/** @deprecated */
|
||||
public const VALIDATE_URL = 'validateUrl';
|
||||
public const API_KEY = 'apiKey';
|
||||
public const TAGS = 'tags';
|
||||
|
@ -39,49 +41,60 @@ class ShortUrlInputFilter extends InputFilter
|
|||
public const CRAWLABLE = 'crawlable';
|
||||
public const FORWARD_QUERY = 'forwardQuery';
|
||||
|
||||
private function __construct(array $data, bool $requireLongUrl)
|
||||
private function __construct(array $data, bool $requireLongUrl, UrlShortenerOptions $options)
|
||||
{
|
||||
$this->initialize($requireLongUrl, $data[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] ?? false);
|
||||
$this->initialize($requireLongUrl, $options);
|
||||
$this->setData($data);
|
||||
}
|
||||
|
||||
public static function withRequiredLongUrl(array $data): self
|
||||
public static function withRequiredLongUrl(array $data, UrlShortenerOptions $options): self
|
||||
{
|
||||
return new self($data, true);
|
||||
return new self($data, true, $options);
|
||||
}
|
||||
|
||||
public static function withNonRequiredLongUrl(array $data): self
|
||||
{
|
||||
return new self($data, false);
|
||||
return new self($data, false, new UrlShortenerOptions());
|
||||
}
|
||||
|
||||
private function initialize(bool $requireLongUrl, bool $multiSegmentEnabled): void
|
||||
private function initialize(bool $requireLongUrl, UrlShortenerOptions $options): void
|
||||
{
|
||||
$longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl);
|
||||
$longUrlInput->getValidatorChain()->attach(new Validator\NotEmpty([
|
||||
$longUrlNotEmptyCommonOptions = [
|
||||
Validator\NotEmpty::OBJECT,
|
||||
Validator\NotEmpty::SPACE,
|
||||
Validator\NotEmpty::NULL,
|
||||
Validator\NotEmpty::EMPTY_ARRAY,
|
||||
Validator\NotEmpty::BOOLEAN,
|
||||
Validator\NotEmpty::STRING,
|
||||
];
|
||||
|
||||
$longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl);
|
||||
$longUrlInput->getValidatorChain()->attach(new Validator\NotEmpty([
|
||||
...$longUrlNotEmptyCommonOptions,
|
||||
Validator\NotEmpty::NULL,
|
||||
]));
|
||||
$this->add($longUrlInput);
|
||||
|
||||
$deviceLongUrlsInput = $this->createInput(self::DEVICE_LONG_URLS, false);
|
||||
$deviceLongUrlsInput->getValidatorChain()->attach(
|
||||
new DeviceLongUrlsValidator(new Validator\NotEmpty([
|
||||
...$longUrlNotEmptyCommonOptions,
|
||||
...($requireLongUrl ? [Validator\NotEmpty::NULL] : []),
|
||||
])),
|
||||
);
|
||||
$this->add($deviceLongUrlsInput);
|
||||
|
||||
$validSince = $this->createInput(self::VALID_SINCE, false);
|
||||
$validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM]));
|
||||
$validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM]));
|
||||
$this->add($validSince);
|
||||
|
||||
$validUntil = $this->createInput(self::VALID_UNTIL, false);
|
||||
$validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM]));
|
||||
$validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM]));
|
||||
$this->add($validUntil);
|
||||
|
||||
// FIXME The only way to enforce the NotEmpty validator to be evaluated when the value is provided but it's
|
||||
// empty, is by using the deprecated setContinueIfEmpty
|
||||
// The only way to enforce the NotEmpty validator to be evaluated when the key is present with an empty value
|
||||
// is by using the deprecated setContinueIfEmpty
|
||||
$customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true);
|
||||
$customSlug->getFilterChain()->attach(new Filter\Callback(match ($multiSegmentEnabled) {
|
||||
true => static fn (mixed $v) => is_string($v) ? trim(str_replace(' ', '-', $v), '/') : $v,
|
||||
false => static fn (mixed $v) => is_string($v) ? str_replace([' ', '/'], '-', $v) : $v,
|
||||
}));
|
||||
$customSlug->getFilterChain()->attach(new CustomSlugFilter($options));
|
||||
$customSlug->getValidatorChain()->attach(new Validator\NotEmpty([
|
||||
Validator\NotEmpty::STRING,
|
||||
Validator\NotEmpty::SPACE,
|
||||
|
@ -102,10 +115,8 @@ class ShortUrlInputFilter extends InputFilter
|
|||
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator());
|
||||
$this->add($domain);
|
||||
|
||||
$apiKeyInput = new Input(self::API_KEY);
|
||||
$apiKeyInput
|
||||
->setRequired(false)
|
||||
->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class]));
|
||||
$apiKeyInput = $this->createInput(self::API_KEY, false);
|
||||
$apiKeyInput->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class]));
|
||||
$this->add($apiKeyInput);
|
||||
|
||||
$this->add($this->createTagsInput(self::TAGS, false));
|
||||
|
|
|
@ -11,6 +11,8 @@ use Shlinkio\Shlink\Common\Validation;
|
|||
use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
|
||||
|
||||
use function Shlinkio\Shlink\Core\enumValues;
|
||||
|
||||
class ShortUrlsParamsInputFilter extends InputFilter
|
||||
{
|
||||
use Validation\InputFactoryTrait;
|
||||
|
@ -46,12 +48,12 @@ class ShortUrlsParamsInputFilter extends InputFilter
|
|||
|
||||
$tagsMode = $this->createInput(self::TAGS_MODE, false);
|
||||
$tagsMode->getValidatorChain()->attach(new InArray([
|
||||
'haystack' => TagsMode::values(),
|
||||
'haystack' => enumValues(TagsMode::class),
|
||||
'strict' => InArray::COMPARE_STRICT,
|
||||
]));
|
||||
$this->add($tagsMode);
|
||||
|
||||
$this->add($this->createOrderByInput(self::ORDER_BY, OrderableField::values()));
|
||||
$this->add($this->createOrderByInput(self::ORDER_BY, enumValues(OrderableField::class)));
|
||||
|
||||
$this->add($this->createBooleanInput(self::EXCLUDE_MAX_VISITS_REACHED, false));
|
||||
$this->add($this->createBooleanInput(self::EXCLUDE_PAST_VALID_UNTIL, false));
|
||||
|
|
|
@ -14,42 +14,41 @@ use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
|
|||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
||||
|
||||
use function count;
|
||||
use function strtolower;
|
||||
|
||||
class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface
|
||||
{
|
||||
public function findOneWithDomainFallback(ShortUrlIdentifier $identifier): ?ShortUrl
|
||||
public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl
|
||||
{
|
||||
// When ordering DESC, Postgres puts nulls at the beginning while the rest of supported DB engines put them at
|
||||
// the bottom
|
||||
$dbPlatform = $this->getEntityManager()->getConnection()->getDatabasePlatform();
|
||||
$ordering = $dbPlatform instanceof PostgreSQLPlatform ? 'ASC' : 'DESC';
|
||||
$isStrict = $shortUrlMode === ShortUrlMode::STRICT;
|
||||
|
||||
$dql = <<<DQL
|
||||
SELECT s
|
||||
FROM Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl AS s
|
||||
LEFT JOIN s.domain AS d
|
||||
WHERE s.shortCode = :shortCode
|
||||
AND (s.domain IS NULL OR d.authority = :domain)
|
||||
ORDER BY s.domain {$ordering}
|
||||
DQL;
|
||||
$qb = $this->createQueryBuilder('s');
|
||||
$qb->leftJoin('s.domain', 'd')
|
||||
->where($qb->expr()->eq($isStrict ? 's.shortCode' : 'LOWER(s.shortCode)', ':shortCode'))
|
||||
->setParameter('shortCode', $isStrict ? $identifier->shortCode : strtolower($identifier->shortCode))
|
||||
->andWhere($qb->expr()->orX(
|
||||
$qb->expr()->isNull('s.domain'),
|
||||
$qb->expr()->eq('d.authority', ':domain'),
|
||||
))
|
||||
->setParameter('domain', $identifier->domain);
|
||||
|
||||
$query = $this->getEntityManager()->createQuery($dql);
|
||||
$query->setMaxResults(1)
|
||||
->setParameters([
|
||||
'shortCode' => $identifier->shortCode,
|
||||
'domain' => $identifier->domain,
|
||||
]);
|
||||
|
||||
// Since we ordered by domain, we will have first the URL matching provided domain, followed by the one
|
||||
// with no domain (if any), so it is safe to fetch 1 max result and we will get:
|
||||
// Since we order by domain, we will have first the URL matching provided domain, followed by the one
|
||||
// with no domain (if any), so it is safe to fetch 1 max result, and we will get:
|
||||
// * The short URL matching both the short code and the domain, or
|
||||
// * The short URL matching the short code but without any domain, or
|
||||
// * No short URL at all
|
||||
$qb->orderBy('s.domain', $ordering)
|
||||
->setMaxResults(1);
|
||||
|
||||
return $query->getOneOrNullResult();
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl
|
||||
|
@ -101,45 +100,45 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
|
|||
return $qb;
|
||||
}
|
||||
|
||||
public function findOneMatching(ShortUrlCreation $meta): ?ShortUrl
|
||||
public function findOneMatching(ShortUrlCreation $creation): ?ShortUrl
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
|
||||
$qb->select('s')
|
||||
->from(ShortUrl::class, 's')
|
||||
->where($qb->expr()->eq('s.longUrl', ':longUrl'))
|
||||
->setParameter('longUrl', $meta->getLongUrl())
|
||||
->setParameter('longUrl', $creation->longUrl)
|
||||
->setMaxResults(1)
|
||||
->orderBy('s.id');
|
||||
|
||||
if ($meta->hasCustomSlug()) {
|
||||
if ($creation->hasCustomSlug()) {
|
||||
$qb->andWhere($qb->expr()->eq('s.shortCode', ':slug'))
|
||||
->setParameter('slug', $meta->getCustomSlug());
|
||||
->setParameter('slug', $creation->customSlug);
|
||||
}
|
||||
if ($meta->hasMaxVisits()) {
|
||||
if ($creation->hasMaxVisits()) {
|
||||
$qb->andWhere($qb->expr()->eq('s.maxVisits', ':maxVisits'))
|
||||
->setParameter('maxVisits', $meta->getMaxVisits());
|
||||
->setParameter('maxVisits', $creation->maxVisits);
|
||||
}
|
||||
if ($meta->hasValidSince()) {
|
||||
if ($creation->hasValidSince()) {
|
||||
$qb->andWhere($qb->expr()->eq('s.validSince', ':validSince'))
|
||||
->setParameter('validSince', $meta->getValidSince(), ChronosDateTimeType::CHRONOS_DATETIME);
|
||||
->setParameter('validSince', $creation->validSince, ChronosDateTimeType::CHRONOS_DATETIME);
|
||||
}
|
||||
if ($meta->hasValidUntil()) {
|
||||
if ($creation->hasValidUntil()) {
|
||||
$qb->andWhere($qb->expr()->eq('s.validUntil', ':validUntil'))
|
||||
->setParameter('validUntil', $meta->getValidUntil(), ChronosDateTimeType::CHRONOS_DATETIME);
|
||||
->setParameter('validUntil', $creation->validUntil, ChronosDateTimeType::CHRONOS_DATETIME);
|
||||
}
|
||||
if ($meta->hasDomain()) {
|
||||
if ($creation->hasDomain()) {
|
||||
$qb->join('s.domain', 'd')
|
||||
->andWhere($qb->expr()->eq('d.authority', ':domain'))
|
||||
->setParameter('domain', $meta->getDomain());
|
||||
->setParameter('domain', $creation->domain);
|
||||
}
|
||||
|
||||
$apiKey = $meta->getApiKey();
|
||||
$apiKey = $creation->apiKey;
|
||||
if ($apiKey !== null) {
|
||||
$this->applySpecification($qb, $apiKey->spec(), 's');
|
||||
}
|
||||
|
||||
$tags = $meta->getTags();
|
||||
$tags = $creation->tags;
|
||||
$tagsAmount = count($tags);
|
||||
if ($tagsAmount === 0) {
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
|
|
|
@ -10,11 +10,12 @@ use Happyr\DoctrineSpecification\Specification\Specification;
|
|||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
||||
|
||||
interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||
{
|
||||
public function findOneWithDomainFallback(ShortUrlIdentifier $identifier): ?ShortUrl;
|
||||
public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl;
|
||||
|
||||
public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl;
|
||||
|
||||
|
@ -22,7 +23,7 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat
|
|||
|
||||
public function shortCodeIsInUseWithLock(ShortUrlIdentifier $identifier, ?Specification $spec = null): bool;
|
||||
|
||||
public function findOneMatching(ShortUrlCreation $meta): ?ShortUrl;
|
||||
public function findOneMatching(ShortUrlCreation $creation): ?ShortUrl;
|
||||
|
||||
public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl;
|
||||
}
|
||||
|
|
|
@ -49,7 +49,7 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
|
|||
|
||||
/**
|
||||
* @param string[] $tags
|
||||
* @return Collection|Tag[]
|
||||
* @return Collection<int, Tag>
|
||||
*/
|
||||
public function resolveTags(array $tags): Collections\Collection
|
||||
{
|
||||
|
|
|
@ -14,7 +14,7 @@ interface ShortUrlRelationResolverInterface
|
|||
|
||||
/**
|
||||
* @param string[] $tags
|
||||
* @return Collection|Tag[]
|
||||
* @return Collection<int, Tag>
|
||||
*/
|
||||
public function resolveTags(array $tags): Collection;
|
||||
}
|
||||
|
|
|
@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core\ShortUrl\Resolver;
|
||||
|
||||
use Doctrine\Common\Collections;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
|
||||
|
||||
|
@ -20,7 +19,7 @@ class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterfac
|
|||
|
||||
/**
|
||||
* @param string[] $tags
|
||||
* @return Collection|Tag[]
|
||||
* @return Collections\Collection<int, Tag>
|
||||
*/
|
||||
public function resolveTags(array $tags): Collections\Collection
|
||||
{
|
||||
|
|
|
@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl;
|
|||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository;
|
||||
|
@ -13,8 +14,10 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
|||
|
||||
class ShortUrlResolver implements ShortUrlResolverInterface
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly UrlShortenerOptions $urlShortenerOptions,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -39,7 +42,7 @@ class ShortUrlResolver implements ShortUrlResolverInterface
|
|||
{
|
||||
/** @var ShortUrlRepository $shortUrlRepo */
|
||||
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
|
||||
$shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier);
|
||||
$shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier, $this->urlShortenerOptions->mode);
|
||||
if (! $shortUrl?->isEnabled()) {
|
||||
throw ShortUrlNotFoundException::fromNotFound($identifier);
|
||||
}
|
||||
|
|
|
@ -34,7 +34,6 @@ class ShortUrlService implements ShortUrlServiceInterface
|
|||
?ApiKey $apiKey = null,
|
||||
): ShortUrl {
|
||||
if ($shortUrlEdit->longUrlWasProvided()) {
|
||||
/** @var ShortUrlEdition $shortUrlEdit */
|
||||
$shortUrlEdit = $this->titleResolutionHelper->processTitleAndValidateUrl($shortUrlEdit);
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Transformer;
|
|||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary;
|
||||
|
||||
use function Functional\invoke;
|
||||
use function Functional\invoke_if;
|
||||
|
@ -26,6 +27,7 @@ class ShortUrlDataTransformer implements DataTransformerInterface
|
|||
'shortCode' => $shortUrl->getShortCode(),
|
||||
'shortUrl' => $this->stringifier->stringify($shortUrl),
|
||||
'longUrl' => $shortUrl->getLongUrl(),
|
||||
'deviceLongUrls' => $shortUrl->deviceLongUrls(),
|
||||
'dateCreated' => $shortUrl->getDateCreated()->toAtomString(),
|
||||
'tags' => invoke($shortUrl->getTags(), '__toString'),
|
||||
'meta' => $this->buildMeta($shortUrl),
|
||||
|
@ -33,7 +35,10 @@ class ShortUrlDataTransformer implements DataTransformerInterface
|
|||
'title' => $shortUrl->title(),
|
||||
'crawlable' => $shortUrl->crawlable(),
|
||||
'forwardQuery' => $shortUrl->forwardQuery(),
|
||||
'visitsSummary' => $this->buildVisitsSummary($shortUrl),
|
||||
'visitsSummary' => VisitsSummary::fromTotalAndNonBots(
|
||||
$shortUrl->getVisitsCount(),
|
||||
$shortUrl->nonBotVisitsCount(),
|
||||
),
|
||||
|
||||
// Deprecated
|
||||
'visitsCount' => $shortUrl->getVisitsCount(),
|
||||
|
@ -52,16 +57,4 @@ class ShortUrlDataTransformer implements DataTransformerInterface
|
|||
'maxVisits' => $maxVisits,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildVisitsSummary(ShortUrl $shortUrl): array
|
||||
{
|
||||
$totalVisits = $shortUrl->getVisitsCount();
|
||||
$nonBotVisits = $shortUrl->nonBotVisitsCount();
|
||||
|
||||
return [
|
||||
'total' => $totalVisits,
|
||||
'nonBots' => $nonBotVisits,
|
||||
'bots' => $totalVisits - $nonBotVisits,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,22 +31,21 @@ class UrlShortener implements UrlShortenerInterface
|
|||
* @throws NonUniqueSlugException
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function shorten(ShortUrlCreation $meta): ShortUrl
|
||||
public function shorten(ShortUrlCreation $creation): ShortUrl
|
||||
{
|
||||
// First, check if a short URL exists for all provided params
|
||||
$existingShortUrl = $this->findExistingShortUrlIfExists($meta);
|
||||
$existingShortUrl = $this->findExistingShortUrlIfExists($creation);
|
||||
if ($existingShortUrl !== null) {
|
||||
return $existingShortUrl;
|
||||
}
|
||||
|
||||
/** @var \Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation $meta */
|
||||
$meta = $this->titleResolutionHelper->processTitleAndValidateUrl($meta);
|
||||
$creation = $this->titleResolutionHelper->processTitleAndValidateUrl($creation);
|
||||
|
||||
/** @var ShortUrl $newShortUrl */
|
||||
$newShortUrl = $this->em->wrapInTransaction(function () use ($meta) {
|
||||
$shortUrl = ShortUrl::create($meta, $this->relationResolver);
|
||||
$newShortUrl = $this->em->wrapInTransaction(function () use ($creation): ShortUrl {
|
||||
$shortUrl = ShortUrl::create($creation, $this->relationResolver);
|
||||
|
||||
$this->verifyShortCodeUniqueness($meta, $shortUrl);
|
||||
$this->verifyShortCodeUniqueness($creation, $shortUrl);
|
||||
$this->em->persist($shortUrl);
|
||||
|
||||
return $shortUrl;
|
||||
|
@ -57,15 +56,15 @@ class UrlShortener implements UrlShortenerInterface
|
|||
return $newShortUrl;
|
||||
}
|
||||
|
||||
private function findExistingShortUrlIfExists(ShortUrlCreation $meta): ?ShortUrl
|
||||
private function findExistingShortUrlIfExists(ShortUrlCreation $creation): ?ShortUrl
|
||||
{
|
||||
if (! $meta->findIfExists()) {
|
||||
if (! $creation->findIfExists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var ShortUrlRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(ShortUrl::class);
|
||||
return $repo->findOneMatching($meta);
|
||||
return $repo->findOneMatching($creation);
|
||||
}
|
||||
|
||||
private function verifyShortCodeUniqueness(ShortUrlCreation $meta, ShortUrl $shortUrlToBeCreated): void
|
||||
|
@ -77,7 +76,7 @@ class UrlShortener implements UrlShortenerInterface
|
|||
|
||||
if (! $couldBeMadeUnique) {
|
||||
$domain = $shortUrlToBeCreated->getDomain();
|
||||
$domainAuthority = $domain?->getAuthority();
|
||||
$domainAuthority = $domain?->authority;
|
||||
|
||||
throw NonUniqueSlugException::fromSlug($shortUrlToBeCreated->getShortCode(), $domainAuthority);
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue