Skip to content

Commit

Permalink
Merge pull request #500 from acelaya-forks/feature/multiple-domains
Browse files Browse the repository at this point in the history
Feature/multiple domains
  • Loading branch information
acelaya authored Oct 4, 2019
2 parents a81ac85 + 403773b commit 05e3071
Show file tree
Hide file tree
Showing 42 changed files with 748 additions and 151 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this

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.
Expand Down
File renamed without changes.
54 changes: 54 additions & 0 deletions data/migrations/Version20190930165521.php
Original file line number Diff line number Diff line change
@@ -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 changes: 48 additions & 0 deletions data/migrations/Version20191001201532.php
Original file line number Diff line number Diff line change
@@ -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']);
}
}
4 changes: 4 additions & 0 deletions docs/swagger/paths/v1_short-urls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions docs/swagger/paths/v1_short-urls_{shortCode}.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
19 changes: 11 additions & 8 deletions module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
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;
Expand All @@ -26,8 +25,6 @@

class GenerateShortUrlCommand extends Command
{
use ShortUrlBuilderTrait;

public const NAME = 'short-url:generate';
private const ALIASES = ['shortcode:generate', 'short-code:generate'];

Expand Down Expand Up @@ -87,6 +84,12 @@ protected function configure(): void
'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.'
);
}

Expand Down Expand Up @@ -119,22 +122,22 @@ protected function execute(InputInterface $input, OutputInterface $output): ?int
$maxVisits = $input->getOption('maxVisits');

try {
$shortCode = $this->urlShortener->urlToShortCode(
$shortUrl = $this->urlShortener->urlToShortCode(
new Uri($longUrl),
$tags,
ShortUrlMeta::createFromParams(
$this->getOptionalDate($input, 'validSince'),
$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) {
Expand Down
7 changes: 5 additions & 2 deletions module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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;

Expand All @@ -35,7 +36,8 @@ protected function configure(): void
->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
Expand All @@ -56,9 +58,10 @@ protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$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) {
Expand Down
30 changes: 24 additions & 6 deletions module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
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;
Expand All @@ -35,7 +37,7 @@ public function setUp(): void
}

/** @test */
public function properShortCodeIsCreatedIfLongUrlIsCorrect()
public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void
{
$urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willReturn(
(new ShortUrl(''))->setShortCode('abc123')
Expand All @@ -47,26 +49,41 @@ public function properShortCodeIsCreatedIfLongUrlIsCorrect()
]);
$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 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()
public function properlyProcessesProvidedTags(): void
{
$urlToShortCode = $this->urlShortener->urlToShortCode(
Argument::type(UriInterface::class),
Expand All @@ -83,6 +100,7 @@ public function properlyProcessesProvidedTags()
]);
$output = $this->commandTester->getDisplay();

$this->assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
$this->assertStringContainsString('http://foo.com/abc123', $output);
$urlToShortCode->shouldHaveBeenCalledOnce();
}
Expand Down
18 changes: 9 additions & 9 deletions module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,37 +33,37 @@ public function setUp(): void
}

/** @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();
$this->assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output);
}

/** @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();
$this->assertStringContainsString('Provided short code "' . $shortCode . '" could not be found.', $output);
}

/** @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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Loading

0 comments on commit 05e3071

Please sign in to comment.