Compare commits

..

45 Commits

Author SHA1 Message Date
Alejandro Celaya
09a5284675 Merge pull request #501 from acelaya-forks/feature/v1.19-final
Updated changelog adding final release 1.19.0
2019-10-05 11:09:12 +02:00
Alejandro Celaya
1112f3acdd Updated changelog adding final release 1.19.0 2019-10-05 11:08:50 +02:00
Alejandro Celaya
05e3071db2 Merge pull request #500 from acelaya-forks/feature/multiple-domains
Feature/multiple domains
2019-10-04 23:39:11 +02:00
Alejandro Celaya
403773bc17 Documented new feature in CHANGELOG 2019-10-04 21:46:41 +02:00
Alejandro Celaya
636df2a736 Read request's authority when tracking a visit and passed it down 2019-10-04 21:36:54 +02:00
Alejandro Celaya
baf3093893 Added support for domain param to command and action to resolve a short URL 2019-10-04 21:17:02 +02:00
Alejandro Celaya
8d3a49a319 Fixed issue with postgres when fetching resultset ordering by nullable column 2019-10-04 18:07:26 +02:00
Alejandro Celaya
eced1af21d Added more database cases covering different combinations of finding short URL by short code and domain 2019-10-04 17:34:34 +02:00
Alejandro Celaya
49c3c9bec1 Ensured domain is taken into account when looking for a short URL 2019-10-04 17:21:22 +02:00
Alejandro Celaya
2ffaabe594 Added option to define domain to GenerateShortUrlCommand 2019-10-02 20:29:13 +02:00
Alejandro Celaya
f31dc6c6e5 Added missing return type hints 2019-10-02 20:15:14 +02:00
Alejandro Celaya
f067d0e831 Allowed to provide the domain when creating a short URL 2019-10-02 20:13:25 +02:00
Alejandro Celaya
a892f72425 Added migration to make the combination of slug+domain unique 2019-10-02 20:01:15 +02:00
Alejandro Celaya
25f64a2fc4 Added check for domain when matching an existing short URL 2019-10-01 22:15:11 +02:00
Alejandro Celaya
fd1fe90731 Created tests for new domain resolvers 2019-10-01 22:00:46 +02:00
Alejandro Celaya
495643f4f1 Ensured domain is taken into account when checking if a slug is in use 2019-10-01 21:42:35 +02:00
Alejandro Celaya
8da6b336f5 Added API test which checks short URLs with a domain are parsed as such 2019-10-01 20:24:11 +02:00
Alejandro Celaya
d0bb86ca8f Added simple way to resolve domains from entity manager when creating a short URL 2019-10-01 20:16:27 +02:00
Alejandro Celaya
1085809fa5 Moved code to convert a ShortUrl into a full link as string to the entity itself 2019-09-30 20:01:36 +02:00
Alejandro Celaya
7b1857dcda Added entities config for domains 2019-09-30 19:42:27 +02:00
Alejandro Celaya
6f38790d47 Created migration which adds domains table 2019-09-30 19:15:14 +02:00
Alejandro Celaya
a81ac85af6 Merge pull request #498 from acelaya-forks/feature/improved-issue-templates
Feature/improved issue templates
2019-09-29 09:38:53 +02:00
Alejandro Celaya
8f4d5b6fce Added funding config file 2019-09-29 09:30:18 +02:00
Alejandro Celaya
8468a48eaa Added generic issue template 2019-09-29 09:23:37 +02:00
Alejandro Celaya
fc0885e5d5 Removed shlink set-up info from feature request issue template 2019-09-29 09:21:56 +02:00
Alejandro Celaya
e1a9e347c3 Merge pull request #496 from acelaya-forks/feature/installer2
Feature/installer2
2019-09-28 09:45:09 +02:00
Alejandro Celaya
1b0e3b686d Ignored false positive in phpstan 2019-09-28 09:35:59 +02:00
Alejandro Celaya
a09208582e Updated changelog 2019-09-28 09:31:41 +02:00
Alejandro Celaya
df1de020d1 Updated to shlink installer 2 2019-09-28 09:30:20 +02:00
Alejandro Celaya
9b363368a2 Merge pull request #490 from acelaya-forks/feature/issue-template
Feature/issue template
2019-09-17 20:17:51 +02:00
Alejandro Celaya
9fac69675a Updated how shlink info is requested to users on issue templates 2019-09-17 20:10:27 +02:00
Alejandro Celaya
1d2cfde7f7 Created individual issue templates 2019-09-17 20:04:04 +02:00
Alejandro Celaya
452612ee00 Merge pull request #485 from acelaya-forks/feature/base-path
Feature/base path
2019-09-13 20:54:45 +02:00
Alejandro Celaya
8d74e0c3ff Fixed undefined-index errors in BasePathPrefixerTest 2019-09-13 20:46:49 +02:00
Alejandro Celaya
0a1786c89a Added support for basepath on docker image 2019-09-13 20:36:40 +02:00
Alejandro Celaya
bc07d77d06 Removed duplicated code from BasePathPrefixer 2019-09-13 20:22:41 +02:00
Alejandro Celaya
6e38457655 Created BasePathPrefixerTest 2019-09-13 20:17:30 +02:00
Alejandro Celaya
d7a3aeb0a2 Created a config prost-processor which adds the base path on every applicable configuration 2019-09-13 20:03:53 +02:00
Alejandro Celaya
76541d5563 Merge pull request #484 from acelaya-forks/feature/disable-asking-for-lang
Ensured installer does not ask for the locale
2019-09-13 19:02:34 +02:00
Alejandro Celaya
28b5d8445e Ensured installer does not ask for the locale 2019-09-13 18:51:51 +02:00
Alejandro Celaya
d17533fd0f Merge pull request #483 from acelaya-forks/feature/remoive-translations
Feature/remoive translations
2019-09-12 20:30:45 +02:00
Alejandro Celaya
01d62b7aea Removed escape characters no longer needed in templates 2019-09-12 19:45:24 +02:00
Alejandro Celaya
bd97804ca6 Updated changelog 2019-09-12 08:10:41 +02:00
Alejandro Celaya
7b0ccc9f69 Removed references to anything related with translations 2019-09-12 08:09:17 +02:00
Alejandro Celaya
fdb98fa2a9 Added service aliases that were removed from shlink-common 2019-09-11 20:25:04 +02:00
71 changed files with 1033 additions and 271 deletions

