mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-05 06:43:12 +08:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09a5284675 | ||
|
|
1112f3acdd | ||
|
|
05e3071db2 | ||
|
|
403773bc17 | ||
|
|
636df2a736 | ||
|
|
baf3093893 | ||
|
|
8d3a49a319 | ||
|
|
eced1af21d | ||
|
|
49c3c9bec1 | ||
|
|
2ffaabe594 | ||
|
|
f31dc6c6e5 | ||
|
|
f067d0e831 | ||
|
|
a892f72425 | ||
|
|
25f64a2fc4 | ||
|
|
fd1fe90731 | ||
|
|
495643f4f1 | ||
|
|
8da6b336f5 | ||
|
|
d0bb86ca8f | ||
|
|
1085809fa5 | ||
|
|
7b1857dcda | ||
|
|
6f38790d47 | ||
|
|
a81ac85af6 | ||
|
|
8f4d5b6fce | ||
|
|
8468a48eaa | ||
|
|
fc0885e5d5 | ||
|
|
e1a9e347c3 | ||
|
|
1b0e3b686d | ||
|
|
a09208582e | ||
|
|
df1de020d1 | ||
|
|
9b363368a2 | ||
|
|
9fac69675a | ||
|
|
1d2cfde7f7 | ||
|
|
452612ee00 | ||
|
|
8d74e0c3ff | ||
|
|
0a1786c89a | ||
|
|
bc07d77d06 | ||
|
|
6e38457655 | ||
|
|
d7a3aeb0a2 | ||
|
|
76541d5563 | ||
|
|
28b5d8445e | ||
|
|
d17533fd0f | ||
|
|
01d62b7aea | ||
|
|
bd97804ca6 | ||
|
|
7b0ccc9f69 | ||
|
|
fdb98fa2a9 |
@@ -5,10 +5,6 @@ SHORTENED_URL_SCHEMA=
|
||||
SHORTENED_URL_HOSTNAME=
|
||||
SHORTCODE_CHARS=
|
||||
|
||||
# Language
|
||||
DEFAULT_LOCALE=
|
||||
CLI_LOCALE=
|
||||
|
||||
# Database
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
|
||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
custom: ['https://acel.me/donate']
|
||||
2
.github/ISSUE_TEMPLATE.md
vendored
2
.github/ISSUE_TEMPLATE.md
vendored
@@ -2,5 +2,5 @@
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
Try to be polite, and understand it is impossible for a project to cover all use cases.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
-->
|
||||
|
||||
37
.github/ISSUE_TEMPLATE/Bug.md
vendored
Normal file
37
.github/ISSUE_TEMPLATE/Bug.md
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Something on shlink is broken or not working as documented?
|
||||
labels: bug
|
||||
---
|
||||
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
|
||||
With that said, please fill in the information requested next. More information might be requested next (like logs or system configs).
|
||||
-->
|
||||
|
||||
#### How Shlink is set-up
|
||||
|
||||
* Shlink Version: x.y.z
|
||||
* PHP Version: x.y.z
|
||||
* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted swoole|Docker image
|
||||
* Database engine used: MySQL|PostgreSQL|SQLite (x.y.z)
|
||||
|
||||
#### Summary
|
||||
|
||||
<!-- Provide a summary describing the problem you are experiencing. -->
|
||||
|
||||
#### Current behavior
|
||||
|
||||
<!-- How is it actually behaving (and it shouldn't)? -->
|
||||
|
||||
#### Expected behavior
|
||||
|
||||
<!-- How did you expected to behave? -->
|
||||
|
||||
#### How to reproduce
|
||||
|
||||
<!-- Provide steps to reproduce the bug. -->
|
||||
18
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
Normal file
18
.github/ISSUE_TEMPLATE/Feature_Request.md
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Do you find shlink is missing some important feature that would make it more useful?
|
||||
labels: feature
|
||||
---
|
||||
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
|
||||
With that said, please fill in the information requested next. More information might be requested next (like logs or system configs).
|
||||
-->
|
||||
|
||||
#### Summary
|
||||
|
||||
<!-- Describe the new feature you would like to request. -->
|
||||
25
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
Normal file
25
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: Question - Support
|
||||
about: Do you have a problem setting up or using shlink?
|
||||
labels: question
|
||||
---
|
||||
|
||||
<!--
|
||||
Before opening an issue, just take into account that this is a completely free of charge open source project.
|
||||
I'm always happy to help and provide support, but some understanding will be required.
|
||||
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
|
||||
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
|
||||
|
||||
With that said, please fill in the information requested next. More information might be requested next (like logs or system configs).
|
||||
-->
|
||||
|
||||
#### How Shlink is set-up
|
||||
|
||||
* Shlink Version: x.y.z
|
||||
* PHP Version: x.y.z
|
||||
* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted swoole|Docker image
|
||||
* Database engine used: MySQL|PostgreSQL|SQLite (x.y.z)
|
||||
|
||||
#### Summary
|
||||
|
||||
<!-- Describe the issue you are facing here. -->
|
||||
47
CHANGELOG.md
47
CHANGELOG.md
@@ -4,6 +4,53 @@ 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).
|
||||
|
||||
## 1.19.0 - 2019-10-05
|
||||
|
||||
#### Added
|
||||
|
||||
* [#482](https://github.com/shlinkio/shlink/issues/482) Added support to serve shlink under a sub path.
|
||||
|
||||
The `router.base_path` config option can be defined now to set the base path from which shlink is served.
|
||||
|
||||
```php
|
||||
return [
|
||||
'router' => [
|
||||
'base_path' => '/foo/bar',
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
This option will also be available on shlink-installer 1.3.0, so the installer will ask for it. It can also be provided for the docker image as the `BASE_PATH` env var.
|
||||
|
||||
* [#479](https://github.com/shlinkio/shlink/issues/479) Added preliminary support for multiple domains.
|
||||
|
||||
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-compaign` 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:
|
||||
|
||||
* If the domain used for the request plus the short code/slug are found, the user is redirected to that long URL and the visit is tracked.
|
||||
* If the domain is not known but the short code/slug is defined for default domain, the user is redirected there and the visit is tracked.
|
||||
* In any other case, no redirection happens and no visit is tracked (if a fall back redirection is configured for not-found URLs, it will still happen).
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#486](https://github.com/shlinkio/shlink/issues/486) Updated to [shlink-installer](https://github.com/shlinkio/shlink-installer) v2, which supports asking for base path in which shlink is served.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* [#435](https://github.com/shlinkio/shlink/issues/435) Removed translations for error pages. All error pages are in english now.
|
||||
|
||||
#### Fixed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## 1.18.1 - 2019-08-24
|
||||
|
||||
#### Added
|
||||
|
||||
@@ -33,9 +33,9 @@
|
||||
"ocramius/proxy-manager": "~2.2.2",
|
||||
"phly/phly-event-dispatcher": "^1.0",
|
||||
"predis/predis": "^1.1",
|
||||
"shlinkio/shlink-common": "^1.0",
|
||||
"shlinkio/shlink-common": "^2.0",
|
||||
"shlinkio/shlink-event-dispatcher": "^1.0",
|
||||
"shlinkio/shlink-installer": "^1.2.1",
|
||||
"shlinkio/shlink-installer": "^2.0",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.0",
|
||||
"symfony/console": "^4.3",
|
||||
"symfony/filesystem": "^4.3",
|
||||
@@ -50,7 +50,6 @@
|
||||
"zendframework/zend-expressive-helpers": "^5.3",
|
||||
"zendframework/zend-expressive-platesrenderer": "^2.1",
|
||||
"zendframework/zend-expressive-swoole": "^2.4",
|
||||
"zendframework/zend-i18n": "^2.9",
|
||||
"zendframework/zend-inputfilter": "^2.10",
|
||||
"zendframework/zend-paginator": "^2.8",
|
||||
"zendframework/zend-servicemanager": "^3.4",
|
||||
|
||||
@@ -6,10 +6,6 @@ use Shlinkio\Shlink\Installer\Config\Plugin;
|
||||
return [
|
||||
|
||||
'installer_plugins_expected_config' => [
|
||||
Plugin\LanguageConfigCustomizer::class => [
|
||||
Plugin\LanguageConfigCustomizer::DEFAULT_LANG,
|
||||
],
|
||||
|
||||
Plugin\UrlShortenerConfigCustomizer::class => [
|
||||
Plugin\UrlShortenerConfigCustomizer::SCHEMA,
|
||||
Plugin\UrlShortenerConfigCustomizer::HOSTNAME,
|
||||
@@ -24,6 +20,7 @@ return [
|
||||
Plugin\ApplicationConfigCustomizer::DISABLE_TRACK_PARAM,
|
||||
Plugin\ApplicationConfigCustomizer::CHECK_VISITS_THRESHOLD,
|
||||
Plugin\ApplicationConfigCustomizer::VISITS_THRESHOLD,
|
||||
Plugin\ApplicationConfigCustomizer::BASE_PATH,
|
||||
],
|
||||
|
||||
Plugin\DatabaseConfigCustomizer::class => [
|
||||
|
||||
@@ -7,6 +7,7 @@ use Monolog\Handler\RotatingFileHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Logger;
|
||||
use Monolog\Processor;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
use const PHP_EOL;
|
||||
|
||||
@@ -61,6 +62,11 @@ return [
|
||||
'Logger_Shlink' => Common\Logger\LoggerFactory::class,
|
||||
'Logger_Access' => Common\Logger\LoggerFactory::class,
|
||||
],
|
||||
'aliases' => [
|
||||
'logger' => 'Logger_Shlink',
|
||||
Logger::class => 'Logger_Shlink',
|
||||
LoggerInterface::class => 'Logger_Shlink',
|
||||
],
|
||||
],
|
||||
|
||||
'zend-expressive-swoole' => [
|
||||
|
||||
@@ -47,9 +47,6 @@ return [
|
||||
'post-routing' => [
|
||||
'middleware' => [
|
||||
Expressive\Router\Middleware\DispatchMiddleware::class,
|
||||
|
||||
// Only if a not found error is triggered, set-up the locale to be used
|
||||
Common\Middleware\LocaleMiddleware::class,
|
||||
Core\Response\NotFoundHandler::class,
|
||||
],
|
||||
'priority' => 1,
|
||||
|
||||
@@ -6,6 +6,8 @@ use Zend\Expressive\Router\FastRouteRouter;
|
||||
return [
|
||||
|
||||
'router' => [
|
||||
'base_path' => '',
|
||||
|
||||
'fastroute' => [
|
||||
FastRouteRouter::CONFIG_CACHE_ENABLED => true,
|
||||
FastRouteRouter::CONFIG_CACHE_FILE => 'data/cache/fastroute_cached_routes.php',
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Shlinkio\Shlink\Common;
|
||||
|
||||
return [
|
||||
|
||||
'translator' => [
|
||||
'locale' => Common\env('DEFAULT_LOCALE', 'en'),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -28,5 +28,6 @@ return (new ConfigAggregator\ConfigAggregator([
|
||||
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')
|
||||
: new ConfigAggregator\ZendConfigProvider('config/params/{generated_config.php,*.config.{php,json}}'),
|
||||
], 'data/cache/app_config.php', [
|
||||
Core\SimplifiedConfigParser::class,
|
||||
Core\Config\SimplifiedConfigParser::class,
|
||||
Core\Config\BasePathPrefixer::class,
|
||||
]))->getMergedConfig();
|
||||
|
||||
54
data/migrations/Version20190930165521.php
Normal file
54
data/migrations/Version20190930165521.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Schema\SchemaException;
|
||||
use Doctrine\DBAL\Types\Type;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20190930165521 extends AbstractMigration
|
||||
{
|
||||
/**
|
||||
* @throws SchemaException
|
||||
*/
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$shortUrls = $schema->getTable('short_urls');
|
||||
if ($shortUrls->hasColumn('domain_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$domains = $schema->createTable('domains');
|
||||
$domains->addColumn('id', Type::BIGINT, [
|
||||
'unsigned' => true,
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
]);
|
||||
$domains->addColumn('authority', Type::STRING, [
|
||||
'length' => 512,
|
||||
'notnull' => true,
|
||||
]);
|
||||
$domains->addUniqueIndex(['authority']);
|
||||
$domains->setPrimaryKey(['id']);
|
||||
|
||||
$shortUrls->addColumn('domain_id', Type::BIGINT, [
|
||||
'unsigned' => true,
|
||||
'notnull' => false,
|
||||
]);
|
||||
$shortUrls->addForeignKeyConstraint('domains', ['domain_id'], ['id'], [
|
||||
'onDelete' => 'RESTRICT',
|
||||
'onUpdate' => 'RESTRICT',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws SchemaException
|
||||
*/
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->getTable('short_urls')->dropColumn('domain_id');
|
||||
$schema->dropTable('domains');
|
||||
}
|
||||
}
|
||||
48
data/migrations/Version20191001201532.php
Normal file
48
data/migrations/Version20191001201532.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Index;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Schema\SchemaException;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
use function array_reduce;
|
||||
|
||||
final class Version20191001201532 extends AbstractMigration
|
||||
{
|
||||
/**
|
||||
* @throws SchemaException
|
||||
*/
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$shortUrls = $schema->getTable('short_urls');
|
||||
if ($shortUrls->hasIndex('unique_short_code_plus_domain')) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var Index|null $shortCodesIndex */
|
||||
$shortCodesIndex = array_reduce($shortUrls->getIndexes(), function (?Index $found, Index $current) {
|
||||
[$column] = $current->getColumns();
|
||||
return $column === 'short_code' ? $current : $found;
|
||||
});
|
||||
if ($shortCodesIndex === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$shortUrls->dropIndex($shortCodesIndex->getName());
|
||||
$shortUrls->addUniqueIndex(['short_code', 'domain_id'], 'unique_short_code_plus_domain');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws SchemaException
|
||||
*/
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$shortUrls = $schema->getTable('short_urls');
|
||||
|
||||
$shortUrls->dropIndex('unique_short_code_plus_domain');
|
||||
$shortUrls->addUniqueIndex(['short_code']);
|
||||
}
|
||||
}
|
||||
@@ -101,9 +101,9 @@ This is the complete list of supported env vars:
|
||||
* `DB_PORT`: The port in which the database service is running when using an external database driver. Defaults to **3306**.
|
||||
* `DISABLE_TRACK_PARAM`: The name of a query param that can be used to visit short URLs avoiding the visit to be tracked. This feature won't be available if not value is provided.
|
||||
* `DELETE_SHORT_URL_THRESHOLD`: The amount of visits on short URLs which will not allow them to be deleted. Defaults to `15`.
|
||||
* `LOCALE`: Defines the default language for error pages when a user accesses a short URL which does not exist. Supported values are **es** and **en**. Defaults to **en**.
|
||||
* `VALIDATE_URLS`: Boolean which tells if shlink should validate a status 20x (after following redirects) is returned when trying to shorten a URL. Defaults to `true`.
|
||||
* `NOT_FOUND_REDIRECT_TO`: If a URL is provided here, when a user tries to access an invalid short URL, he/she will be redirected to this value. If this env var is not provided, the user will see a generic `404 - not found` page.
|
||||
* `BASE_PATH`: The base path from which you plan to serve shlink, in case you don't want to serve it from the root of the domain. Defaults to `''`.
|
||||
* `REDIS_SERVERS`: A comma-separated list of redis servers where Shlink locks are stored (locks are used to prevent some operations to be run more than once in parallel).
|
||||
|
||||
This is important when running more than one Shlink instance ([Multi instance considerations](#multi-instance-considerations)). If not provided, Shlink stores locks on every instance separately.
|
||||
@@ -128,10 +128,10 @@ docker run \
|
||||
-e DB_PORT=3306 \
|
||||
-e DISABLE_TRACK_PARAM="no-track" \
|
||||
-e DELETE_SHORT_URL_THRESHOLD=30 \
|
||||
-e LOCALE=es \
|
||||
-e VALIDATE_URLS=false \
|
||||
-e "NOT_FOUND_REDIRECT_TO=https://www.google.com" \
|
||||
-e "REDIS_SERVERS=tcp://172.20.0.1:6379,tcp://172.20.0.2:6379" \
|
||||
-e "BASE_PATH=/my-campaign" \
|
||||
shlinkio/shlink
|
||||
```
|
||||
|
||||
@@ -147,7 +147,6 @@ The whole configuration should have this format, but it can be split into multip
|
||||
{
|
||||
"disable_track_param": "my_param",
|
||||
"delete_short_url_threshold": 30,
|
||||
"locale": "es",
|
||||
"short_domain_schema": "https",
|
||||
"short_domain_host": "doma.in",
|
||||
"validate_url": false,
|
||||
|
||||
@@ -124,10 +124,6 @@ return [
|
||||
'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', 15),
|
||||
],
|
||||
|
||||
'translator' => [
|
||||
'locale' => env('LOCALE', 'en'),
|
||||
],
|
||||
|
||||
'entity_manager' => [
|
||||
'connection' => $helper->getDbConfig(),
|
||||
],
|
||||
@@ -172,4 +168,8 @@ return [
|
||||
'servers' => env('REDIS_SERVERS'),
|
||||
],
|
||||
|
||||
'router' => [
|
||||
'base_path' => env('BASE_PATH', ''),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -216,6 +216,10 @@
|
||||
"findIfExists": {
|
||||
"description": "Will force existing matching URL to be returned if found, instead of creating a new one",
|
||||
"type": "boolean"
|
||||
},
|
||||
"domain": {
|
||||
"description": "The domain to which the short URL will be attached",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,15 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "domain",
|
||||
"in": "query",
|
||||
"description": "The domain in which the short code should be searched for. Will fall back to default domain if not found.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
|
||||
@@ -9,7 +9,6 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||
use Shlinkio\Shlink\Core\Util\ShortUrlBuilderTrait;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -26,8 +25,6 @@ use function sprintf;
|
||||
|
||||
class GenerateShortUrlCommand extends Command
|
||||
{
|
||||
use ShortUrlBuilderTrait;
|
||||
|
||||
public const NAME = 'short-url:generate';
|
||||
private const ALIASES = ['shortcode:generate', 'short-code:generate'];
|
||||
|
||||
@@ -87,6 +84,12 @@ class GenerateShortUrlCommand extends Command
|
||||
'f',
|
||||
InputOption::VALUE_NONE,
|
||||
'This will force existing matching URL to be returned if found, instead of creating a new one.'
|
||||
)
|
||||
->addOption(
|
||||
'domain',
|
||||
'd',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The domain to which this short URL will be attached.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,7 +122,7 @@ class GenerateShortUrlCommand extends Command
|
||||
$maxVisits = $input->getOption('maxVisits');
|
||||
|
||||
try {
|
||||
$shortCode = $this->urlShortener->urlToShortCode(
|
||||
$shortUrl = $this->urlShortener->urlToShortCode(
|
||||
new Uri($longUrl),
|
||||
$tags,
|
||||
ShortUrlMeta::createFromParams(
|
||||
@@ -127,14 +130,14 @@ class GenerateShortUrlCommand extends Command
|
||||
$this->getOptionalDate($input, 'validUntil'),
|
||||
$customSlug,
|
||||
$maxVisits !== null ? (int) $maxVisits : null,
|
||||
$input->getOption('findIfExists')
|
||||
$input->getOption('findIfExists'),
|
||||
$input->getOption('domain')
|
||||
)
|
||||
)->getShortCode();
|
||||
$shortUrl = $this->buildShortUrl($this->domainConfig, $shortCode);
|
||||
);
|
||||
|
||||
$io->writeln([
|
||||
sprintf('Processed long URL: <info>%s</info>', $longUrl),
|
||||
sprintf('Generated short URL: <info>%s</info>', $shortUrl),
|
||||
sprintf('Generated short URL: <info>%s</info>', $shortUrl->toString($this->domainConfig)),
|
||||
]);
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
} catch (InvalidUrlException $e) {
|
||||
|
||||
@@ -10,6 +10,7 @@ use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
@@ -35,7 +36,8 @@ class ResolveUrlCommand extends Command
|
||||
->setName(self::NAME)
|
||||
->setAliases(self::ALIASES)
|
||||
->setDescription('Returns the long URL behind a short code')
|
||||
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code to parse');
|
||||
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code to parse')
|
||||
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain to which the short URL is attached.');
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
@@ -56,9 +58,10 @@ class ResolveUrlCommand extends Command
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$shortCode = $input->getArgument('shortCode');
|
||||
$domain = $input->getOption('domain');
|
||||
|
||||
try {
|
||||
$url = $this->urlShortener->shortCodeToUrl($shortCode);
|
||||
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain);
|
||||
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
} catch (InvalidShortCodeException $e) {
|
||||
|
||||
@@ -9,8 +9,10 @@ use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\GenerateShortUrlCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortener;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
@@ -35,7 +37,7 @@ class GenerateShortUrlCommandTest extends TestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function properShortCodeIsCreatedIfLongUrlIsCorrect()
|
||||
public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void
|
||||
{
|
||||
$urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willReturn(
|
||||
(new ShortUrl(''))->setShortCode('abc123')
|
||||
@@ -47,26 +49,41 @@ class GenerateShortUrlCommandTest extends TestCase
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
$this->assertStringContainsString('http://foo.com/abc123', $output);
|
||||
$urlToShortCode->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function exceptionWhileParsingLongUrlOutputsError()
|
||||
public function exceptionWhileParsingLongUrlOutputsError(): void
|
||||
{
|
||||
$this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(new InvalidUrlException())
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid']);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString(
|
||||
'Provided URL "http://domain.com/invalid" is invalid.',
|
||||
$output
|
||||
);
|
||||
|
||||
$this->assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
|
||||
$this->assertStringContainsString('Provided URL "http://domain.com/invalid" is invalid.', $output);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function properlyProcessesProvidedTags()
|
||||
public function providingNonUniqueSlugOutputsError(): void
|
||||
{
|
||||
$urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(
|
||||
NonUniqueSlugException::class
|
||||
);
|
||||
|
||||
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--customSlug' => 'my-slug']);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
|
||||
$this->assertStringContainsString('Provided slug "my-slug" is already in use', $output);
|
||||
$urlToShortCode->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function properlyProcessesProvidedTags(): void
|
||||
{
|
||||
$urlToShortCode = $this->urlShortener->urlToShortCode(
|
||||
Argument::type(UriInterface::class),
|
||||
@@ -83,6 +100,7 @@ class GenerateShortUrlCommandTest extends TestCase
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
|
||||
$this->assertStringContainsString('http://foo.com/abc123', $output);
|
||||
$urlToShortCode->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
@@ -33,13 +33,13 @@ class ResolveUrlCommandTest extends TestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function correctShortCodeResolvesUrl()
|
||||
public function correctShortCodeResolvesUrl(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$expectedUrl = 'http://domain.com/foo/bar';
|
||||
$shortUrl = new ShortUrl($expectedUrl);
|
||||
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($shortUrl)
|
||||
->shouldBeCalledOnce();
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, null)->willReturn($shortUrl)
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
@@ -47,11 +47,11 @@ class ResolveUrlCommandTest extends TestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function incorrectShortCodeOutputsErrorMessage()
|
||||
public function incorrectShortCodeOutputsErrorMessage(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class)
|
||||
->shouldBeCalledOnce();
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(EntityDoesNotExistException::class)
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
@@ -59,11 +59,11 @@ class ResolveUrlCommandTest extends TestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function wrongShortCodeFormatOutputsErrorMessage()
|
||||
public function wrongShortCodeFormatOutputsErrorMessage(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(new InvalidShortCodeException())
|
||||
->shouldBeCalledOnce();
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(new InvalidShortCodeException())
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
@@ -12,8 +12,6 @@ use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Zend\ServiceManager\ServiceManager;
|
||||
|
||||
use function array_merge;
|
||||
|
||||
class ApplicationFactoryTest extends TestCase
|
||||
{
|
||||
/** @var ApplicationFactory */
|
||||
@@ -49,7 +47,7 @@ class ApplicationFactoryTest extends TestCase
|
||||
{
|
||||
return new ServiceManager(['services' => [
|
||||
'config' => [
|
||||
'cli' => array_merge($config, ['locale' => 'en']),
|
||||
'cli' => $config,
|
||||
],
|
||||
AppOptions::class => new AppOptions(),
|
||||
]]);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use Doctrine\DBAL\Types\Type;
|
||||
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata; // @codingStandardsIgnoreLine
|
||||
|
||||
/** @var $metadata ClassMetadata */
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
|
||||
$builder->setTable('domains');
|
||||
|
||||
$builder->createField('id', Type::BIGINT)
|
||||
->columnName('id')
|
||||
->makePrimaryKey()
|
||||
->generatedValue('IDENTITY')
|
||||
->option('unsigned', true)
|
||||
->build();
|
||||
|
||||
$builder->createField('authority', Type::STRING)
|
||||
->unique()
|
||||
->build();
|
||||
@@ -28,7 +28,6 @@ $builder->createField('longUrl', Type::STRING)
|
||||
|
||||
$builder->createField('shortCode', Type::STRING)
|
||||
->columnName('short_code')
|
||||
->unique()
|
||||
->length(255)
|
||||
->build();
|
||||
|
||||
@@ -61,3 +60,10 @@ $builder->createManyToMany('tags', Entity\Tag::class)
|
||||
->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE')
|
||||
->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE')
|
||||
->build();
|
||||
|
||||
$builder->createManyToOne('domain', Entity\Domain::class)
|
||||
->addJoinColumn('domain_id', 'id', true, false, 'RESTRICT')
|
||||
->cascadePersist()
|
||||
->build();
|
||||
|
||||
$builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain');
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
'translator' => [
|
||||
'translation_file_patterns' => [
|
||||
[
|
||||
'type' => 'gettext',
|
||||
'base_dir' => __DIR__ . '/../lang',
|
||||
'pattern' => '%s.mo',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
Binary file not shown.
@@ -1,44 +0,0 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Shlink 1.0\n"
|
||||
"POT-Creation-Date: 2019-04-14 08:58+0200\n"
|
||||
"PO-Revision-Date: 2019-04-14 08:58+0200\n"
|
||||
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: es_ES\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: Poedit 2.1.1\n"
|
||||
"X-Poedit-Basepath: ..\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Poedit-SourceCharset: UTF-8\n"
|
||||
"X-Poedit-KeywordsList: translate;translaePlural;translate_plural\n"
|
||||
"X-Poedit-SearchPath-0: templates\n"
|
||||
"X-Poedit-SearchPath-1: config\n"
|
||||
"X-Poedit-SearchPath-2: src\n"
|
||||
|
||||
msgid "URL Not Found"
|
||||
msgstr "URL no encontrada"
|
||||
|
||||
msgid "Page not found."
|
||||
msgstr "Página no encontrada."
|
||||
|
||||
msgid "The page you requested could not be found."
|
||||
msgstr "La página solicitada no ha podido ser encontrada."
|
||||
|
||||
msgid "Oops!"
|
||||
msgstr "¡Vaya!"
|
||||
|
||||
#, php-format
|
||||
msgid "We encountered a %s %s error."
|
||||
msgstr "Hemos encontrado un error %s %s."
|
||||
|
||||
msgid "This short URL doesn't seem to be valid."
|
||||
msgstr "Esta URL acortada no parece ser válida."
|
||||
|
||||
msgid "Make sure you included all the characters, with no extra punctuation."
|
||||
msgstr "Asegúrate de haber incluído todos los caracteres, sin puntuación extra."
|
||||
|
||||
msgid "Invalid URL"
|
||||
msgstr "URL inválida"
|
||||
@@ -53,11 +53,12 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$shortCode = $request->getAttribute('shortCode', '');
|
||||
$domain = $request->getUri()->getAuthority();
|
||||
$query = $request->getQueryParams();
|
||||
$disableTrackParam = $this->appOptions->getDisableTrackParam();
|
||||
|
||||
try {
|
||||
$url = $this->urlShortener->shortCodeToUrl($shortCode);
|
||||
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain);
|
||||
|
||||
// Track visit to this short code
|
||||
if ($disableTrackParam === null || ! array_key_exists($disableTrackParam, $query)) {
|
||||
|
||||
35
module/Core/src/Config/BasePathPrefixer.php
Normal file
35
module/Core/src/Config/BasePathPrefixer.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
use function Functional\map;
|
||||
|
||||
class BasePathPrefixer
|
||||
{
|
||||
private const ELEMENTS_WITH_PATH = ['routes', 'middleware_pipeline'];
|
||||
|
||||
public function __invoke(array $config): array
|
||||
{
|
||||
$basePath = $config['router']['base_path'] ?? '';
|
||||
$config['url_shortener']['domain']['hostname'] .= $basePath;
|
||||
|
||||
foreach (self::ELEMENTS_WITH_PATH as $configKey) {
|
||||
$config[$configKey] = $this->prefixPathsWithBasePath($configKey, $config, $basePath);
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
private function prefixPathsWithBasePath(string $configKey, array $config, string $basePath): array
|
||||
{
|
||||
return map($config[$configKey] ?? [], function (array $element) use ($basePath) {
|
||||
if (! isset($element['path'])) {
|
||||
return $element;
|
||||
}
|
||||
|
||||
$element['path'] = $basePath . $element['path'];
|
||||
return $element;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core;
|
||||
namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
use Shlinkio\Shlink\Installer\Util\PathCollection;
|
||||
use Zend\Stdlib\ArrayUtils;
|
||||
@@ -21,8 +21,8 @@ class SimplifiedConfigParser
|
||||
'not_found_redirect_to' => ['url_shortener', 'not_found_short_url', 'redirect_to'],
|
||||
'db_config' => ['entity_manager', 'connection'],
|
||||
'delete_short_url_threshold' => ['delete_short_urls', 'visits_threshold'],
|
||||
'locale' => ['translator', 'locale'],
|
||||
'redis_servers' => ['redis', 'servers'],
|
||||
'base_path' => ['router', 'base_path'],
|
||||
];
|
||||
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
|
||||
'not_found_redirect_to' => [
|
||||
11
module/Core/src/Domain/Resolver/DomainResolverInterface.php
Normal file
11
module/Core/src/Domain/Resolver/DomainResolverInterface.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain\Resolver;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
|
||||
interface DomainResolverInterface
|
||||
{
|
||||
public function resolveDomain(?string $domain): ?Domain;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain\Resolver;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
|
||||
class PersistenceDomainResolver implements DomainResolverInterface
|
||||
{
|
||||
/** @var EntityManagerInterface */
|
||||
private $em;
|
||||
|
||||
public function __construct(EntityManagerInterface $em)
|
||||
{
|
||||
$this->em = $em;
|
||||
}
|
||||
|
||||
public function resolveDomain(?string $domain): ?Domain
|
||||
{
|
||||
if ($domain === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var Domain|null $existingDomain */
|
||||
$existingDomain = $this->em->getRepository(Domain::class)->findOneBy(['authority' => $domain]);
|
||||
return $existingDomain ?? new Domain($domain);
|
||||
}
|
||||
}
|
||||
14
module/Core/src/Domain/Resolver/SimpleDomainResolver.php
Normal file
14
module/Core/src/Domain/Resolver/SimpleDomainResolver.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Domain\Resolver;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
|
||||
class SimpleDomainResolver implements DomainResolverInterface
|
||||
{
|
||||
public function resolveDomain(?string $domain): ?Domain
|
||||
{
|
||||
return $domain !== null ? new Domain($domain) : null;
|
||||
}
|
||||
}
|
||||
22
module/Core/src/Entity/Domain.php
Normal file
22
module/Core/src/Entity/Domain.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Entity;
|
||||
|
||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
|
||||
class Domain extends AbstractEntity
|
||||
{
|
||||
/** @var string */
|
||||
private $authority;
|
||||
|
||||
public function __construct(string $authority)
|
||||
{
|
||||
$this->authority = $authority;
|
||||
}
|
||||
|
||||
public function getAuthority(): string
|
||||
{
|
||||
return $this->authority;
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,15 @@ use Cake\Chronos\Chronos;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver\SimpleDomainResolver;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Zend\Diactoros\Uri;
|
||||
|
||||
use function array_reduce;
|
||||
use function count;
|
||||
use function Functional\contains;
|
||||
use function Functional\invoke;
|
||||
|
||||
class ShortUrl extends AbstractEntity
|
||||
{
|
||||
@@ -29,9 +35,14 @@ class ShortUrl extends AbstractEntity
|
||||
private $validUntil;
|
||||
/** @var integer|null */
|
||||
private $maxVisits;
|
||||
/** @var Domain|null */
|
||||
private $domain;
|
||||
|
||||
public function __construct(string $longUrl, ?ShortUrlMeta $meta = null)
|
||||
{
|
||||
public function __construct(
|
||||
string $longUrl,
|
||||
?ShortUrlMeta $meta = null,
|
||||
?DomainResolverInterface $domainResolver = null
|
||||
) {
|
||||
$meta = $meta ?? ShortUrlMeta::createEmpty();
|
||||
|
||||
$this->longUrl = $longUrl;
|
||||
@@ -42,6 +53,7 @@ class ShortUrl extends AbstractEntity
|
||||
$this->validUntil = $meta->getValidUntil();
|
||||
$this->maxVisits = $meta->getMaxVisits();
|
||||
$this->shortCode = $meta->getCustomSlug() ?? ''; // TODO logic to calculate short code should be passed somehow
|
||||
$this->domain = ($domainResolver ?? new SimpleDomainResolver())->resolveDomain($meta->getDomain());
|
||||
}
|
||||
|
||||
public function getLongUrl(): string
|
||||
@@ -131,4 +143,47 @@ class ShortUrl extends AbstractEntity
|
||||
{
|
||||
return $this->maxVisits !== null && $this->getVisitsCount() >= $this->maxVisits;
|
||||
}
|
||||
|
||||
public function toString(array $domainConfig): string
|
||||
{
|
||||
return (string) (new Uri())->withPath($this->shortCode)
|
||||
->withScheme($domainConfig['schema'] ?? 'http')
|
||||
->withHost($this->resolveDomain($domainConfig['hostname'] ?? ''));
|
||||
}
|
||||
|
||||
private function resolveDomain(string $fallback = ''): string
|
||||
{
|
||||
if ($this->domain === null) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return $this->domain->getAuthority();
|
||||
}
|
||||
|
||||
public function matchesCriteria(ShortUrlMeta $meta, array $tags): bool
|
||||
{
|
||||
if ($meta->hasMaxVisits() && $meta->getMaxVisits() !== $this->maxVisits) {
|
||||
return false;
|
||||
}
|
||||
if ($meta->hasDomain() && $meta->getDomain() !== $this->resolveDomain()) {
|
||||
return false;
|
||||
}
|
||||
if ($meta->hasValidSince() && ! $meta->getValidSince()->eq($this->validSince)) {
|
||||
return false;
|
||||
}
|
||||
if ($meta->hasValidUntil() && ! $meta->getValidUntil()->eq($this->validUntil)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$shortUrlTags = invoke($this->getTags(), '__toString');
|
||||
$hasAllTags = count($shortUrlTags) === count($tags) && array_reduce(
|
||||
$tags,
|
||||
function (bool $hasAllTags, string $tag) use ($shortUrlTags) {
|
||||
return $hasAllTags && contains($shortUrlTags, $tag);
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
return $hasAllTags;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,13 @@ use function sprintf;
|
||||
|
||||
class NonUniqueSlugException extends InvalidArgumentException
|
||||
{
|
||||
public static function fromSlug(string $slug): self
|
||||
public static function fromSlug(string $slug, ?string $domain): self
|
||||
{
|
||||
return new self(sprintf('Provided slug "%s" is not unique.', $slug));
|
||||
$suffix = '';
|
||||
if ($domain !== null) {
|
||||
$suffix = sprintf(' for domain "%s"', $domain);
|
||||
}
|
||||
|
||||
return new self(sprintf('Provided slug "%s" is not unique%s.', $slug, $suffix));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ final class ShortUrlMeta
|
||||
private $maxVisits;
|
||||
/** @var bool|null */
|
||||
private $findIfExists;
|
||||
/** @var string|null */
|
||||
private $domain;
|
||||
|
||||
// Force named constructors
|
||||
private function __construct()
|
||||
@@ -47,6 +49,7 @@ final class ShortUrlMeta
|
||||
* @param string|null $customSlug
|
||||
* @param int|null $maxVisits
|
||||
* @param bool|null $findIfExists
|
||||
* @param string|null $domain
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public static function createFromParams(
|
||||
@@ -54,7 +57,8 @@ final class ShortUrlMeta
|
||||
$validUntil = null,
|
||||
$customSlug = null,
|
||||
$maxVisits = null,
|
||||
$findIfExists = null
|
||||
$findIfExists = null,
|
||||
$domain = null
|
||||
): self {
|
||||
// We do not type hint the arguments because that will be done by the validation process and we would get a
|
||||
// type error if any of them do not match
|
||||
@@ -65,6 +69,7 @@ final class ShortUrlMeta
|
||||
ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug,
|
||||
ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits,
|
||||
ShortUrlMetaInputFilter::FIND_IF_EXISTS => $findIfExists,
|
||||
ShortUrlMetaInputFilter::DOMAIN => $domain,
|
||||
]);
|
||||
return $instance;
|
||||
}
|
||||
@@ -86,6 +91,7 @@ final class ShortUrlMeta
|
||||
$this->maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS);
|
||||
$this->maxVisits = $this->maxVisits !== null ? (int) $this->maxVisits : null;
|
||||
$this->findIfExists = $inputFilter->getValue(ShortUrlMetaInputFilter::FIND_IF_EXISTS);
|
||||
$this->domain = $inputFilter->getValue(ShortUrlMetaInputFilter::DOMAIN);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,4 +150,14 @@ final class ShortUrlMeta
|
||||
{
|
||||
return (bool) $this->findIfExists;
|
||||
}
|
||||
|
||||
public function hasDomain(): bool
|
||||
{
|
||||
return $this->domain !== null;
|
||||
}
|
||||
|
||||
public function getDomain(): ?string
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,14 +117,22 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
return $qb;
|
||||
}
|
||||
|
||||
public function findOneByShortCode(string $shortCode): ?ShortUrl
|
||||
public function findOneByShortCode(string $shortCode, ?string $domain = null): ?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()->getName();
|
||||
$ordering = $dbPlatform === 'postgresql' ? 'ASC' : 'DESC';
|
||||
|
||||
$dql= <<<DQL
|
||||
SELECT s
|
||||
FROM Shlinkio\Shlink\Core\Entity\ShortUrl AS s
|
||||
LEFT JOIN s.domain AS d
|
||||
WHERE s.shortCode = :shortCode
|
||||
AND (s.validSince <= :now OR s.validSince IS NULL)
|
||||
AND (s.validUntil >= :now OR s.validUntil IS NULL)
|
||||
AND (s.domain IS NULL OR d.authority = :domain)
|
||||
ORDER BY s.domain {$ordering}
|
||||
DQL;
|
||||
|
||||
$query = $this->getEntityManager()->createQuery($dql);
|
||||
@@ -132,10 +140,38 @@ DQL;
|
||||
->setParameters([
|
||||
'shortCode' => $shortCode,
|
||||
'now' => Chronos::now(),
|
||||
'domain' => $domain,
|
||||
]);
|
||||
|
||||
/** @var ShortUrl|null $result */
|
||||
$result = $query->getOneOrNullResult();
|
||||
return $result === null || $result->maxVisitsReached() ? null : $result;
|
||||
// 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:
|
||||
// * 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
|
||||
|
||||
/** @var ShortUrl|null $shortUrl */
|
||||
$shortUrl = $query->getOneOrNullResult();
|
||||
return $shortUrl !== null && ! $shortUrl->maxVisitsReached() ? $shortUrl : null;
|
||||
}
|
||||
|
||||
public function slugIsInUse(string $slug, ?string $domain = null): bool
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->select('COUNT(DISTINCT s.id)')
|
||||
->from(ShortUrl::class, 's')
|
||||
->where($qb->expr()->isNotNull('s.shortCode'))
|
||||
->andWhere($qb->expr()->eq('s.shortCode', ':slug'))
|
||||
->setParameter('slug', $slug);
|
||||
|
||||
if ($domain !== null) {
|
||||
$qb->join('s.domain', 'd')
|
||||
->andWhere($qb->expr()->eq('d.authority', ':authority'))
|
||||
->setParameter('authority', $domain);
|
||||
} else {
|
||||
$qb->andWhere($qb->expr()->isNull('s.domain'));
|
||||
}
|
||||
|
||||
$result = (int) $qb->getQuery()->getSingleScalarResult();
|
||||
return $result > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,5 +26,7 @@ interface ShortUrlRepositoryInterface extends ObjectRepository
|
||||
*/
|
||||
public function countList(?string $searchTerm = null, array $tags = []): int;
|
||||
|
||||
public function findOneByShortCode(string $shortCode): ?ShortUrl;
|
||||
public function findOneByShortCode(string $shortCode, ?string $domain = null): ?ShortUrl;
|
||||
|
||||
public function slugIsInUse(string $slug, ?string $domain): bool;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use GuzzleHttp\ClientInterface;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
|
||||
@@ -22,11 +23,8 @@ use Shlinkio\Shlink\Core\Util\TagManagerTrait;
|
||||
use Throwable;
|
||||
|
||||
use function array_reduce;
|
||||
use function count;
|
||||
use function floor;
|
||||
use function fmod;
|
||||
use function Functional\contains;
|
||||
use function Functional\invoke;
|
||||
use function preg_match;
|
||||
use function strlen;
|
||||
|
||||
@@ -77,7 +75,7 @@ class UrlShortener implements UrlShortenerInterface
|
||||
$this->em->beginTransaction();
|
||||
|
||||
// First, create the short URL with an empty short code
|
||||
$shortUrl = new ShortUrl($url, $meta);
|
||||
$shortUrl = new ShortUrl($url, $meta, new PersistenceDomainResolver($this->em));
|
||||
$this->em->persist($shortUrl);
|
||||
$this->em->flush();
|
||||
|
||||
@@ -120,30 +118,11 @@ class UrlShortener implements UrlShortenerInterface
|
||||
|
||||
// Iterate short URLs until one that matches is found, or return null otherwise
|
||||
return array_reduce($shortUrls, function (?ShortUrl $found, ShortUrl $shortUrl) use ($tags, $meta) {
|
||||
if ($found) {
|
||||
if ($found !== null) {
|
||||
return $found;
|
||||
}
|
||||
|
||||
if ($meta->hasMaxVisits() && $meta->getMaxVisits() !== $shortUrl->getMaxVisits()) {
|
||||
return null;
|
||||
}
|
||||
if ($meta->hasValidSince() && ! $meta->getValidSince()->eq($shortUrl->getValidSince())) {
|
||||
return null;
|
||||
}
|
||||
if ($meta->hasValidUntil() && ! $meta->getValidUntil()->eq($shortUrl->getValidUntil())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$shortUrlTags = invoke($shortUrl->getTags(), '__toString');
|
||||
$hasAllTags = count($shortUrlTags) === count($tags) && array_reduce(
|
||||
$tags,
|
||||
function (bool $hasAllTags, string $tag) use ($shortUrlTags) {
|
||||
return $hasAllTags && contains($shortUrlTags, $tag);
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
return $hasAllTags ? $shortUrl : null;
|
||||
return $shortUrl->matchesCriteria($meta, $tags) ? $shortUrl : null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -165,12 +144,13 @@ class UrlShortener implements UrlShortenerInterface
|
||||
}
|
||||
|
||||
$customSlug = $meta->getCustomSlug();
|
||||
$domain = $meta->getDomain();
|
||||
|
||||
/** @var ShortUrlRepository $repo */
|
||||
$repo = $this->em->getRepository(ShortUrl::class);
|
||||
$shortUrlsCount = $repo->count(['shortCode' => $customSlug]);
|
||||
$shortUrlsCount = $repo->slugIsInUse($customSlug, $domain);
|
||||
if ($shortUrlsCount > 0) {
|
||||
throw NonUniqueSlugException::fromSlug($customSlug);
|
||||
throw NonUniqueSlugException::fromSlug($customSlug, $domain);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +175,7 @@ class UrlShortener implements UrlShortenerInterface
|
||||
* @throws InvalidShortCodeException
|
||||
* @throws EntityDoesNotExistException
|
||||
*/
|
||||
public function shortCodeToUrl(string $shortCode): ShortUrl
|
||||
public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl
|
||||
{
|
||||
$chars = $this->options->getChars();
|
||||
|
||||
@@ -206,7 +186,7 @@ class UrlShortener implements UrlShortenerInterface
|
||||
|
||||
/** @var ShortUrlRepository $shortUrlRepo */
|
||||
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
|
||||
$shortUrl = $shortUrlRepo->findOneByShortCode($shortCode);
|
||||
$shortUrl = $shortUrlRepo->findOneByShortCode($shortCode, $domain);
|
||||
if ($shortUrl === null) {
|
||||
throw EntityDoesNotExistException::createFromEntityAndConditions(ShortUrl::class, [
|
||||
'shortCode' => $shortCode,
|
||||
|
||||
@@ -26,5 +26,5 @@ interface UrlShortenerInterface
|
||||
* @throws InvalidShortCodeException
|
||||
* @throws EntityDoesNotExistException
|
||||
*/
|
||||
public function shortCodeToUrl(string $shortCode): ShortUrl;
|
||||
public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl;
|
||||
}
|
||||
|
||||
@@ -5,15 +5,12 @@ namespace Shlinkio\Shlink\Core\Transformer;
|
||||
|
||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Util\ShortUrlBuilderTrait;
|
||||
|
||||
use function Functional\invoke;
|
||||
use function Functional\invoke_if;
|
||||
|
||||
class ShortUrlDataTransformer implements DataTransformerInterface
|
||||
{
|
||||
use ShortUrlBuilderTrait;
|
||||
|
||||
/** @var array */
|
||||
private $domainConfig;
|
||||
|
||||
@@ -23,21 +20,20 @@ class ShortUrlDataTransformer implements DataTransformerInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ShortUrl $value
|
||||
* @param ShortUrl $shortUrl
|
||||
*/
|
||||
public function transform($value): array
|
||||
public function transform($shortUrl): array
|
||||
{
|
||||
$longUrl = $value->getLongUrl();
|
||||
$shortCode = $value->getShortCode();
|
||||
$longUrl = $shortUrl->getLongUrl();
|
||||
|
||||
return [
|
||||
'shortCode' => $shortCode,
|
||||
'shortUrl' => $this->buildShortUrl($this->domainConfig, $shortCode),
|
||||
'shortCode' => $shortUrl->getShortCode(),
|
||||
'shortUrl' => $shortUrl->toString($this->domainConfig),
|
||||
'longUrl' => $longUrl,
|
||||
'dateCreated' => $value->getDateCreated()->toAtomString(),
|
||||
'visitsCount' => $value->getVisitsCount(),
|
||||
'tags' => invoke($value->getTags(), '__toString'),
|
||||
'meta' => $this->buildMeta($value),
|
||||
'dateCreated' => $shortUrl->getDateCreated()->toAtomString(),
|
||||
'visitsCount' => $shortUrl->getVisitsCount(),
|
||||
'tags' => invoke($shortUrl->getTags(), '__toString'),
|
||||
'meta' => $this->buildMeta($shortUrl),
|
||||
|
||||
// Deprecated
|
||||
'originalUrl' => $longUrl,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Util;
|
||||
|
||||
use Zend\Diactoros\Uri;
|
||||
|
||||
trait ShortUrlBuilderTrait
|
||||
{
|
||||
private function buildShortUrl(array $domainConfig, string $shortCode): string
|
||||
{
|
||||
return (string) (new Uri())->withPath($shortCode)
|
||||
->withScheme($domainConfig['schema'] ?? 'http')
|
||||
->withHost($domainConfig['hostname'] ?? '');
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ trait TagManagerTrait
|
||||
* @param string[] $tags
|
||||
* @return Collections\Collection|Tag[]
|
||||
*/
|
||||
private function tagNamesToEntities(EntityManagerInterface $em, array $tags)
|
||||
private function tagNamesToEntities(EntityManagerInterface $em, array $tags): Collections\Collection
|
||||
{
|
||||
$entities = [];
|
||||
foreach ($tags as $tagName) {
|
||||
|
||||
@@ -17,6 +17,7 @@ class ShortUrlMetaInputFilter extends InputFilter
|
||||
public const CUSTOM_SLUG = 'customSlug';
|
||||
public const MAX_VISITS = 'maxVisits';
|
||||
public const FIND_IF_EXISTS = 'findIfExists';
|
||||
public const DOMAIN = 'domain';
|
||||
|
||||
public function __construct(?array $data = null)
|
||||
{
|
||||
@@ -46,5 +47,11 @@ class ShortUrlMetaInputFilter extends InputFilter
|
||||
$this->add($maxVisits);
|
||||
|
||||
$this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false));
|
||||
|
||||
$domain = $this->createInput(self::DOMAIN, false);
|
||||
$domain->getValidatorChain()->attach(new Validator\Hostname([
|
||||
'allow' => Validator\Hostname::ALLOW_DNS | Validator\Hostname::ALLOW_LOCAL,
|
||||
]));
|
||||
$this->add($domain);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php $this->layout('ShlinkCore::layout/default') ?>
|
||||
|
||||
<?php $this->start('title') ?>
|
||||
<?= $this->translate('URL Not Found') ?>
|
||||
URL Not Found
|
||||
<?php $this->end() ?>
|
||||
|
||||
<?php $this->start('stylesheets') ?>
|
||||
@@ -14,6 +14,6 @@
|
||||
<?php $this->start('main') ?>
|
||||
<h1>404</h1>
|
||||
<hr>
|
||||
<h3><?= $this->translate('Page not found.') ?></h3>
|
||||
<p><?= $this->translate('The page you requested could not be found.') ?></p>
|
||||
<h3>Page not found.</h3>
|
||||
<p>The page you requested could not be found.</p>
|
||||
<?php $this->end() ?>
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
<?php $this->end() ?>
|
||||
|
||||
<?php $this->start('main') ?>
|
||||
<h1><?= $this->translate('Oops!') ?></h1>
|
||||
<h1>Oops!</h1>
|
||||
<hr>
|
||||
|
||||
<?php if ($status !== 404): ?>
|
||||
<p><?= sprintf($this->translate('We encountered a %s %s error.'), $status, $reason) ?></p>
|
||||
<p><?= sprintf('We encountered a %s %s error.', $status, $reason) ?></p>
|
||||
<?php else: ?>
|
||||
<p><?= $this->translate('This short URL doesn\'t seem to be valid.') ?></p>
|
||||
<p><?= $this->translate('Make sure you included all the characters, with no extra punctuation.') ?></p>
|
||||
<p>'This short URL doesn't seem to be valid.</p>
|
||||
<p>'Make sure you included all the characters, with no extra punctuation.</p>
|
||||
<?php endif; ?>
|
||||
<?php $this->end() ?>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php $this->layout('ShlinkCore::layout/default') ?>
|
||||
|
||||
<?php $this->start('title') ?>
|
||||
<?= $this->translate('Invalid URL') ?>
|
||||
Invalid URL
|
||||
<?php $this->end() ?>
|
||||
|
||||
<?php $this->start('stylesheets') ?>
|
||||
@@ -12,8 +12,8 @@
|
||||
<?php $this->end() ?>
|
||||
|
||||
<?php $this->start('main') ?>
|
||||
<h1><?= $this->translate('Oops!') ?></h1>
|
||||
<h1>Oops!</h1>
|
||||
<hr>
|
||||
<p><?= $this->translate('This short URL doesn\'t seem to be valid.') ?></p>
|
||||
<p><?= $this->translate('Make sure you included all the characters, with no extra punctuation.') ?></p>
|
||||
<p>This short URL doesn't seem to be valid.</p>
|
||||
<p>Make sure you included all the characters, with no extra punctuation.</p>
|
||||
<?php $this->end() ?>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?= $this->locale() ?>">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title><?= $this->section('title', '') ?> | URL shortener</title>
|
||||
<meta charset="utf-8">
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace ShlinkioTest\Shlink\Core\Repository;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
@@ -21,6 +22,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
||||
Tag::class,
|
||||
Visit::class,
|
||||
ShortUrl::class,
|
||||
Domain::class,
|
||||
];
|
||||
|
||||
/** @var ShortUrlRepository */
|
||||
@@ -32,37 +34,64 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function findOneByShortCodeReturnsProperData()
|
||||
public function findOneByShortCodeReturnsProperData(): void
|
||||
{
|
||||
$foo = new ShortUrl('foo');
|
||||
$foo->setShortCode('foo');
|
||||
$this->getEntityManager()->persist($foo);
|
||||
$regularOne = new ShortUrl('foo');
|
||||
$regularOne->setShortCode('foo');
|
||||
$this->getEntityManager()->persist($regularOne);
|
||||
|
||||
$bar = new ShortUrl('bar', ShortUrlMeta::createFromParams(Chronos::now()->addMonth()));
|
||||
$bar->setShortCode('bar_very_long_text');
|
||||
$this->getEntityManager()->persist($bar);
|
||||
$notYetValid = new ShortUrl('bar', ShortUrlMeta::createFromParams(Chronos::now()->addMonth()));
|
||||
$notYetValid->setShortCode('bar_very_long_text');
|
||||
$this->getEntityManager()->persist($notYetValid);
|
||||
|
||||
$baz = new ShortUrl('baz', ShortUrlMeta::createFromRawData(['maxVisits' => 3]));
|
||||
$expired = new ShortUrl('expired', ShortUrlMeta::createFromParams(null, Chronos::now()->subMonth()));
|
||||
$expired->setShortCode('expired');
|
||||
$this->getEntityManager()->persist($expired);
|
||||
|
||||
$allVisitsComplete = new ShortUrl('baz', ShortUrlMeta::createFromRawData(['maxVisits' => 3]));
|
||||
$visits = [];
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$visit = new Visit($baz, Visitor::emptyInstance());
|
||||
$visit = new Visit($allVisitsComplete, Visitor::emptyInstance());
|
||||
$this->getEntityManager()->persist($visit);
|
||||
$visits[] = $visit;
|
||||
}
|
||||
$baz->setShortCode('baz')
|
||||
->setVisits(new ArrayCollection($visits));
|
||||
$this->getEntityManager()->persist($baz);
|
||||
$allVisitsComplete->setShortCode('baz')
|
||||
->setVisits(new ArrayCollection($visits));
|
||||
$this->getEntityManager()->persist($allVisitsComplete);
|
||||
|
||||
$withDomain = new ShortUrl('foo', ShortUrlMeta::createFromRawData(['domain' => 'example.com']));
|
||||
$withDomain->setShortCode('domain-short-code');
|
||||
$this->getEntityManager()->persist($withDomain);
|
||||
|
||||
$withDomainDuplicatingRegular = new ShortUrl('foo_with_domain', ShortUrlMeta::createFromRawData([
|
||||
'domain' => 'doma.in',
|
||||
]));
|
||||
$withDomainDuplicatingRegular->setShortCode('foo');
|
||||
$this->getEntityManager()->persist($withDomainDuplicatingRegular);
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->assertSame($foo, $this->repo->findOneByShortCode($foo->getShortCode()));
|
||||
$this->assertSame($regularOne, $this->repo->findOneByShortCode($regularOne->getShortCode()));
|
||||
$this->assertSame($regularOne, $this->repo->findOneByShortCode($withDomainDuplicatingRegular->getShortCode()));
|
||||
$this->assertSame($withDomain, $this->repo->findOneByShortCode($withDomain->getShortCode(), 'example.com'));
|
||||
$this->assertSame(
|
||||
$withDomainDuplicatingRegular,
|
||||
$this->repo->findOneByShortCode($withDomainDuplicatingRegular->getShortCode(), 'doma.in')
|
||||
);
|
||||
$this->assertSame(
|
||||
$regularOne,
|
||||
$this->repo->findOneByShortCode($withDomainDuplicatingRegular->getShortCode(), 'other-domain.com')
|
||||
);
|
||||
$this->assertNull($this->repo->findOneByShortCode('invalid'));
|
||||
$this->assertNull($this->repo->findOneByShortCode($bar->getShortCode()));
|
||||
$this->assertNull($this->repo->findOneByShortCode($baz->getShortCode()));
|
||||
$this->assertNull($this->repo->findOneByShortCode($withDomain->getShortCode()));
|
||||
$this->assertNull($this->repo->findOneByShortCode($withDomain->getShortCode(), 'other-domain.com'));
|
||||
$this->assertNull($this->repo->findOneByShortCode($notYetValid->getShortCode()));
|
||||
$this->assertNull($this->repo->findOneByShortCode($expired->getShortCode()));
|
||||
$this->assertNull($this->repo->findOneByShortCode($allVisitsComplete->getShortCode()));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function countListReturnsProperNumberOfResults()
|
||||
public function countListReturnsProperNumberOfResults(): void
|
||||
{
|
||||
$count = 5;
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
@@ -76,7 +105,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function findListProperlyFiltersByTagAndSearchTerm()
|
||||
public function findListProperlyFiltersByTagAndSearchTerm(): void
|
||||
{
|
||||
$tag = new Tag('bar');
|
||||
$this->getEntityManager()->persist($tag);
|
||||
@@ -121,7 +150,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function findListProperlyMapsFieldNamesToColumnNamesWhenOrdering()
|
||||
public function findListProperlyMapsFieldNamesToColumnNamesWhenOrdering(): void
|
||||
{
|
||||
$urls = ['a', 'z', 'c', 'b'];
|
||||
foreach ($urls as $url) {
|
||||
@@ -140,4 +169,26 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
||||
$this->assertEquals('c', $result[2]->getLongUrl());
|
||||
$this->assertEquals('z', $result[3]->getLongUrl());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function slugIsInUseLooksForShortUrlInProperSetOfTables(): void
|
||||
{
|
||||
$shortUrlWithoutDomain = (new ShortUrl('foo'))->setShortCode('my-cool-slug');
|
||||
$this->getEntityManager()->persist($shortUrlWithoutDomain);
|
||||
|
||||
$shortUrlWithDomain = (new ShortUrl(
|
||||
'foo',
|
||||
ShortUrlMeta::createFromRawData(['domain' => 'doma.in'])
|
||||
))->setShortCode('another-slug');
|
||||
$this->getEntityManager()->persist($shortUrlWithDomain);
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->assertTrue($this->repo->slugIsInUse('my-cool-slug'));
|
||||
$this->assertFalse($this->repo->slugIsInUse('my-cool-slug', 'doma.in'));
|
||||
$this->assertFalse($this->repo->slugIsInUse('slug-not-in-use'));
|
||||
$this->assertFalse($this->repo->slugIsInUse('another-slug'));
|
||||
$this->assertFalse($this->repo->slugIsInUse('another-slug', 'example.com'));
|
||||
$this->assertTrue($this->repo->slugIsInUse('another-slug', 'doma.in'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ class PixelActionTest extends TestCase
|
||||
public function imageIsReturned(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn(
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, '')->willReturn(
|
||||
new ShortUrl('http://domain.com/foo/bar')
|
||||
)->shouldBeCalledOnce();
|
||||
$this->visitTracker->track(Argument::cetera())->shouldBeCalledOnce();
|
||||
|
||||
@@ -47,8 +47,8 @@ class RedirectActionTest extends TestCase
|
||||
$shortCode = 'abc123';
|
||||
$expectedUrl = 'http://domain.com/foo/bar';
|
||||
$shortUrl = new ShortUrl($expectedUrl);
|
||||
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($shortUrl)
|
||||
->shouldBeCalledOnce();
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, '')->willReturn($shortUrl)
|
||||
->shouldBeCalledOnce();
|
||||
$this->visitTracker->track(Argument::cetera())->shouldBeCalledOnce();
|
||||
|
||||
$request = (new ServerRequest())->withAttribute('shortCode', $shortCode);
|
||||
@@ -64,8 +64,8 @@ class RedirectActionTest extends TestCase
|
||||
public function nextMiddlewareIsInvokedIfLongUrlIsNotFound(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class)
|
||||
->shouldBeCalledOnce();
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(EntityDoesNotExistException::class)
|
||||
->shouldBeCalledOnce();
|
||||
$this->visitTracker->track(Argument::cetera())->shouldNotBeCalled();
|
||||
|
||||
$handler = $this->prophesize(RequestHandlerInterface::class);
|
||||
@@ -81,7 +81,7 @@ class RedirectActionTest extends TestCase
|
||||
public function redirectToCustomUrlIsReturnedIfConfiguredSoAndShortUrlIsNotFound(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$shortCodeToUrl = $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(
|
||||
$shortCodeToUrl = $this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(
|
||||
EntityDoesNotExistException::class
|
||||
);
|
||||
|
||||
@@ -106,8 +106,8 @@ class RedirectActionTest extends TestCase
|
||||
$shortCode = 'abc123';
|
||||
$expectedUrl = 'http://domain.com/foo/bar';
|
||||
$shortUrl = new ShortUrl($expectedUrl);
|
||||
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($shortUrl)
|
||||
->shouldBeCalledOnce();
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, '')->willReturn($shortUrl)
|
||||
->shouldBeCalledOnce();
|
||||
$this->visitTracker->track(Argument::cetera())->shouldNotBeCalled();
|
||||
|
||||
$request = (new ServerRequest())->withAttribute('shortCode', $shortCode)
|
||||
|
||||
91
module/Core/test/Config/BasePathPrefixerTest.php
Normal file
91
module/Core/test/Config/BasePathPrefixerTest.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Config;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Config\BasePathPrefixer;
|
||||
|
||||
class BasePathPrefixerTest extends TestCase
|
||||
{
|
||||
/** @var BasePathPrefixer */
|
||||
private $prefixer;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->prefixer = new BasePathPrefixer();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideConfig
|
||||
*/
|
||||
public function parsesConfigAsExpected(
|
||||
array $originalConfig,
|
||||
array $expectedRoutes,
|
||||
array $expectedMiddlewares,
|
||||
string $expectedHostname
|
||||
): void {
|
||||
[
|
||||
'routes' => $routes,
|
||||
'middleware_pipeline' => $middlewares,
|
||||
'url_shortener' => $urlShortener,
|
||||
] = ($this->prefixer)($originalConfig);
|
||||
|
||||
$this->assertEquals($expectedRoutes, $routes);
|
||||
$this->assertEquals($expectedMiddlewares, $middlewares);
|
||||
$this->assertEquals([
|
||||
'domain' => [
|
||||
'hostname' => $expectedHostname,
|
||||
],
|
||||
], $urlShortener);
|
||||
}
|
||||
|
||||
public function provideConfig(): iterable
|
||||
{
|
||||
$urlShortener = [
|
||||
'domain' => [
|
||||
'hostname' => null,
|
||||
],
|
||||
];
|
||||
|
||||
yield 'without anything' => [['url_shortener' => $urlShortener], [], [], ''];
|
||||
yield 'with empty options' => [
|
||||
[
|
||||
'routes' => [],
|
||||
'middleware_pipeline' => [],
|
||||
'url_shortener' => $urlShortener,
|
||||
],
|
||||
[],
|
||||
[],
|
||||
'',
|
||||
];
|
||||
yield 'with non-empty options' => [
|
||||
[
|
||||
'routes' => [
|
||||
['path' => '/something'],
|
||||
['path' => '/something-else'],
|
||||
],
|
||||
'middleware_pipeline' => [
|
||||
['with' => 'no_path'],
|
||||
['path' => '/rest', 'middleware' => []],
|
||||
],
|
||||
'url_shortener' => [
|
||||
'domain' => [
|
||||
'hostname' => 'doma.in',
|
||||
],
|
||||
],
|
||||
'router' => ['base_path' => '/foo/bar'],
|
||||
],
|
||||
[
|
||||
['path' => '/foo/bar/something'],
|
||||
['path' => '/foo/bar/something-else'],
|
||||
],
|
||||
[
|
||||
['with' => 'no_path'],
|
||||
['path' => '/foo/bar/rest', 'middleware' => []],
|
||||
],
|
||||
'doma.in/foo/bar',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core;
|
||||
namespace ShlinkioTest\Shlink\Core\Config;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\SimplifiedConfigParser;
|
||||
use Shlinkio\Shlink\Core\Config\SimplifiedConfigParser;
|
||||
|
||||
use function array_merge;
|
||||
|
||||
@@ -39,7 +39,6 @@ class SimplifiedConfigParserTest extends TestCase
|
||||
'short_domain_host' => 'doma.in',
|
||||
'validate_url' => false,
|
||||
'delete_short_url_threshold' => 50,
|
||||
'locale' => 'es',
|
||||
'not_found_redirect_to' => 'foobar.com',
|
||||
'redis_servers' => [
|
||||
'tcp://1.1.1.1:1111',
|
||||
@@ -51,6 +50,7 @@ class SimplifiedConfigParserTest extends TestCase
|
||||
'password' => 'bar',
|
||||
'port' => '1234',
|
||||
],
|
||||
'base_path' => '/foo/bar',
|
||||
];
|
||||
$expected = [
|
||||
'app_options' => [
|
||||
@@ -80,10 +80,6 @@ class SimplifiedConfigParserTest extends TestCase
|
||||
],
|
||||
],
|
||||
|
||||
'translator' => [
|
||||
'locale' => 'es',
|
||||
],
|
||||
|
||||
'delete_short_urls' => [
|
||||
'visits_threshold' => 50,
|
||||
'check_visits_threshold' => true,
|
||||
@@ -101,6 +97,10 @@ class SimplifiedConfigParserTest extends TestCase
|
||||
'tcp://1.2.2.2:2222',
|
||||
],
|
||||
],
|
||||
|
||||
'router' => [
|
||||
'base_path' => '/foo/bar',
|
||||
],
|
||||
];
|
||||
|
||||
$result = ($this->postProcessor)(array_merge($config, $simplified));
|
||||
@@ -24,7 +24,6 @@ class ConfigProviderTest extends TestCase
|
||||
$this->assertArrayHasKey('routes', $config);
|
||||
$this->assertArrayHasKey('dependencies', $config);
|
||||
$this->assertArrayHasKey('templates', $config);
|
||||
$this->assertArrayHasKey('translator', $config);
|
||||
$this->assertArrayHasKey('zend-expressive', $config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Domain\Resolver;
|
||||
|
||||
use Doctrine\Common\Persistence\ObjectRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
|
||||
class PersistenceDomainResolverTest extends TestCase
|
||||
{
|
||||
/** @var PersistenceDomainResolver */
|
||||
private $domainResolver;
|
||||
/** @var ObjectProphecy */
|
||||
private $em;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->em = $this->prophesize(EntityManagerInterface::class);
|
||||
$this->domainResolver = new PersistenceDomainResolver($this->em->reveal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function returnsEmptyWhenNoDomainIsProvided(): void
|
||||
{
|
||||
$getRepository = $this->em->getRepository(Domain::class);
|
||||
|
||||
$this->assertNull($this->domainResolver->resolveDomain(null));
|
||||
$getRepository->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideFoundDomains
|
||||
*/
|
||||
public function findsOrCreatesDomainWhenValueIsProvided(?Domain $foundDomain, string $authority): void
|
||||
{
|
||||
$repo = $this->prophesize(ObjectRepository::class);
|
||||
$findDomain = $repo->findOneBy(['authority' => $authority])->willReturn($foundDomain);
|
||||
$getRepository = $this->em->getRepository(Domain::class)->willReturn($repo->reveal());
|
||||
|
||||
$result = $this->domainResolver->resolveDomain($authority);
|
||||
|
||||
if ($foundDomain !== null) {
|
||||
$this->assertSame($result, $foundDomain);
|
||||
}
|
||||
$this->assertInstanceOf(Domain::class, $result);
|
||||
$this->assertEquals($authority, $result->getAuthority());
|
||||
$findDomain->shouldHaveBeenCalledOnce();
|
||||
$getRepository->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideFoundDomains(): iterable
|
||||
{
|
||||
$authority = 'doma.in';
|
||||
|
||||
yield 'without found domain' => [null, $authority];
|
||||
yield 'with found domain' => [new Domain($authority), $authority];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Domain\Resolver;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver\SimpleDomainResolver;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
|
||||
class SimpleDomainResolverTest extends TestCase
|
||||
{
|
||||
/** @var SimpleDomainResolver */
|
||||
private $domainResolver;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->domainResolver = new SimpleDomainResolver();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideDomains
|
||||
*/
|
||||
public function resolvesExpectedDomain(?string $domain): void
|
||||
{
|
||||
$result = $this->domainResolver->resolveDomain($domain);
|
||||
|
||||
if ($domain === null) {
|
||||
$this->assertNull($result);
|
||||
} else {
|
||||
$this->assertInstanceOf(Domain::class, $result);
|
||||
$this->assertEquals($domain, $result->getAuthority());
|
||||
}
|
||||
}
|
||||
|
||||
public function provideDomains(): iterable
|
||||
{
|
||||
yield 'with empty domain' => [null];
|
||||
yield 'with non-empty domain' => ['domain.com'];
|
||||
}
|
||||
}
|
||||
34
module/Core/test/Exception/NonUniqueSlugExceptionTest.php
Normal file
34
module/Core/test/Exception/NonUniqueSlugExceptionTest.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Exception;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
|
||||
class NonUniqueSlugExceptionTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideMessages
|
||||
*/
|
||||
public function properlyCreatesExceptionFromSlug(string $expectedMessage, string $slug, ?string $domain): void
|
||||
{
|
||||
$e = NonUniqueSlugException::fromSlug($slug, $domain);
|
||||
$this->assertEquals($expectedMessage, $e->getMessage());
|
||||
}
|
||||
|
||||
public function provideMessages(): iterable
|
||||
{
|
||||
yield 'without domain' => [
|
||||
'Provided slug "foo" is not unique.',
|
||||
'foo',
|
||||
null,
|
||||
];
|
||||
yield 'with domain' => [
|
||||
'Provided slug "baz" is not unique for domain "bar".',
|
||||
'baz',
|
||||
'bar',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ class UrlShortenerTest extends TestCase
|
||||
$shortUrl->setId('10');
|
||||
});
|
||||
$repo = $this->prophesize(ShortUrlRepository::class);
|
||||
$repo->count(Argument::any())->willReturn(0);
|
||||
$repo->slugIsInUse(Argument::cetera())->willReturn(false);
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
|
||||
|
||||
$this->setUrlShortener(false);
|
||||
@@ -122,11 +122,11 @@ class UrlShortenerTest extends TestCase
|
||||
public function exceptionIsThrownWhenNonUniqueSlugIsProvided(): void
|
||||
{
|
||||
$repo = $this->prophesize(ShortUrlRepository::class);
|
||||
$countBySlug = $repo->count(['shortCode' => 'custom-slug'])->willReturn(1);
|
||||
$slugIsInUse = $repo->slugIsInUse('custom-slug', null)->willReturn(true);
|
||||
$repo->findBy(Argument::cetera())->willReturn([]);
|
||||
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
|
||||
|
||||
$countBySlug->shouldBeCalledOnce();
|
||||
$slugIsInUse->shouldBeCalledOnce();
|
||||
$getRepo->shouldBeCalled();
|
||||
$this->expectException(NonUniqueSlugException::class);
|
||||
|
||||
@@ -247,7 +247,7 @@ class UrlShortenerTest extends TestCase
|
||||
$shortUrl->setShortCode($shortCode);
|
||||
|
||||
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
|
||||
$repo->findOneByShortCode($shortCode)->willReturn($shortUrl);
|
||||
$repo->findOneByShortCode($shortCode, null)->willReturn($shortUrl);
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
|
||||
|
||||
$url = $this->urlShortener->shortCodeToUrl($shortCode);
|
||||
|
||||
@@ -36,7 +36,8 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction
|
||||
$this->getOptionalDate($postData, 'validUntil'),
|
||||
$postData['customSlug'] ?? null,
|
||||
$postData['maxVisits'] ?? null,
|
||||
$postData['findIfExists'] ?? null
|
||||
$postData['findIfExists'] ?? null,
|
||||
$postData['domain'] ?? null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,10 +45,11 @@ class ResolveShortUrlAction extends AbstractRestAction
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$shortCode = $request->getAttribute('shortCode');
|
||||
$domain = $request->getQueryParams()['domain'] ?? null;
|
||||
$transformer = new ShortUrlDataTransformer($this->domainConfig);
|
||||
|
||||
try {
|
||||
$url = $this->urlShortener->shortCodeToUrl($shortCode);
|
||||
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain);
|
||||
return new JsonResponse($transformer->transform($url));
|
||||
} catch (InvalidShortCodeException $e) {
|
||||
$this->logger->warning('Provided short code with invalid format. {e}', ['e' => $e]);
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace ShlinkioApiTest\Shlink\Rest\Action;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Shlinkio\Shlink\Rest\Util\RestUtils;
|
||||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||
|
||||
use function Functional\map;
|
||||
@@ -33,6 +34,18 @@ class CreateShortUrlActionTest extends ApiTestCase
|
||||
$this->assertEquals('my-cool-slug', $payload['shortCode']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideConflictingSlugs
|
||||
*/
|
||||
public function failsToCreateShortUrlWithDuplicatedSlug(string $slug, ?string $domain): void
|
||||
{
|
||||
[$statusCode, $payload] = $this->createShortUrl(['customSlug' => $slug, 'domain' => $domain]);
|
||||
|
||||
$this->assertEquals(self::STATUS_BAD_REQUEST, $statusCode);
|
||||
$this->assertEquals(RestUtils::INVALID_SLUG_ERROR, $payload['error']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function createsNewShortUrlWithTags(): void
|
||||
{
|
||||
@@ -126,22 +139,32 @@ class CreateShortUrlActionTest extends ApiTestCase
|
||||
]];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function returnsErrorWhenRequestingReturnExistingButCustomSlugIsInUse(): void
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideConflictingSlugs
|
||||
*/
|
||||
public function returnsErrorWhenRequestingReturnExistingButCustomSlugIsInUse(string $slug, ?string $domain): void
|
||||
{
|
||||
$longUrl = 'https://www.alejandrocelaya.com';
|
||||
|
||||
[$firstStatusCode] = $this->createShortUrl(['longUrl' => $longUrl]);
|
||||
[$secondStatusCode] = $this->createShortUrl([
|
||||
'longUrl' => $longUrl,
|
||||
'customSlug' => 'custom',
|
||||
'customSlug' => $slug,
|
||||
'findIfExists' => true,
|
||||
'domain' => $domain,
|
||||
]);
|
||||
|
||||
$this->assertEquals(self::STATUS_OK, $firstStatusCode);
|
||||
$this->assertEquals(self::STATUS_BAD_REQUEST, $secondStatusCode);
|
||||
}
|
||||
|
||||
public function provideConflictingSlugs(): iterable
|
||||
{
|
||||
yield 'without domain' => ['custom', null];
|
||||
yield 'with domain' => ['custom-with-domain', 'some-domain.com'];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function createsNewShortUrlIfRequestedToFindButThereIsNoMatch(): void
|
||||
{
|
||||
|
||||
@@ -63,13 +63,45 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
],
|
||||
'originalUrl' => 'https://shlink.io',
|
||||
],
|
||||
[
|
||||
'shortCode' => 'ghi789',
|
||||
'shortUrl' => 'http://example.com/ghi789',
|
||||
'longUrl' =>
|
||||
'https://blog.alejandrocelaya.com/2019/04/27'
|
||||
. '/considerations-to-properly-use-open-source-software-projects/',
|
||||
'dateCreated' => '2019-01-01T00:00:00+00:00',
|
||||
'visitsCount' => 0,
|
||||
'tags' => [],
|
||||
'meta' => [
|
||||
'validSince' => null,
|
||||
'validUntil' => null,
|
||||
'maxVisits' => null,
|
||||
],
|
||||
'originalUrl' =>
|
||||
'https://blog.alejandrocelaya.com/2019/04/27'
|
||||
. '/considerations-to-properly-use-open-source-software-projects/',
|
||||
],
|
||||
[
|
||||
'shortCode' => 'custom-with-domain',
|
||||
'shortUrl' => 'http://some-domain.com/custom-with-domain',
|
||||
'longUrl' => 'https://google.com',
|
||||
'dateCreated' => '2019-01-01T00:00:00+00:00',
|
||||
'visitsCount' => 0,
|
||||
'tags' => [],
|
||||
'meta' => [
|
||||
'validSince' => null,
|
||||
'validUntil' => null,
|
||||
'maxVisits' => null,
|
||||
],
|
||||
'originalUrl' => 'https://google.com',
|
||||
],
|
||||
],
|
||||
'pagination' => [
|
||||
'currentPage' => 1,
|
||||
'pagesCount' => 1,
|
||||
'itemsPerPage' => 10,
|
||||
'itemsInCurrentPage' => 3,
|
||||
'totalItems' => 3,
|
||||
'itemsInCurrentPage' => 5,
|
||||
'totalItems' => 5,
|
||||
],
|
||||
],
|
||||
], $respPayload);
|
||||
|
||||
@@ -34,6 +34,18 @@ class ShortUrlsFixture extends AbstractFixture
|
||||
));
|
||||
$manager->persist($customShortUrl);
|
||||
|
||||
$withDomainShortUrl = $this->setShortUrlDate(new ShortUrl(
|
||||
'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/',
|
||||
ShortUrlMeta::createFromRawData(['domain' => 'example.com'])
|
||||
))->setShortCode('ghi789');
|
||||
$manager->persist($withDomainShortUrl);
|
||||
|
||||
$withDomainAndSlugShortUrl = $this->setShortUrlDate(new ShortUrl(
|
||||
'https://google.com',
|
||||
ShortUrlMeta::createFromRawData(['domain' => 'some-domain.com'])
|
||||
))->setShortCode('custom-with-domain');
|
||||
$manager->persist($withDomainAndSlugShortUrl);
|
||||
|
||||
$manager->flush();
|
||||
|
||||
$this->addReference('abc123_short_url', $abcShortUrl);
|
||||
|
||||
@@ -36,14 +36,14 @@ class CreateShortUrlActionTest extends TestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function missingLongUrlParamReturnsError()
|
||||
public function missingLongUrlParamReturnsError(): void
|
||||
{
|
||||
$response = $this->action->handle(new ServerRequest());
|
||||
$this->assertEquals(400, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function properShortcodeConversionReturnsData()
|
||||
public function properShortcodeConversionReturnsData(): void
|
||||
{
|
||||
$this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array'), Argument::cetera())
|
||||
->willReturn(
|
||||
@@ -60,7 +60,7 @@ class CreateShortUrlActionTest extends TestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function anInvalidUrlReturnsError()
|
||||
public function anInvalidUrlReturnsError(): void
|
||||
{
|
||||
$this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array'), Argument::cetera())
|
||||
->willThrow(InvalidUrlException::class)
|
||||
@@ -75,7 +75,7 @@ class CreateShortUrlActionTest extends TestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function nonUniqueSlugReturnsError()
|
||||
public function nonUniqueSlugReturnsError(): void
|
||||
{
|
||||
$this->urlShortener->urlToShortCode(
|
||||
Argument::type(Uri::class),
|
||||
@@ -94,7 +94,7 @@ class CreateShortUrlActionTest extends TestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function aGenericExceptionWillReturnError()
|
||||
public function aGenericExceptionWillReturnError(): void
|
||||
{
|
||||
$this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array'), Argument::cetera())
|
||||
->willThrow(Exception::class)
|
||||
|
||||
@@ -30,11 +30,11 @@ class ResolveShortUrlActionTest extends TestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function incorrectShortCodeReturnsError()
|
||||
public function incorrectShortCodeReturnsError(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class)
|
||||
->shouldBeCalledOnce();
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(EntityDoesNotExistException::class)
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
$request = (new ServerRequest())->withAttribute('shortCode', $shortCode);
|
||||
$response = $this->action->handle($request);
|
||||
@@ -43,10 +43,10 @@ class ResolveShortUrlActionTest extends TestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function correctShortCodeReturnsSuccess()
|
||||
public function correctShortCodeReturnsSuccess(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn(
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, null)->willReturn(
|
||||
new ShortUrl('http://domain.com/foo/bar')
|
||||
)->shouldBeCalledOnce();
|
||||
|
||||
@@ -57,11 +57,11 @@ class ResolveShortUrlActionTest extends TestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function invalidShortCodeExceptionReturnsError()
|
||||
public function invalidShortCodeExceptionReturnsError(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(InvalidShortCodeException::class)
|
||||
->shouldBeCalledOnce();
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(InvalidShortCodeException::class)
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
$request = (new ServerRequest())->withAttribute('shortCode', $shortCode);
|
||||
$response = $this->action->handle($request);
|
||||
@@ -70,11 +70,11 @@ class ResolveShortUrlActionTest extends TestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function unexpectedExceptionWillReturnError()
|
||||
public function unexpectedExceptionWillReturnError(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(Exception::class)
|
||||
->shouldBeCalledOnce();
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(Exception::class)
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
$request = (new ServerRequest())->withAttribute('shortCode', $shortCode);
|
||||
$response = $this->action->handle($request);
|
||||
|
||||
@@ -4,3 +4,4 @@ parameters:
|
||||
- '#ObjectManager::flush()#'
|
||||
- '#\$metadata ClassMetadata#'
|
||||
- '#Undefined variable: \$metadata#'
|
||||
- '#AbstractQuery::setParameters()#'
|
||||
|
||||
Reference in New Issue
Block a user