mirror of
https://github.com/shlinkio/shlink.git
synced 2024-11-27 16:26:37 +03:00
commit
e60d80bb16
8 changed files with 137 additions and 25 deletions
24
CHANGELOG.md
24
CHANGELOG.md
|
@ -4,6 +4,30 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## 2.0.5 - 2020-02-09
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* [#651](https://github.com/shlinkio/shlink/issues/651) Documented how Shlink behaves when using multiple domains.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#648](https://github.com/shlinkio/shlink/issues/648) Ensured any user can write in log files, in case shlink is run by several system users.
|
||||||
|
* [#650](https://github.com/shlinkio/shlink/issues/650) Ensured default domain is ignored when trying to create a short URL.
|
||||||
|
|
||||||
|
|
||||||
## 2.0.4 - 2020-02-02
|
## 2.0.4 - 2020-02-02
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
|
|
64
README.md
64
README.md
|
@ -22,6 +22,10 @@ A PHP-based self-hosted URL shortener that can be used to serve shortened URLs u
|
||||||
- [Using a docker image](#using-a-docker-image)
|
- [Using a docker image](#using-a-docker-image)
|
||||||
- [Using shlink](#using-shlink)
|
- [Using shlink](#using-shlink)
|
||||||
- [Shlink CLI Help](#shlink-cli-help)
|
- [Shlink CLI Help](#shlink-cli-help)
|
||||||
|
- [Multiple domains](#multiple-domains)
|
||||||
|
- [Management](#management)
|
||||||
|
- [Visits](#visits)
|
||||||
|
- [Special redirects](#special-redirects)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
@ -280,4 +284,64 @@ Available commands:
|
||||||
visit:locate Resolves visits origin locations.
|
visit:locate Resolves visits origin locations.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Multiple domains
|
||||||
|
|
||||||
|
While in many cases you will just have one short domain and you'll want all your short URLs to be served from it, there are some cases in which you might want to have multiple short domains served from the same Shlink instance.
|
||||||
|
|
||||||
|
If that's the case, you need to understand how Shlink will behave when managing your short URLs or any of them is visited.
|
||||||
|
|
||||||
|
### Management
|
||||||
|
|
||||||
|
When you create a short URL it is possible to optionally pass a `domain` param. If you don't pass it, the short URL will be created for the default domain (the one provided during Shlink's installation or in the `SHORT_DOMAIN_HOST` env var when using the docker image).
|
||||||
|
|
||||||
|
However, if you pass it, the short URL will be "linked" to that domain.
|
||||||
|
|
||||||
|
> Note that, if the default domain is passed, Shlink will ignore it and will behave as if no `domain` param was provided.
|
||||||
|
|
||||||
|
The main benefit of being able to pass the domain is that Shlink will allow the same custom slug to be used in multiple short URLs, as long as the domain is different (like `example.com/my-compaign`, `another.com/my-compaign` and `foo.com/my-compaign`).
|
||||||
|
|
||||||
|
Then, each short URL will be tracked separately and you will be able to define specific tags and metadata for each one of them.
|
||||||
|
|
||||||
|
However, this has a side effect. When you try to interact with an existing short URL (editing tags, editing meta, resolving it or deleting it), either from the REST API or the CLI tool, you will have to provide the domain appropriately.
|
||||||
|
|
||||||
|
Let's imagine this situation. Shlink's default domain is `example.com`, and you have the next short URLs:
|
||||||
|
|
||||||
|
* `https://example.com/abc123` -> a regular short URL where no domain was provided.
|
||||||
|
* `https://example.com/my-campaign` -> a regular short URL where no domain was provided, but it has a custom slug.
|
||||||
|
* `https://another.com/my-campaign` -> a short URL where the `another.com` domain was provided, and it has a custom slug.
|
||||||
|
* `https://another.com/def456` -> a short URL where the `another.com` domain was provided.
|
||||||
|
|
||||||
|
These are some of the results you will get when trying to interact with them, depending on the params you provide:
|
||||||
|
|
||||||
|
* Providing just the `abc123` short code -> the first URL will be matched.
|
||||||
|
* Providing just the `my-campaign` short code -> the second URL will be matched, since you did not specify a domain, therefor, Shlink looks for the one with the short code/slug `my-campaign` which is also linked to default domain (or not linked to any domain, to be more accurate).
|
||||||
|
* Providing the `my-campaign` short code and the `another.com` domain -> The third one will be matched.
|
||||||
|
* Providing just the `def456` short code -> Shlink will fail/not find any short URL, since there's none with the short code `def456` linked to default domain.
|
||||||
|
* Providing the `def456` short code and the `another.com` domain -> The fourth short URL will be matched.
|
||||||
|
* Providing any short code and the `foo.com` domain -> Again, no short URL will be found, as there's none linked to `foo.com` domain.
|
||||||
|
|
||||||
|
### Visits
|
||||||
|
|
||||||
|
Before adding support for multiple domains, you could point as many domains as you wanted to Shlink, and they would have always worked for existing short codes/slugs.
|
||||||
|
|
||||||
|
In order to keep backwards compatibility, Shlink's behavior when a short URL is visited is slightly different, getting to fallback in some cases.
|
||||||
|
|
||||||
|
Let's continue with previous example, and also consider we have three domains that will resolve to our Shlink instance, which are `example.com`, `another.com` and `foo.com`.
|
||||||
|
|
||||||
|
With that in mind, this is how Shlink will behave when the next short URLs are visited:
|
||||||
|
|
||||||
|
* `https://another.com/abc123` -> There was no short URL specifically defined for domain `another.com` and short code `abc123`, but it exists for default domain (`example.com`), so it will fall back to it and redirect to where `example.com/abc123` is configured to redirect.
|
||||||
|
* `https://example.com/def456` -> The fall-back does not happen from default domain to specific ones, only the other way around (like in previous case). Because of that, this one will result in a not-found URL, even though the `def456` short code exists for `another.com` domain.
|
||||||
|
* `https://foo.com/abc123` -> This will also fall-back to `example.com/abc123`, like in the first case.
|
||||||
|
* `https://another.com/non-existing` -> The combination of `another.com` domain with the `non-existing` slug does not exist, so Shlink will try to fall-back to the same but for default domain (`example.com`). However, since that combination does not exist either, it will result in a not-found URL.
|
||||||
|
* Any other short URL visited exactly as it was configured will, of course, resolve as expected.
|
||||||
|
|
||||||
|
### Special redirects
|
||||||
|
|
||||||
|
It is currently possible to configure some special redirects when the base domain is visited, a URL does not match, or an invalid/disabled short URL is visited.
|
||||||
|
|
||||||
|
Those are configured during Shlink's installation or via env vars when using the docker image.
|
||||||
|
|
||||||
|
Currently those are all shared for all domains serving the same Shlink instance, but the plan is to update that and allow specific ones for every existing domain.
|
||||||
|
|
||||||
> This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com)
|
> This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com)
|
||||||
|
|
|
@ -41,6 +41,7 @@ return [
|
||||||
'level' => Logger::INFO,
|
'level' => Logger::INFO,
|
||||||
'filename' => 'data/log/shlink_log.log',
|
'filename' => 'data/log/shlink_log.log',
|
||||||
'max_files' => 30,
|
'max_files' => 30,
|
||||||
|
'file_permission' => 0666,
|
||||||
],
|
],
|
||||||
'formatter' => $formatter,
|
'formatter' => $formatter,
|
||||||
],
|
],
|
||||||
|
|
|
@ -37,7 +37,7 @@ return [
|
||||||
Middleware\BodyParserMiddleware::class => InvokableFactory::class,
|
Middleware\BodyParserMiddleware::class => InvokableFactory::class,
|
||||||
Middleware\CrossDomainMiddleware::class => InvokableFactory::class,
|
Middleware\CrossDomainMiddleware::class => InvokableFactory::class,
|
||||||
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class => InvokableFactory::class,
|
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class => InvokableFactory::class,
|
||||||
Middleware\ShortUrl\DropDefaultDomainFromQueryMiddleware::class => ConfigAbstractFactory::class,
|
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ConfigAbstractFactory::class,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
@ -74,7 +74,7 @@ return [
|
||||||
Action\Tag\CreateTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
|
Action\Tag\CreateTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
|
||||||
Action\Tag\UpdateTagAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
|
Action\Tag\UpdateTagAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
|
||||||
|
|
||||||
Middleware\ShortUrl\DropDefaultDomainFromQueryMiddleware::class => ['config.url_shortener.domain.hostname'],
|
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'],
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Rest;
|
namespace Shlinkio\Shlink\Rest;
|
||||||
|
|
||||||
$contentNegotiationMiddleware = [Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class];
|
$contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class;
|
||||||
$dropDomainMiddleware = [Middleware\ShortUrl\DropDefaultDomainFromQueryMiddleware::class];
|
$dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
|
@ -13,16 +13,16 @@ return [
|
||||||
Action\HealthAction::getRouteDef(),
|
Action\HealthAction::getRouteDef(),
|
||||||
|
|
||||||
// Short codes
|
// Short codes
|
||||||
Action\ShortUrl\CreateShortUrlAction::getRouteDef($contentNegotiationMiddleware),
|
Action\ShortUrl\CreateShortUrlAction::getRouteDef([$contentNegotiationMiddleware, $dropDomainMiddleware]),
|
||||||
Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef($contentNegotiationMiddleware),
|
Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([$contentNegotiationMiddleware]),
|
||||||
Action\ShortUrl\EditShortUrlAction::getRouteDef($dropDomainMiddleware),
|
Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]),
|
||||||
Action\ShortUrl\DeleteShortUrlAction::getRouteDef($dropDomainMiddleware),
|
Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]),
|
||||||
Action\ShortUrl\ResolveShortUrlAction::getRouteDef($dropDomainMiddleware),
|
Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]),
|
||||||
Action\ShortUrl\ListShortUrlsAction::getRouteDef(),
|
Action\ShortUrl\ListShortUrlsAction::getRouteDef(),
|
||||||
Action\ShortUrl\EditShortUrlTagsAction::getRouteDef($dropDomainMiddleware),
|
Action\ShortUrl\EditShortUrlTagsAction::getRouteDef([$dropDomainMiddleware]),
|
||||||
|
|
||||||
// Visits
|
// Visits
|
||||||
Action\Visit\GetVisitsAction::getRouteDef($dropDomainMiddleware),
|
Action\Visit\GetVisitsAction::getRouteDef([$dropDomainMiddleware]),
|
||||||
|
|
||||||
// Tags
|
// Tags
|
||||||
Action\Tag\ListTagsAction::getRouteDef(),
|
Action\Tag\ListTagsAction::getRouteDef(),
|
||||||
|
|
|
@ -9,7 +9,7 @@ use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Psr\Http\Server\MiddlewareInterface;
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
use Psr\Http\Server\RequestHandlerInterface;
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
|
||||||
class DropDefaultDomainFromQueryMiddleware implements MiddlewareInterface
|
class DropDefaultDomainFromRequestMiddleware implements MiddlewareInterface
|
||||||
{
|
{
|
||||||
private string $defaultDomain;
|
private string $defaultDomain;
|
||||||
|
|
||||||
|
@ -20,12 +20,18 @@ class DropDefaultDomainFromQueryMiddleware implements MiddlewareInterface
|
||||||
|
|
||||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||||
{
|
{
|
||||||
$query = $request->getQueryParams();
|
$request = $request->withQueryParams($this->sanitizeDomainFromPayload($request->getQueryParams()))
|
||||||
if (isset($query['domain']) && $query['domain'] === $this->defaultDomain) {
|
->withParsedBody($this->sanitizeDomainFromPayload($request->getParsedBody()));
|
||||||
unset($query['domain']);
|
|
||||||
$request = $request->withQueryParams($query);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $handler->handle($request);
|
return $handler->handle($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function sanitizeDomainFromPayload(array $payload): array
|
||||||
|
{
|
||||||
|
if (isset($payload['domain']) && $payload['domain'] === $this->defaultDomain) {
|
||||||
|
unset($payload['domain']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -228,6 +228,22 @@ class CreateShortUrlActionTest extends ApiTestCase
|
||||||
$this->assertEquals($url, $payload['url']);
|
$this->assertEquals($url, $payload['url']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function defaultDomainIsDroppedIfProvided(): void
|
||||||
|
{
|
||||||
|
[$createStatusCode, ['shortCode' => $shortCode]] = $this->createShortUrl([
|
||||||
|
'longUrl' => 'https://www.alejandrocelaya.com',
|
||||||
|
'domain' => 'doma.in',
|
||||||
|
]);
|
||||||
|
$getResp = $this->callApiWithKey(self::METHOD_GET, '/short-urls/' . $shortCode);
|
||||||
|
$payload = $this->getJsonResponsePayload($getResp);
|
||||||
|
|
||||||
|
$this->assertEquals(self::STATUS_OK, $createStatusCode);
|
||||||
|
$this->assertEquals(self::STATUS_OK, $getResp->getStatusCode());
|
||||||
|
$this->assertArrayHasKey('domain', $payload);
|
||||||
|
$this->assertNull($payload['domain']);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array {
|
* @return array {
|
||||||
* @var int $statusCode
|
* @var int $statusCode
|
||||||
|
|
|
@ -12,29 +12,30 @@ use Prophecy\Argument;
|
||||||
use Prophecy\Prophecy\ObjectProphecy;
|
use Prophecy\Prophecy\ObjectProphecy;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Psr\Http\Server\RequestHandlerInterface;
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
use Shlinkio\Shlink\Rest\Middleware\ShortUrl\DropDefaultDomainFromQueryMiddleware;
|
use Shlinkio\Shlink\Rest\Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware;
|
||||||
|
|
||||||
class DropDefaultDomainFromQueryMiddlewareTest extends TestCase
|
class DropDefaultDomainFromRequestMiddlewareTest extends TestCase
|
||||||
{
|
{
|
||||||
private DropDefaultDomainFromQueryMiddleware $middleware;
|
private DropDefaultDomainFromRequestMiddleware $middleware;
|
||||||
private ObjectProphecy $next;
|
private ObjectProphecy $next;
|
||||||
|
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
$this->next = $this->prophesize(RequestHandlerInterface::class);
|
$this->next = $this->prophesize(RequestHandlerInterface::class);
|
||||||
$this->middleware = new DropDefaultDomainFromQueryMiddleware('doma.in');
|
$this->middleware = new DropDefaultDomainFromRequestMiddleware('doma.in');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @test
|
* @test
|
||||||
* @dataProvider provideQueryParams
|
* @dataProvider provideQueryParams
|
||||||
*/
|
*/
|
||||||
public function domainIsDroppedWhenDefaultOneIsProvided(array $providedQuery, array $expectedQuery): void
|
public function domainIsDroppedWhenDefaultOneIsProvided(array $providedPayload, array $expectedPayload): void
|
||||||
{
|
{
|
||||||
$req = ServerRequestFactory::fromGlobals()->withQueryParams($providedQuery);
|
$req = ServerRequestFactory::fromGlobals()->withQueryParams($providedPayload)->withParsedBody($providedPayload);
|
||||||
|
|
||||||
$handle = $this->next->handle(Argument::that(function (ServerRequestInterface $request) use ($expectedQuery) {
|
$handle = $this->next->handle(Argument::that(function (ServerRequestInterface $request) use ($expectedPayload) {
|
||||||
Assert::assertEquals($expectedQuery, $request->getQueryParams());
|
Assert::assertEquals($expectedPayload, $request->getQueryParams());
|
||||||
|
Assert::assertEquals($expectedPayload, $request->getParsedBody());
|
||||||
return $request;
|
return $request;
|
||||||
}))->willReturn(new Response());
|
}))->willReturn(new Response());
|
||||||
|
|
Loading…
Reference in a new issue