View File

@@ -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
View File

@@ -0,0 +1 @@
custom: ['https://acel.me/donate']

View File

@@ -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
View 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. -->

View 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. -->

View 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. -->

View File

@@ -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

View File

@@ -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",

View File

@@ -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 => [

View File

@@ -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' => [

View File

@@ -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,

View File

@@ -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',

View File

@@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Common;
return [
'translator' => [
'locale' => Common\env('DEFAULT_LOCALE', 'en'),
],
];

View File

@@ -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();

View 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');
}
}

View 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']);
}
}

View File

@@ -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,

View File

@@ -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', ''),
],
];

View File

@@ -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"
}
}
}

View File

@@ -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": [

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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();
}

View File

@@ -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();

View File

@@ -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(),
]]);

View File

@@ -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();

View File

@@ -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');

View File

@@ -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.

View File

@@ -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"

View File

@@ -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)) {

View 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;
});
}
}

View File

@@ -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' => [

View 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;
}

View File

@@ -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);
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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'] ?? '');
}
}

View File

@@ -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) {

View File

@@ -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);
}
}

View File

@@ -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() ?>

View File

@@ -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() ?>

View File

@@ -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() ?>

View File

@@ -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">

View File

@@ -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'));
}
}

View File

@@ -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();

View File

@@ -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)

View 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',
];
}
}

View File

@@ -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));

View File

@@ -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);
}
}

View File

@@ -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];
}
}

View File

@@ -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'];
}
}

View 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',
];
}
}

View File

@@ -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);

View File

@@ -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
)
);
}

View File

@@ -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]);

View File

@@ -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
{

View File

@@ -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);

View File

@@ -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);

View File

@@ -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)

View File

@@ -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);

View File

@@ -4,3 +4,4 @@ parameters:
- '#ObjectManager::flush()#'
- '#\$metadata ClassMetadata#'
- '#Undefined variable: \$metadata#'
- '#AbstractQuery::setParameters()#'