Merge pull request #958 from acelaya-forks/feature/api-key-permissions

Feature/api key permissions
This commit is contained in:
Alejandro Celaya 2021-01-10 11:25:29 +01:00 committed by GitHub
commit 95e51665b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
143 changed files with 2520 additions and 626 deletions

View file

@ -17,7 +17,7 @@ indocker
docker-*
phpstan.neon
php*xml*
infection.json
infection*
**/test*
build*
**/.*

View file

@ -6,11 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
## [Unreleased]
### Added
* [#869](https://github.com/shlinkio/shlink/issues/869) Added support for Mercure Hub 0.10.
* [#795](https://github.com/shlinkio/shlink/issues/795) and [#882](https://github.com/shlinkio/shlink/issues/882) Added new roles system to API keys.
API keys can have any combinations of these two roles now, allowing to limit their interactions:
* Can interact only with short URLs created with that API key.
* Can interact only with short URLs for a specific domain.
* [#833](https://github.com/shlinkio/shlink/issues/833) Added support to connect through unix socket when using an external MySQL, MariaDB or Postgres database.
It can be provided during the installation, or as the `DB_UNIX_SOCKET` env var for the docker image.
* [#869](https://github.com/shlinkio/shlink/issues/869) Added support for Mercure Hub 0.10.
* [#896](https://github.com/shlinkio/shlink/issues/896) Added support for unicode characters in custom slugs.
* [#930](https://github.com/shlinkio/shlink/issues/930) Added new `bin/set-option` script that allows changing individual configuration options on existing shlink instances.
* [#877](https://github.com/shlinkio/shlink/issues/877) Improved API tests on CORS, and "refined" middleware handling it.

View file

@ -19,12 +19,12 @@
"cakephp/chronos": "^2.0",
"cocur/slugify": "^4.0",
"doctrine/cache": "^1.9",
"doctrine/dbal": "^2.10",
"doctrine/migrations": "^3.0.2",
"doctrine/orm": "^2.8",
"endroid/qr-code": "^3.6",
"geoip2/geoip2": "^2.9",
"guzzlehttp/guzzle": "^7.0",
"happyr/doctrine-specification": "2.0.x-dev#cb116d3 as 2.0",
"laminas/laminas-config": "^3.3",
"laminas/laminas-config-aggregator": "^1.1",
"laminas/laminas-diactoros": "^2.1.3",
@ -47,7 +47,7 @@
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.7",
"ramsey/uuid": "^3.9",
"shlinkio/shlink-common": "dev-main#2963395 as 3.4",
"shlinkio/shlink-common": "dev-main#1311861 as 3.4",
"shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^1.6",
"shlinkio/shlink-importer": "^2.1",
@ -71,7 +71,7 @@
"phpunit/phpunit": "^9.5",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.1.1",
"shlinkio/shlink-test-utils": "^1.6",
"shlinkio/shlink-test-utils": "^1.7",
"symfony/var-dumper": "^5.2",
"veewee/composer-run-parallel": "^0.1.0"
},
@ -125,13 +125,7 @@
],
"test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
"test:unit:ci": "@test:unit --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml",
"test:db": [
"@test:db:sqlite:ci",
"@test:db:mysql",
"@test:db:maria",
"@test:db:postgres",
"@test:db:ms"
],
"test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
"test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
"test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml",
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
@ -140,17 +134,12 @@
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
"test:api": "bin/test/run-api-tests.sh",
"test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html",
"infect": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered",
"infect:ci:base": "@infect --skip-initial-tests",
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit",
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --test-framework-options=--configuration=phpunit-db.xml",
"infect:ci": [
"@infect:ci:unit",
"@infect:ci:db"
],
"infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --skip-initial-tests",
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80",
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json",
"infect:ci": "@parallel infect:ci:unit infect:ci:db",
"infect:test": [
"@test:unit:ci",
"@test:db:sqlite:ci",
"@parallel test:unit:ci test:db:sqlite:ci",
"@infect:ci"
],
"clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php"

View file

@ -4,12 +4,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common;
use Happyr\DoctrineSpecification\EntitySpecificationRepository;
return [
'entity_manager' => [
'orm' => [
'proxies_dir' => 'data/proxies',
'load_mappings_using_functional_style' => true,
'default_repository_classname' => EntitySpecificationRepository::class,
],
'connection' => [
'user' => '',

View file

@ -58,7 +58,7 @@ final class Version20180913205455 extends AbstractMigration
}
try {
return (string) IpAddress::fromString($addr)->getObfuscatedCopy();
return (string) IpAddress::fromString($addr)->getAnonymizedCopy();
} catch (InvalidArgumentException $e) {
return null;
}

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20210102174433 extends AbstractMigration
{
private const TABLE_NAME = 'api_key_roles';
public function up(Schema $schema): void
{
$this->skipIf($schema->hasTable(self::TABLE_NAME));
$table = $schema->createTable(self::TABLE_NAME);
$table->addColumn('id', Types::BIGINT, [
'unsigned' => true,
'autoincrement' => true,
'notnull' => true,
]);
$table->setPrimaryKey(['id']);
$table->addColumn('role_name', Types::STRING, [
'length' => 256,
'notnull' => true,
]);
$table->addColumn('meta', Types::JSON, [
'notnull' => true,
]);
$table->addColumn('api_key_id', Types::BIGINT, [
'unsigned' => true,
'notnull' => true,
]);
$table->addForeignKeyConstraint('api_keys', ['api_key_id'], ['id'], [
'onDelete' => 'CASCADE',
'onUpdate' => 'RESTRICT',
]);
$table->addUniqueIndex(['role_name', 'api_key_id'], 'UQ_role_plus_api_key');
}
public function down(Schema $schema): void
{
$this->skipIf(! $schema->hasTable(self::TABLE_NAME));
$schema->getTable(self::TABLE_NAME)->dropIndex('UQ_role_plus_api_key');
$schema->dropTable(self::TABLE_NAME);
}
}

View file

@ -191,7 +191,7 @@
"Short URLs"
],
"summary": "Create short URL",
"description": "Creates a new short URL.<br></br>**Param findIfExists:**: Starting with v1.16, this new param allows to force shlink to return existing short URLs when found based on provided params, instead of creating a new one. However, it might add complexity and have unexpected outputs.\n\nThese are the use cases:\n* Only the long URL is provided: It will return the newest match or create a new short URL if none is found.\n* Long url and custom slug are provided: It will return the short URL when both params match, return an error when the slug is in use for another long URL, or create a new short URL otherwise.\n* Any of the above but including other params (tags, validSince, validUntil, maxVisits): It will behave the same as the previous two cases, but it will try to exactly match existing results using all the params. If any of them does not match, it will try to create a new short URL.",
"description": "Creates a new short URL.<br></br>**Param findIfExists**: This new param allows to force shlink to return existing short URLs when found based on provided params, instead of creating a new one. However, it might add complexity and have unexpected outputs.\n\nThese are the use cases:\n* Only the long URL is provided: It will return the newest match or create a new short URL if none is found.\n* Long url and custom slug are provided: It will return the short URL when both params match, return an error when the slug is in use for another long URL, or create a new short URL otherwise.\n* Any of the above but including other params (tags, validSince, validUntil, maxVisits): It will behave the same as the previous two cases, but it will try to exactly match existing results using all the params. If any of them does not match, it will try to create a new short URL.",
"security": [
{
"ApiKey": []

View file

@ -232,6 +232,16 @@
}
}
},
"403": {
"description": "The API key you used does not have permissions to rename tags.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"404": {
"description": "There's no tag found with the name provided in oldName param.",
"content": {
@ -298,6 +308,16 @@
"204": {
"description": "Tags properly deleted"
},
"403": {
"description": "The API key you used does not have permissions to delete tags.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {

23
infection-db.json Normal file
View file

@ -0,0 +1,23 @@
{
"source": {
"directories": [
"module/*/src"
]
},
"timeout": 5,
"logs": {
"text": "build/infection-db/infection-log.txt",
"summary": "build/infection-db/summary-log.txt",
"debug": "build/infection-db/debug-log.txt"
},
"tmpDir": "build/infection-db/temp",
"phpUnit": {
"configDir": "."
},
"testFrameworkOptions": "--configuration=phpunit-db.xml",
"mutators": {
"@default": true,
"IdenticalEqual": false,
"NotIdenticalNotEqual": false
}
}

View file

@ -6,11 +6,11 @@
},
"timeout": 5,
"logs": {
"text": "build/infection/infection-log.txt",
"summary": "build/infection/summary-log.txt",
"debug": "build/infection/debug-log.txt"
"text": "build/infection-unit/infection-log.txt",
"summary": "build/infection-unit/summary-log.txt",
"debug": "build/infection-unit/debug-log.txt"
},
"tmpDir": "build/infection/temp",
"tmpDir": "build/infection-unit/temp",
"phpUnit": {
"configDir": "."
},

View file

@ -87,7 +87,7 @@ return [
Command\Tag\RenameTagCommand::class => [TagService::class],
Command\Tag\DeleteTagsCommand::class => [TagService::class],
Command\Domain\ListDomainsCommand::class => [DomainService::class, 'config.url_shortener.domain.hostname'],
Command\Domain\ListDomainsCommand::class => [DomainService::class],
Command\Db\CreateDatabaseCommand::class => [
LockFactory::class,

View file

@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@ -19,13 +19,11 @@ class ListDomainsCommand extends Command
public const NAME = 'domain:list';
private DomainServiceInterface $domainService;
private string $defaultDomain;
public function __construct(DomainServiceInterface $domainService, string $defaultDomain)
public function __construct(DomainServiceInterface $domainService)
{
parent::__construct();
$this->domainService = $domainService;
$this->defaultDomain = $defaultDomain;
}
protected function configure(): void
@ -37,12 +35,12 @@ class ListDomainsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$regularDomains = $this->domainService->listDomainsWithout($this->defaultDomain);
$domains = $this->domainService->listDomains();
ShlinkTable::fromOutput($output)->render(['Domain', 'Is default'], [
[$this->defaultDomain, 'Yes'],
...map($regularDomains, fn (Domain $domain) => [$domain->getAuthority(), 'No']),
]);
ShlinkTable::fromOutput($output)->render(
['Domain', 'Is default'],
map($domains, fn (DomainItem $domain) => [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No']),
);
return ExitCodes::EXIT_SUCCESS;
}

View file

@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@ -42,7 +43,7 @@ class RenameTagCommand extends Command
$newName = $input->getArgument('newName');
try {
$this->tagService->renameTag($oldName, $newName);
$this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName));
$io->success('Tag properly renamed.');
return ExitCodes::EXIT_SUCCESS;
} catch (TagNotFoundException | TagConflictException $e) {

View file

@ -10,7 +10,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
@ -25,7 +25,7 @@ class ListDomainsCommandTest extends TestCase
{
$this->domainService = $this->prophesize(DomainServiceInterface::class);
$command = new ListDomainsCommand($this->domainService->reveal(), 'foo.com');
$command = new ListDomainsCommand($this->domainService->reveal());
$app = new Application();
$app->add($command);
@ -45,9 +45,10 @@ class ListDomainsCommandTest extends TestCase
+---------+------------+
OUTPUT;
$listDomains = $this->domainService->listDomainsWithout('foo.com')->willReturn([
new Domain('bar.com'),
new Domain('baz.com'),
$listDomains = $this->domainService->listDomains()->willReturn([
new DomainItem('foo.com', true),
new DomainItem('bar.com', false),
new DomainItem('baz.com', false),
]);
$this->commandTester->execute([]);

View file

@ -10,6 +10,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
@ -37,7 +38,9 @@ class RenameTagCommandTest extends TestCase
{
$oldName = 'foo';
$newName = 'bar';
$renameTag = $this->tagService->renameTag($oldName, $newName)->willThrow(TagNotFoundException::fromTag('foo'));
$renameTag = $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName))->willThrow(
TagNotFoundException::fromTag('foo'),
);
$this->commandTester->execute([
'oldName' => $oldName,
@ -54,7 +57,9 @@ class RenameTagCommandTest extends TestCase
{
$oldName = 'foo';
$newName = 'bar';
$renameTag = $this->tagService->renameTag($oldName, $newName)->willReturn(new Tag($newName));
$renameTag = $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName))->willReturn(
new Tag($newName),
);
$this->commandTester->execute([
'oldName' => $oldName,

View file

@ -88,7 +88,7 @@ return [
],
Service\ShortUrl\ShortUrlResolver::class => ['em'],
Service\ShortUrl\ShortCodeHelper::class => ['em'],
Domain\DomainService::class => ['em'],
Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'],
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
Util\DoctrineBatchHelper::class => ['em'],

View file

@ -5,25 +5,54 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function Functional\map;
class DomainService implements DomainServiceInterface
{
private EntityManagerInterface $em;
private string $defaultDomain;
public function __construct(EntityManagerInterface $em)
public function __construct(EntityManagerInterface $em, string $defaultDomain)
{
$this->em = $em;
$this->defaultDomain = $defaultDomain;
}
/**
* @return Domain[]
* @return DomainItem[]
*/
public function listDomainsWithout(?string $excludeDomain = null): array
public function listDomains(?ApiKey $apiKey = null): array
{
/** @var DomainRepositoryInterface $repo */
$repo = $this->em->getRepository(Domain::class);
return $repo->findDomainsWithout($excludeDomain);
$domains = $repo->findDomainsWithout($this->defaultDomain, $apiKey);
$mappedDomains = map($domains, fn (Domain $domain) => new DomainItem($domain->getAuthority(), false));
if ($apiKey !== null && $apiKey->hasRole(Role::DOMAIN_SPECIFIC)) {
return $mappedDomains;
}
return [
new DomainItem($this->defaultDomain, true),
...$mappedDomains,
];
}
public function getDomain(string $domainId): Domain
{
/** @var Domain|null $domain */
$domain = $this->em->find(Domain::class, $domainId);
if ($domain === null) {
throw DomainNotFoundException::fromId($domainId);
}
return $domain;
}
}

View file

@ -4,12 +4,16 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface DomainServiceInterface
{
/**
* @return Domain[]
* @return DomainItem[]
*/
public function listDomainsWithout(?string $excludeDomain = null): array;
public function listDomains(?ApiKey $apiKey = null): array;
public function getDomain(string $domainId): Domain;
}

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain\Model;
use JsonSerializable;
final class DomainItem implements JsonSerializable
{
private string $domain;
private bool $isDefault;
public function __construct(string $domain, bool $isDefault)
{
$this->domain = $domain;
$this->isDefault = $isDefault;
}
public function jsonSerialize(): array
{
return [
'domain' => $this->domain,
'isDefault' => $this->isDefault,
];
}
public function toString(): string
{
return $this->domain;
}
public function isDefault(): bool
{
return $this->isDefault;
}
}

View file

@ -4,17 +4,18 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain\Repository;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\Expr\Join;
use Happyr\DoctrineSpecification\EntitySpecificationRepository;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class DomainRepository extends EntityRepository implements DomainRepositoryInterface
class DomainRepository extends EntitySpecificationRepository implements DomainRepositoryInterface
{
/**
* @return Domain[]
*/
public function findDomainsWithout(?string $excludedAuthority = null): array
public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array
{
$qb = $this->createQueryBuilder('d');
$qb->join(ShortUrl::class, 's', Join::WITH, 's.domain = d')
@ -25,6 +26,10 @@ class DomainRepository extends EntityRepository implements DomainRepositoryInter
->setParameter('excludedAuthority', $excludedAuthority);
}
if ($apiKey !== null) {
$this->applySpecification($qb, $apiKey->spec(), 's');
}
return $qb->getQuery()->getResult();
}
}

View file

@ -5,12 +5,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain\Repository;
use Doctrine\Persistence\ObjectRepository;
use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface DomainRepositoryInterface extends ObjectRepository
interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
{
/**
* @return Domain[]
*/
public function findDomainsWithout(?string $excludedAuthority = null): array;
public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array;
}

View file

@ -59,7 +59,7 @@ class ShortUrl extends AbstractEntity
$this->shortCodeLength = $meta->getShortCodeLength();
$this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($this->shortCodeLength);
$this->domain = $relationResolver->resolveDomain($meta->getDomain());
$this->authorApiKey = $relationResolver->resolveApiKey($meta->getApiKey());
$this->authorApiKey = $meta->getApiKey();
}
public static function fromImport(

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use function sprintf;
class DomainNotFoundException extends DomainException implements ProblemDetailsExceptionInterface
{
use CommonProblemDetailsExceptionTrait;
private const TITLE = 'Domain not found';
private const TYPE = 'DOMAIN_NOT_FOUND';
public static function fromId(string $id): self
{
$e = new self(sprintf('Domain with id "%s" could not be found', $id));
$e->detail = $e->getMessage();
$e->title = self::TITLE;
$e->type = self::TYPE;
$e->status = StatusCodeInterface::STATUS_NOT_FOUND;
$e->additional = ['id' => $id];
return $e;
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
class ForbiddenTagOperationException extends DomainException implements ProblemDetailsExceptionInterface
{
use CommonProblemDetailsExceptionTrait;
private const TITLE = 'Forbidden tag operation';
private const TYPE = 'FORBIDDEN_OPERATION';
public static function forDeletion(): self
{
return self::createWithMessage('You are not allowed to delete tags');
}
public static function forRenaming(): self
{
return self::createWithMessage('You are not allowed to rename tags');
}
private static function createWithMessage(string $message): self
{
$e = new self($message);
$e->detail = $message;
$e->title = self::TITLE;
$e->type = self::TYPE;
$e->status = StatusCodeInterface::STATUS_FORBIDDEN;
return $e;
}
}

View file

@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
use function sprintf;
@ -17,18 +18,15 @@ class TagConflictException extends RuntimeException implements ProblemDetailsExc
private const TITLE = 'Tag conflict';
private const TYPE = 'TAG_CONFLICT';
public static function fromExistingTag(string $oldName, string $newName): self
public static function forExistingTag(TagRenaming $renaming): self
{
$e = new self(sprintf('You cannot rename tag %s to %s, because it already exists', $oldName, $newName));
$e = new self(sprintf('You cannot rename tag %s, because it already exists', $renaming->toString()));
$e->detail = $e->getMessage();
$e->title = self::TITLE;
$e->type = self::TYPE;
$e->status = StatusCodeInterface::STATUS_CONFLICT;
$e->additional = [
'oldName' => $oldName,
'newName' => $newName,
];
$e->additional = $renaming->toArray();
return $e;
}

View file

@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Model;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
@ -24,7 +25,7 @@ final class ShortUrlMeta
private ?string $domain = null;
private int $shortCodeLength = 5;
private ?bool $validateUrl = null;
private ?string $apiKey = null;
private ?ApiKey $apiKey = null;
// Enforce named constructors
private function __construct()
@ -135,7 +136,7 @@ final class ShortUrlMeta
return $this->validateUrl;
}
public function getApiKey(): ?string
public function getApiKey(): ?ApiKey
{
return $this->apiKey;
}

View file

@ -4,19 +4,23 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
use Happyr\DoctrineSpecification\Specification\Specification;
use Laminas\Paginator\Adapter\AdapterInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlRepositoryAdapter implements AdapterInterface
{
private ShortUrlRepositoryInterface $repository;
private ShortUrlsParams $params;
private ?ApiKey $apiKey;
public function __construct(ShortUrlRepositoryInterface $repository, ShortUrlsParams $params)
public function __construct(ShortUrlRepositoryInterface $repository, ShortUrlsParams $params, ?ApiKey $apiKey)
{
$this->repository = $repository;
$this->params = $params;
$this->apiKey = $apiKey;
}
public function getItems($offset, $itemCountPerPage): array // phpcs:ignore
@ -28,6 +32,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
$this->params->tags(),
$this->params->orderBy(),
$this->params->dateRange(),
$this->resolveSpec(),
);
}
@ -37,6 +42,12 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
$this->params->searchTerm(),
$this->params->tags(),
$this->params->dateRange(),
$this->resolveSpec(),
);
}
private function resolveSpec(): ?Specification
{
return $this->apiKey !== null ? $this->apiKey->spec() : null;
}
}

View file

@ -4,20 +4,28 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{
private VisitRepositoryInterface $visitRepository;
private string $tag;
private VisitsParams $params;
private ?ApiKey $apiKey;
public function __construct(VisitRepositoryInterface $visitRepository, string $tag, VisitsParams $params)
{
public function __construct(
VisitRepositoryInterface $visitRepository,
string $tag,
VisitsParams $params,
?ApiKey $apiKey
) {
$this->visitRepository = $visitRepository;
$this->params = $params;
$this->tag = $tag;
$this->apiKey = $apiKey;
}
public function getItems($offset, $itemCountPerPage): array // phpcs:ignore
@ -27,11 +35,21 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
$this->params->getDateRange(),
$itemCountPerPage,
$offset,
$this->resolveSpec(),
);
}
protected function doCount(): int
{
return $this->visitRepository->countVisitsByTag($this->tag, $this->params->getDateRange());
return $this->visitRepository->countVisitsByTag(
$this->tag,
$this->params->getDateRange(),
$this->resolveSpec(),
);
}
private function resolveSpec(): ?Specification
{
return $this->apiKey !== null ? $this->apiKey->spec(true) : null;
}
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
@ -13,15 +14,18 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
private VisitRepositoryInterface $visitRepository;
private ShortUrlIdentifier $identifier;
private VisitsParams $params;
private ?Specification $spec;
public function __construct(
VisitRepositoryInterface $visitRepository,
ShortUrlIdentifier $identifier,
VisitsParams $params
VisitsParams $params,
?Specification $spec
) {
$this->visitRepository = $visitRepository;
$this->params = $params;
$this->identifier = $identifier;
$this->spec = $spec;
}
public function getItems($offset, $itemCountPerPage): array // phpcs:ignore
@ -32,6 +36,7 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
$this->params->getDateRange(),
$itemCountPerPage,
$offset,
$this->spec,
);
}
@ -41,6 +46,7 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
$this->identifier->shortCode(),
$this->identifier->domain(),
$this->params->getDateRange(),
$this->spec,
);
}
}

View file

@ -4,9 +4,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Happyr\DoctrineSpecification\EntitySpecificationRepository;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
@ -19,7 +20,7 @@ use function array_key_exists;
use function count;
use function Functional\contains;
class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface
class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface
{
/**
* @param string[] $tags
@ -31,9 +32,10 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
?string $searchTerm = null,
array $tags = [],
?ShortUrlsOrdering $orderBy = null,
?DateRange $dateRange = null
?DateRange $dateRange = null,
?Specification $spec = null
): array {
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange);
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec);
$qb->select('DISTINCT s')
->setMaxResults($limit)
->setFirstResult($offset);
@ -75,18 +77,23 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
return $qb->getQuery()->getResult();
}
public function countList(?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null): int
{
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange);
public function countList(
?string $searchTerm = null,
array $tags = [],
?DateRange $dateRange = null,
?Specification $spec = null
): int {
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec);
$qb->select('COUNT(DISTINCT s)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
private function createListQueryBuilder(
?string $searchTerm = null,
array $tags = [],
?DateRange $dateRange = null
?string $searchTerm,
array $tags,
?DateRange $dateRange,
?Specification $spec
): QueryBuilder {
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(ShortUrl::class, 's')
@ -125,6 +132,8 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
->andWhere($qb->expr()->in('t.name', $tags));
}
$this->applySpecification($qb, $spec, 's');
return $qb;
}
@ -160,23 +169,23 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
return $query->getOneOrNullResult();
}
public function findOne(string $shortCode, ?string $domain = null): ?ShortUrl
public function findOne(string $shortCode, ?string $domain = null, ?Specification $spec = null): ?ShortUrl
{
$qb = $this->createFindOneQueryBuilder($shortCode, $domain);
$qb = $this->createFindOneQueryBuilder($shortCode, $domain, $spec);
$qb->select('s');
return $qb->getQuery()->getOneOrNullResult();
}
public function shortCodeIsInUse(string $slug, ?string $domain = null): bool
public function shortCodeIsInUse(string $slug, ?string $domain = null, ?Specification $spec = null): bool
{
$qb = $this->createFindOneQueryBuilder($slug, $domain);
$qb = $this->createFindOneQueryBuilder($slug, $domain, $spec);
$qb->select('COUNT(DISTINCT s.id)');
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
}
private function createFindOneQueryBuilder(string $slug, ?string $domain = null): QueryBuilder
private function createFindOneQueryBuilder(string $slug, ?string $domain, ?Specification $spec): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(ShortUrl::class, 's')
@ -187,6 +196,8 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
$this->whereDomainIs($qb, $domain);
$this->applySpecification($qb, $spec, 's');
return $qb;
}
@ -223,6 +234,11 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
->setParameter('domain', $meta->getDomain());
}
$apiKey = $meta->getApiKey();
if ($apiKey !== null) {
$this->applySpecification($qb, $apiKey->spec(), 's');
}
$tagsAmount = count($tags);
if ($tagsAmount === 0) {
return $qb->getQuery()->getOneOrNullResult();

View file

@ -5,13 +5,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\Persistence\ObjectRepository;
use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
interface ShortUrlRepositoryInterface extends ObjectRepository
interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
{
public function findList(
?int $limit = null,
@ -19,16 +21,22 @@ interface ShortUrlRepositoryInterface extends ObjectRepository
?string $searchTerm = null,
array $tags = [],
?ShortUrlsOrdering $orderBy = null,
?DateRange $dateRange = null
?DateRange $dateRange = null,
?Specification $spec = null
): array;
public function countList(?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null): int;
public function countList(
?string $searchTerm = null,
array $tags = [],
?DateRange $dateRange = null,
?Specification $spec = null
): int;
public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl;
public function findOne(string $shortCode, ?string $domain = null): ?ShortUrl;
public function findOne(string $shortCode, ?string $domain = null, ?Specification $spec = null): ?ShortUrl;
public function shortCodeIsInUse(string $slug, ?string $domain): bool;
public function shortCodeIsInUse(string $slug, ?string $domain, ?Specification $spec = null): bool;
public function findOneMatching(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl;

View file

@ -4,13 +4,18 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\ORM\EntityRepository;
use Happyr\DoctrineSpecification\EntitySpecificationRepository;
use Happyr\DoctrineSpecification\Spec;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Spec\CountTagsWithName;
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function Functional\map;
class TagRepository extends EntityRepository implements TagRepositoryInterface
class TagRepository extends EntitySpecificationRepository implements TagRepositoryInterface
{
public function deleteByName(array $names): int
{
@ -28,21 +33,32 @@ class TagRepository extends EntityRepository implements TagRepositoryInterface
/**
* @return TagInfo[]
*/
public function findTagsWithInfo(): array
public function findTagsWithInfo(?Specification $spec = null): array
{
$dql = <<<DQL
SELECT t AS tag, COUNT(DISTINCT s.id) AS shortUrlsCount, COUNT(DISTINCT v.id) AS visitsCount
FROM Shlinkio\Shlink\Core\Entity\Tag t
LEFT JOIN t.shortUrls s
LEFT JOIN s.visits v
GROUP BY t
ORDER BY t.name ASC
DQL;
$query = $this->getEntityManager()->createQuery($dql);
$qb = $this->createQueryBuilder('t');
$qb->select('t AS tag', 'COUNT(DISTINCT s.id) AS shortUrlsCount', 'COUNT(DISTINCT v.id) AS visitsCount')
->leftJoin('t.shortUrls', 's')
->leftJoin('s.visits', 'v')
->groupBy('t')
->orderBy('t.name', 'ASC');
$this->applySpecification($qb, $spec, 't');
$query = $qb->getQuery();
return map(
$query->getResult(),
fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']),
);
}
public function tagExists(string $tag, ?ApiKey $apiKey = null): bool
{
$result = (int) $this->matchSingleScalarResult(Spec::andX(
new CountTagsWithName($tag),
new WithApiKeySpecsEnsuringJoin($apiKey),
));
return $result > 0;
}
}

View file

@ -5,14 +5,19 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\Persistence\ObjectRepository;
use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface TagRepositoryInterface extends ObjectRepository
interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
{
public function deleteByName(array $names): int;
/**
* @return TagInfo[]
*/
public function findTagsWithInfo(): array;
public function findTagsWithInfo(?Specification $spec = null): array;
public function tagExists(string $tag, ?ApiKey $apiKey = null): bool;
}

View file

@ -4,17 +4,21 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use Doctrine\ORM\QueryBuilder;
use Happyr\DoctrineSpecification\EntitySpecificationRepository;
use Happyr\DoctrineSpecification\Spec;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use const PHP_INT_MAX;
class VisitRepository extends EntityRepository implements VisitRepositoryInterface
class VisitRepository extends EntitySpecificationRepository implements VisitRepositoryInterface
{
/**
* @return iterable|Visit[]
@ -84,15 +88,20 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
?string $domain = null,
?DateRange $dateRange = null,
?int $limit = null,
?int $offset = null
?int $offset = null,
?Specification $spec = null
): array {
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange, $spec);
return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset);
}
public function countVisitsByShortCode(string $shortCode, ?string $domain = null, ?DateRange $dateRange = null): int
{
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
public function countVisitsByShortCode(
string $shortCode,
?string $domain = null,
?DateRange $dateRange = null,
?Specification $spec = null
): int {
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange, $spec);
$qb->select('COUNT(v.id)');
return (int) $qb->getQuery()->getSingleScalarResult();
@ -101,11 +110,12 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
private function createVisitsByShortCodeQueryBuilder(
string $shortCode,
?string $domain,
?DateRange $dateRange
?DateRange $dateRange,
?Specification $spec = null
): QueryBuilder {
/** @var ShortUrlRepositoryInterface $shortUrlRepo */
$shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOne($shortCode, $domain);
$shortUrl = $shortUrlRepo->findOne($shortCode, $domain, $spec);
$shortUrlId = $shortUrl !== null ? $shortUrl->getId() : -1;
// Parameters in this query need to be part of the query itself, as we need to use it a sub-query later
@ -124,32 +134,36 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
string $tag,
?DateRange $dateRange = null,
?int $limit = null,
?int $offset = null
?int $offset = null,
?Specification $spec = null
): array {
$qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange);
$qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange, $spec);
return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset);
}
public function countVisitsByTag(string $tag, ?DateRange $dateRange = null): int
public function countVisitsByTag(string $tag, ?DateRange $dateRange = null, ?Specification $spec = null): int
{
$qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange);
$qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange, $spec);
$qb->select('COUNT(v.id)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
private function createVisitsByTagQueryBuilder(string $tag, ?DateRange $dateRange = null): QueryBuilder
{
// Parameters in this query need to be part of the query itself, as we need to use it a sub-query later
private function createVisitsByTagQueryBuilder(
string $tag,
?DateRange $dateRange,
?Specification $spec
): QueryBuilder {
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
// Since they are not strictly provided by the caller, it's reasonably safe
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(Visit::class, 'v')
->join('v.shortUrl', 's')
->join('s.tags', 't')
->where($qb->expr()->eq('t.name', '\'' . $tag . '\''));
->where($qb->expr()->eq('t.name', '\'' . $tag . '\'')); // This needs to be concatenated, not bound
// Apply date range filtering
$this->applyDatesInline($qb, $dateRange);
$this->applySpecification($qb, $spec, 'v');
return $qb;
}
@ -194,4 +208,11 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
return $query->getResult();
}
public function countVisits(?ApiKey $apiKey = null): int
{
return (int) $this->matchSingleScalarResult(
Spec::countOf(new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrl')),
);
}
}

View file

@ -5,10 +5,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\Persistence\ObjectRepository;
use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface VisitRepositoryInterface extends ObjectRepository
interface VisitRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
{
public const DEFAULT_BLOCK_SIZE = 10000;
@ -35,13 +38,15 @@ interface VisitRepositoryInterface extends ObjectRepository
?string $domain = null,
?DateRange $dateRange = null,
?int $limit = null,
?int $offset = null
?int $offset = null,
?Specification $spec = null
): array;
public function countVisitsByShortCode(
string $shortCode,
?string $domain = null,
?DateRange $dateRange = null
?DateRange $dateRange = null,
?Specification $spec = null
): int;
/**
@ -51,8 +56,11 @@ interface VisitRepositoryInterface extends ObjectRepository
string $tag,
?DateRange $dateRange = null,
?int $limit = null,
?int $offset = null
?int $offset = null,
?Specification $spec = null
): array;
public function countVisitsByTag(string $tag, ?DateRange $dateRange = null): int;
public function countVisitsByTag(string $tag, ?DateRange $dateRange = null, ?Specification $spec = null): int;
public function countVisits(?ApiKey $apiKey = null): int;
}

View file

@ -9,6 +9,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class DeleteShortUrlService implements DeleteShortUrlServiceInterface
{
@ -30,9 +31,12 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface
* @throws Exception\ShortUrlNotFoundException
* @throws Exception\DeleteShortUrlException
*/
public function deleteByShortCode(ShortUrlIdentifier $identifier, bool $ignoreThreshold = false): void
{
$shortUrl = $this->urlResolver->resolveShortUrl($identifier);
public function deleteByShortCode(
ShortUrlIdentifier $identifier,
bool $ignoreThreshold = false,
?ApiKey $apiKey = null
): void {
$shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey);
if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) {
throw Exception\DeleteShortUrlException::fromVisitsThreshold(
$this->deleteShortUrlsOptions->getVisitsThreshold(),

View file

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface DeleteShortUrlServiceInterface
{
@ -13,5 +14,9 @@ interface DeleteShortUrlServiceInterface
* @throws Exception\ShortUrlNotFoundException
* @throws Exception\DeleteShortUrlException
*/
public function deleteByShortCode(ShortUrlIdentifier $identifier, bool $ignoreThreshold = false): void;
public function deleteByShortCode(
ShortUrlIdentifier $identifier,
bool $ignoreThreshold = false,
?ApiKey $apiKey = null
): void;
}

View file

@ -9,6 +9,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlResolver implements ShortUrlResolverInterface
{
@ -22,11 +23,15 @@ class ShortUrlResolver implements ShortUrlResolverInterface
/**
* @throws ShortUrlNotFoundException
*/
public function resolveShortUrl(ShortUrlIdentifier $identifier): ShortUrl
public function resolveShortUrl(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): ShortUrl
{
/** @var ShortUrlRepository $shortUrlRepo */
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOne($identifier->shortCode(), $identifier->domain());
$shortUrl = $shortUrlRepo->findOne(
$identifier->shortCode(),
$identifier->domain(),
$apiKey !== null ? $apiKey->spec() : null,
);
if ($shortUrl === null) {
throw ShortUrlNotFoundException::fromNotFound($identifier);
}

View file

@ -7,13 +7,14 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface ShortUrlResolverInterface
{
/**
* @throws ShortUrlNotFoundException
*/
public function resolveShortUrl(ShortUrlIdentifier $identifier): ShortUrl;
public function resolveShortUrl(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): ShortUrl;
/**
* @throws ShortUrlNotFoundException

View file

@ -17,6 +17,7 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlService implements ShortUrlServiceInterface
{
@ -39,11 +40,11 @@ class ShortUrlService implements ShortUrlServiceInterface
/**
* @return ShortUrl[]|Paginator
*/
public function listShortUrls(ShortUrlsParams $params): Paginator
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator
{
/** @var ShortUrlRepository $repo */
$repo = $this->em->getRepository(ShortUrl::class);
$paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $params));
$paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $params, $apiKey));
$paginator->setItemCountPerPage($params->itemsPerPage())
->setCurrentPageNumber($params->page());
@ -54,9 +55,9 @@ class ShortUrlService implements ShortUrlServiceInterface
* @param string[] $tags
* @throws ShortUrlNotFoundException
*/
public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags = []): ShortUrl
public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags, ?ApiKey $apiKey = null): ShortUrl
{
$shortUrl = $this->urlResolver->resolveShortUrl($identifier);
$shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey);
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
$this->em->flush();
@ -68,13 +69,16 @@ class ShortUrlService implements ShortUrlServiceInterface
* @throws ShortUrlNotFoundException
* @throws InvalidUrlException
*/
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit): ShortUrl
{
public function updateMetadataByShortCode(
ShortUrlIdentifier $identifier,
ShortUrlEdit $shortUrlEdit,
?ApiKey $apiKey = null
): ShortUrl {
if ($shortUrlEdit->hasLongUrl()) {
$this->urlValidator->validateUrl($shortUrlEdit->longUrl(), $shortUrlEdit->doValidateUrl());
}
$shortUrl = $this->urlResolver->resolveShortUrl($identifier);
$shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey);
$shortUrl->update($shortUrlEdit);
$this->em->flush();

View file

@ -11,23 +11,28 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface ShortUrlServiceInterface
{
/**
* @return ShortUrl[]|Paginator
*/
public function listShortUrls(ShortUrlsParams $params): Paginator;
public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator;
/**
* @param string[] $tags
* @throws ShortUrlNotFoundException
*/
public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags = []): ShortUrl;
public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags, ?ApiKey $apiKey = null): ShortUrl;
/**
* @throws ShortUrlNotFoundException
* @throws InvalidUrlException
*/
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit): ShortUrl;
public function updateMetadataByShortCode(
ShortUrlIdentifier $identifier,
ShortUrlEdit $shortUrlEdit,
?ApiKey $apiKey = null
): ShortUrl;
}

View file

@ -21,6 +21,7 @@ use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class VisitsTracker implements VisitsTrackerInterface
{
@ -52,17 +53,19 @@ class VisitsTracker implements VisitsTrackerInterface
* @return Visit[]|Paginator
* @throws ShortUrlNotFoundException
*/
public function info(ShortUrlIdentifier $identifier, VisitsParams $params): Paginator
public function info(ShortUrlIdentifier $identifier, VisitsParams $params, ?ApiKey $apiKey = null): Paginator
{
$spec = $apiKey !== null ? $apiKey->spec() : null;
/** @var ShortUrlRepositoryInterface $repo */
$repo = $this->em->getRepository(ShortUrl::class);
if (! $repo->shortCodeIsInUse($identifier->shortCode(), $identifier->domain())) {
if (! $repo->shortCodeIsInUse($identifier->shortCode(), $identifier->domain(), $spec)) {
throw ShortUrlNotFoundException::fromNotFound($identifier);
}
/** @var VisitRepositoryInterface $repo */
$repo = $this->em->getRepository(Visit::class);
$paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params));
$paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params, $spec));
$paginator->setItemCountPerPage($params->getItemsPerPage())
->setCurrentPageNumber($params->getPage());
@ -73,18 +76,17 @@ class VisitsTracker implements VisitsTrackerInterface
* @return Visit[]|Paginator
* @throws TagNotFoundException
*/
public function visitsForTag(string $tag, VisitsParams $params): Paginator
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator
{
/** @var TagRepository $tagRepo */
$tagRepo = $this->em->getRepository(Tag::class);
$count = $tagRepo->count(['name' => $tag]);
if ($count === 0) {
if (! $tagRepo->tagExists($tag, $apiKey)) {
throw TagNotFoundException::fromTag($tag);
}
/** @var VisitRepositoryInterface $repo */
$repo = $this->em->getRepository(Visit::class);
$paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params));
$paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params, $apiKey));
$paginator->setItemCountPerPage($params->getItemsPerPage())
->setCurrentPageNumber($params->getPage());

View file

@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface VisitsTrackerInterface
{
@ -21,11 +22,11 @@ interface VisitsTrackerInterface
* @return Visit[]|Paginator
* @throws ShortUrlNotFoundException
*/
public function info(ShortUrlIdentifier $identifier, VisitsParams $params): Paginator;
public function info(ShortUrlIdentifier $identifier, VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
/**
* @return Visit[]|Paginator
* @throws TagNotFoundException
*/
public function visitsForTag(string $tag, VisitsParams $params): Paginator;
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
}

View file

@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Resolver;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInterface
{
@ -27,15 +26,4 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt
$existingDomain = $this->em->getRepository(Domain::class)->findOneBy(['authority' => $domain]);
return $existingDomain ?? new Domain($domain);
}
public function resolveApiKey(?string $key): ?ApiKey
{
if ($key === null) {
return null;
}
/** @var ApiKey|null $existingApiKey */
$existingApiKey = $this->em->getRepository(ApiKey::class)->findOneBy(['key' => $key]);
return $existingApiKey;
}
}

View file

@ -5,11 +5,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Resolver;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface ShortUrlRelationResolverInterface
{
public function resolveDomain(?string $domain): ?Domain;
public function resolveApiKey(?string $key): ?ApiKey;
}

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Resolver;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterface
{
@ -13,9 +12,4 @@ class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterfac
{
return $domain !== null ? new Domain($domain) : null;
}
public function resolveApiKey(?string $key): ?ApiKey
{
return null;
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Spec;
use Happyr\DoctrineSpecification\BaseSpecification;
use Happyr\DoctrineSpecification\Filter\Filter;
use Happyr\DoctrineSpecification\Spec;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class BelongsToApiKey extends BaseSpecification
{
private ApiKey $apiKey;
private string $dqlAlias;
public function __construct(ApiKey $apiKey, ?string $dqlAlias = null)
{
$this->apiKey = $apiKey;
$this->dqlAlias = $dqlAlias ?? 's';
parent::__construct($this->dqlAlias);
}
protected function getSpec(): Filter
{
return Spec::eq('authorApiKey', $this->apiKey, $this->dqlAlias);
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Spec;
use Doctrine\ORM\QueryBuilder;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class BelongsToApiKeyInlined implements Specification
{
private ApiKey $apiKey;
public function __construct(ApiKey $apiKey)
{
$this->apiKey = $apiKey;
}
public function getFilter(QueryBuilder $qb, string $dqlAlias): string
{
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
return (string) $qb->expr()->eq('s.authorApiKey', '\'' . $this->apiKey->getId() . '\'');
}
public function modify(QueryBuilder $qb, string $dqlAlias): void
{
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Spec;
use Happyr\DoctrineSpecification\BaseSpecification;
use Happyr\DoctrineSpecification\Filter\Filter;
use Happyr\DoctrineSpecification\Spec;
class BelongsToDomain extends BaseSpecification
{
private string $domainId;
private string $dqlAlias;
public function __construct(string $domainId, ?string $dqlAlias = null)
{
$this->domainId = $domainId;
$this->dqlAlias = $dqlAlias ?? 's';
parent::__construct($this->dqlAlias);
}
protected function getSpec(): Filter
{
return Spec::eq('domain', $this->domainId, $this->dqlAlias);
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Spec;
use Doctrine\ORM\QueryBuilder;
use Happyr\DoctrineSpecification\Specification\Specification;
class BelongsToDomainInlined implements Specification
{
private string $domainId;
public function __construct(string $domainId)
{
$this->domainId = $domainId;
}
public function getFilter(QueryBuilder $qb, string $dqlAlias): string
{
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
return (string) $qb->expr()->eq('s.domain', '\'' . $this->domainId . '\'');
}
public function modify(QueryBuilder $qb, string $dqlAlias): void
{
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Tag\Model;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use function sprintf;
final class TagRenaming
{
private string $oldName;
private string $newName;
private function __construct()
{
}
public static function fromNames(string $oldName, string $newName): self
{
$o = new self();
$o->oldName = $oldName;
$o->newName = $newName;
return $o;
}
public static function fromArray(array $payload): self
{
if (! isset($payload['oldName'], $payload['newName'])) {
throw ValidationException::fromArray([
'oldName' => 'oldName is required',
'newName' => 'newName is required',
]);
}
return self::fromNames($payload['oldName'], $payload['newName']);
}
public function oldName(): string
{
return $this->oldName;
}
public function newName(): string
{
return $this->newName;
}
public function nameChanged(): bool
{
return $this->oldName !== $this->newName;
}
public function toString(): string
{
return sprintf('%s to %s', $this->oldName, $this->newName);
}
public function toArray(): array
{
return [
'oldName' => $this->oldName,
'newName' => $this->newName,
];
}
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Tag\Spec;
use Happyr\DoctrineSpecification\BaseSpecification;
use Happyr\DoctrineSpecification\Spec;
use Happyr\DoctrineSpecification\Specification\Specification;
class CountTagsWithName extends BaseSpecification
{
private string $tagName;
public function __construct(string $tagName)
{
parent::__construct();
$this->tagName = $tagName;
}
protected function getSpec(): Specification
{
return Spec::countOf(
Spec::andX(
Spec::select('id'),
Spec::eq('name', $this->tagName),
),
);
}
}

View file

@ -6,13 +6,18 @@ namespace Shlinkio\Shlink\Core\Tag;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM;
use Happyr\DoctrineSpecification\Spec;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class TagService implements TagServiceInterface
{
@ -28,28 +33,38 @@ class TagService implements TagServiceInterface
/**
* @return Tag[]
*/
public function listTags(): array
public function listTags(?ApiKey $apiKey = null): array
{
/** @var TagRepository $repo */
$repo = $this->em->getRepository(Tag::class);
/** @var Tag[] $tags */
$tags = $this->em->getRepository(Tag::class)->findBy([], ['name' => 'ASC']);
$tags = $repo->match(Spec::andX(
Spec::orderBy('name'),
new WithApiKeySpecsEnsuringJoin($apiKey),
));
return $tags;
}
/**
* @return TagInfo[]
*/
public function tagsInfo(): array
public function tagsInfo(?ApiKey $apiKey = null): array
{
/** @var TagRepositoryInterface $repo */
$repo = $this->em->getRepository(Tag::class);
return $repo->findTagsWithInfo();
return $repo->findTagsWithInfo($apiKey !== null ? $apiKey->spec() : null);
}
/**
* @param string[] $tagNames
* @throws ForbiddenTagOperationException
*/
public function deleteTags(array $tagNames): void
public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void
{
if ($apiKey !== null && ! $apiKey->isAdmin()) {
throw ForbiddenTagOperationException::forDeletion();
}
/** @var TagRepository $repo */
$repo = $this->em->getRepository(Tag::class);
$repo->deleteByName($tagNames);
@ -73,24 +88,29 @@ class TagService implements TagServiceInterface
/**
* @throws TagNotFoundException
* @throws TagConflictException
* @throws ForbiddenTagOperationException
*/
public function renameTag(string $oldName, string $newName): Tag
public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag
{
if ($apiKey !== null && ! $apiKey->isAdmin()) {
throw ForbiddenTagOperationException::forRenaming();
}
/** @var TagRepository $repo */
$repo = $this->em->getRepository(Tag::class);
/** @var Tag|null $tag */
$tag = $repo->findOneBy(['name' => $oldName]);
$tag = $repo->findOneBy(['name' => $renaming->oldName()]);
if ($tag === null) {
throw TagNotFoundException::fromTag($oldName);
throw TagNotFoundException::fromTag($renaming->oldName());
}
$newNameExists = $newName !== $oldName && $repo->count(['name' => $newName]) > 0;
$newNameExists = $renaming->nameChanged() && $repo->count(['name' => $renaming->newName()]) > 0;
if ($newNameExists) {
throw TagConflictException::fromExistingTag($oldName, $newName);
throw TagConflictException::forExistingTag($renaming);
}
$tag->rename($newName);
$tag->rename($renaming->newName());
$this->em->flush();
return $tag;

View file

@ -6,26 +6,30 @@ namespace Shlinkio\Shlink\Core\Tag;
use Doctrine\Common\Collections\Collection;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface TagServiceInterface
{
/**
* @return Tag[]
*/
public function listTags(): array;
public function listTags(?ApiKey $apiKey = null): array;
/**
* @return TagInfo[]
*/
public function tagsInfo(): array;
public function tagsInfo(?ApiKey $apiKey = null): array;
/**
* @param string[] $tagNames
* @throws ForbiddenTagOperationException
*/
public function deleteTags(array $tagNames): void;
public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void;
/**
* @deprecated
@ -37,6 +41,7 @@ interface TagServiceInterface
/**
* @throws TagNotFoundException
* @throws TagConflictException
* @throws ForbiddenTagOperationException
*/
public function renameTag(string $oldName, string $newName): Tag;
public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag;
}

View file

@ -11,6 +11,7 @@ use Laminas\InputFilter\InputFilter;
use Laminas\Validator;
use Shlinkio\Shlink\Common\Validation;
use Shlinkio\Shlink\Core\Util\CocurSymfonySluggerBridge;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use const Shlinkio\Shlink\Core\CUSTOM_SLUGS_REGEXP;
use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH;
@ -73,7 +74,11 @@ class ShortUrlMetaInputFilter extends InputFilter
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator());
$this->add($domain);
$this->add($this->createInput(self::API_KEY, false));
$apiKeyInput = new Input(self::API_KEY);
$apiKeyInput
->setRequired(false)
->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class]));
$this->add($apiKeyInput);
}
private function createPositiveNumberInput(string $name, int $min = 1): Input

View file

@ -8,6 +8,7 @@ use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class VisitsStatsHelper implements VisitsStatsHelperInterface
{
@ -18,15 +19,15 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface
$this->em = $em;
}
public function getVisitsStats(): VisitsStats
public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats
{
return new VisitsStats($this->getVisitsCount());
return new VisitsStats($this->getVisitsCount($apiKey));
}
private function getVisitsCount(): int
private function getVisitsCount(?ApiKey $apiKey): int
{
/** @var VisitRepository $visitsRepo */
$visitsRepo = $this->em->getRepository(Visit::class);
return $visitsRepo->count([]);
return $visitsRepo->countVisits($apiKey);
}
}

View file

@ -5,8 +5,9 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface VisitsStatsHelperInterface
{
public function getVisitsStats(): VisitsStats;
public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats;
}

View file

@ -9,12 +9,13 @@ use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
class DomainRepositoryTest extends DatabaseTestCase
{
protected const ENTITIES_TO_EMPTY = [ShortUrl::class, Domain::class];
protected const ENTITIES_TO_EMPTY = [ShortUrl::class, Domain::class, ApiKey::class];
private DomainRepository $repo;
@ -28,35 +29,70 @@ class DomainRepositoryTest extends DatabaseTestCase
{
$fooDomain = new Domain('foo.com');
$this->getEntityManager()->persist($fooDomain);
$fooShortUrl = $this->createShortUrl($fooDomain);
$this->getEntityManager()->persist($fooShortUrl);
$this->getEntityManager()->persist($this->createShortUrl($fooDomain));
$barDomain = new Domain('bar.com');
$this->getEntityManager()->persist($barDomain);
$barShortUrl = $this->createShortUrl($barDomain);
$this->getEntityManager()->persist($barShortUrl);
$this->getEntityManager()->persist($this->createShortUrl($barDomain));
$bazDomain = new Domain('baz.com');
$this->getEntityManager()->persist($bazDomain);
$bazShortUrl = $this->createShortUrl($bazDomain);
$this->getEntityManager()->persist($bazShortUrl);
$this->getEntityManager()->persist($this->createShortUrl($bazDomain));
$detachedDomain = new Domain('detached.com');
$this->getEntityManager()->persist($detachedDomain);
$this->getEntityManager()->flush();
self::assertEquals([$barDomain, $bazDomain, $fooDomain], $this->repo->findDomainsWithout());
self::assertEquals([$barDomain, $bazDomain, $fooDomain], $this->repo->findDomainsWithout(null));
self::assertEquals([$barDomain, $bazDomain], $this->repo->findDomainsWithout('foo.com'));
self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout('bar.com'));
self::assertEquals([$barDomain, $fooDomain], $this->repo->findDomainsWithout('baz.com'));
}
private function createShortUrl(Domain $domain): ShortUrl
/** @test */
public function findDomainsReturnsJustThoseMatchingProvidedApiKey(): void
{
$authorApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls());
$this->getEntityManager()->persist($authorApiKey);
$authorAndDomainApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls());
$this->getEntityManager()->persist($authorAndDomainApiKey);
$fooDomain = new Domain('foo.com');
$this->getEntityManager()->persist($fooDomain);
$this->getEntityManager()->persist($this->createShortUrl($fooDomain, $authorApiKey));
$barDomain = new Domain('bar.com');
$this->getEntityManager()->persist($barDomain);
$this->getEntityManager()->persist($this->createShortUrl($barDomain, $authorAndDomainApiKey));
$bazDomain = new Domain('baz.com');
$this->getEntityManager()->persist($bazDomain);
$this->getEntityManager()->persist($this->createShortUrl($bazDomain, $authorApiKey));
$this->getEntityManager()->flush();
$authorAndDomainApiKey->registerRole(RoleDefinition::forDomain($fooDomain->getId()));
$fooDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($fooDomain->getId()));
$this->getEntityManager()->persist($fooDomainApiKey);
$barDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($barDomain->getId()));
$this->getEntityManager()->persist($fooDomainApiKey);
$this->getEntityManager()->flush();
self::assertEquals([$fooDomain], $this->repo->findDomainsWithout(null, $fooDomainApiKey));
self::assertEquals([$barDomain], $this->repo->findDomainsWithout(null, $barDomainApiKey));
self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout(null, $authorApiKey));
self::assertEquals([], $this->repo->findDomainsWithout(null, $authorAndDomainApiKey));
}
private function createShortUrl(Domain $domain, ?ApiKey $apiKey = null): ShortUrl
{
return new ShortUrl(
'foo',
ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority()]),
ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority(), 'apiKey' => $apiKey]),
new class ($domain) implements ShortUrlRelationResolverInterface {
private Domain $domain;
@ -69,11 +105,6 @@ class DomainRepositoryTest extends DatabaseTestCase
{
return $this->domain;
}
public function resolveApiKey(?string $key): ?ApiKey
{
return null;
}
},
);
}

View file

@ -16,8 +16,11 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver;
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
use function count;
@ -31,6 +34,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
Visit::class,
ShortUrl::class,
Domain::class,
ApiKey::class,
];
private ShortUrlRepository $repo;
@ -308,17 +312,84 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
$result = $this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta));
self::assertSame($shortUrl1, $result);
self::assertNotSame($shortUrl2, $result);
self::assertNotSame($shortUrl3, $result);
}
/** @test */
public function findOneMatchingAppliesProvidedApiKeyConditions(): void
{
$start = Chronos::parse('2020-03-05 20:18:30');
$wrongDomain = new Domain('wrong.com');
$this->getEntityManager()->persist($wrongDomain);
$rightDomain = new Domain('right.com');
$this->getEntityManager()->persist($rightDomain);
$this->getEntityManager()->flush();
$apiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls());
$this->getEntityManager()->persist($apiKey);
$otherApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls());
$this->getEntityManager()->persist($otherApiKey);
$wrongDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($wrongDomain->getId()));
$this->getEntityManager()->persist($wrongDomainApiKey);
$rightDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($rightDomain->getId()));
$this->getEntityManager()->persist($rightDomainApiKey);
$shortUrl = new ShortUrl('foo', ShortUrlMeta::fromRawData(
['validSince' => $start, 'apiKey' => $apiKey, 'domain' => $rightDomain->getAuthority()],
), new PersistenceShortUrlRelationResolver($this->getEntityManager()));
$shortUrl->setTags($this->tagNamesToEntities($this->getEntityManager(), ['foo', 'bar']));
$this->getEntityManager()->persist($shortUrl);
$this->getEntityManager()->flush();
self::assertSame(
$shortUrl1,
$this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta)),
$shortUrl,
$this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData(['validSince' => $start])),
);
self::assertNotSame(
$shortUrl2,
$this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta)),
self::assertSame($shortUrl, $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([
'validSince' => $start,
'apiKey' => $apiKey,
])));
self::assertNull($this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([
'validSince' => $start,
'apiKey' => $otherApiKey,
])));
self::assertSame(
$shortUrl,
$this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([
'validSince' => $start,
'domain' => $rightDomain->getAuthority(),
])),
);
self::assertNotSame(
$shortUrl3,
$this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta)),
self::assertSame(
$shortUrl,
$this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([
'validSince' => $start,
'domain' => $rightDomain->getAuthority(),
'apiKey' => $rightDomainApiKey,
])),
);
self::assertSame(
$shortUrl,
$this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([
'validSince' => $start,
'domain' => $rightDomain->getAuthority(),
'apiKey' => $apiKey,
])),
);
self::assertNull(
$this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([
'validSince' => $start,
'domain' => $rightDomain->getAuthority(),
'apiKey' => $wrongDomainApiKey,
])),
);
}

View file

@ -5,11 +5,16 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Repository;
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;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
use function array_chunk;
@ -20,6 +25,8 @@ class TagRepositoryTest extends DatabaseTestCase
Visit::class,
ShortUrl::class,
Tag::class,
ApiKey::class,
Domain::class,
];
private TagRepository $repo;
@ -97,4 +104,59 @@ class TagRepositoryTest extends DatabaseTestCase
$result[3]->jsonSerialize(),
);
}
/** @test */
public function tagExistsReturnsExpectedResultBasedOnApiKey(): void
{
$domain = new Domain('foo.com');
$this->getEntityManager()->persist($domain);
$this->getEntityManager()->flush();
$authorApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls());
$this->getEntityManager()->persist($authorApiKey);
$domainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($domain->getId()));
$this->getEntityManager()->persist($domainApiKey);
$names = ['foo', 'bar', 'baz', 'another'];
$tags = [];
foreach ($names as $name) {
$tag = new Tag($name);
$tags[] = $tag;
$this->getEntityManager()->persist($tag);
}
[$firstUrlTags, $secondUrlTags] = array_chunk($tags, 3);
$shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData(['apiKey' => $authorApiKey]));
$shortUrl->setTags(new ArrayCollection($firstUrlTags));
$this->getEntityManager()->persist($shortUrl);
$shortUrl2 = new ShortUrl(
'',
ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority()]),
new PersistenceShortUrlRelationResolver($this->getEntityManager()),
);
$shortUrl2->setTags(new ArrayCollection($secondUrlTags));
$this->getEntityManager()->persist($shortUrl2);
$this->getEntityManager()->flush();
self::assertTrue($this->repo->tagExists('foo'));
self::assertTrue($this->repo->tagExists('bar'));
self::assertTrue($this->repo->tagExists('baz'));
self::assertTrue($this->repo->tagExists('another'));
self::assertFalse($this->repo->tagExists('invalid'));
self::assertTrue($this->repo->tagExists('foo', $authorApiKey));
self::assertTrue($this->repo->tagExists('bar', $authorApiKey));
self::assertTrue($this->repo->tagExists('baz', $authorApiKey));
self::assertFalse($this->repo->tagExists('another', $authorApiKey));
self::assertFalse($this->repo->tagExists('invalid', $authorApiKey));
self::assertFalse($this->repo->tagExists('foo', $domainApiKey));
self::assertFalse($this->repo->tagExists('bar', $domainApiKey));
self::assertFalse($this->repo->tagExists('baz', $domainApiKey));
self::assertTrue($this->repo->tagExists('another', $domainApiKey));
self::assertFalse($this->repo->tagExists('invalid', $domainApiKey));
}
}

View file

@ -15,7 +15,10 @@ use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
use function Functional\map;
@ -30,6 +33,7 @@ class VisitRepositoryTest extends DatabaseTestCase
ShortUrl::class,
Domain::class,
Tag::class,
ApiKey::class,
];
private VisitRepository $repo;
@ -185,6 +189,49 @@ class VisitRepositoryTest extends DatabaseTestCase
)));
}
/** @test */
public function countReturnsExpectedResultBasedOnApiKey(): void
{
$domain = new Domain('foo.com');
$this->getEntityManager()->persist($domain);
$this->getEntityManager()->flush();
$apiKey1 = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls());
$this->getEntityManager()->persist($apiKey1);
$shortUrl = new ShortUrl(
'',
ShortUrlMeta::fromRawData(['apiKey' => $apiKey1, 'domain' => $domain->getAuthority()]),
new PersistenceShortUrlRelationResolver($this->getEntityManager()),
);
$this->getEntityManager()->persist($shortUrl);
$this->createVisitsForShortUrl($shortUrl, 4);
$apiKey2 = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls());
$this->getEntityManager()->persist($apiKey2);
$shortUrl2 = new ShortUrl('', ShortUrlMeta::fromRawData(['apiKey' => $apiKey2]));
$this->getEntityManager()->persist($shortUrl2);
$this->createVisitsForShortUrl($shortUrl2, 5);
$shortUrl3 = new ShortUrl(
'',
ShortUrlMeta::fromRawData(['apiKey' => $apiKey2, 'domain' => $domain->getAuthority()]),
new PersistenceShortUrlRelationResolver($this->getEntityManager()),
);
$this->getEntityManager()->persist($shortUrl3);
$this->createVisitsForShortUrl($shortUrl3, 7);
$domainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($domain->getId()));
$this->getEntityManager()->persist($domainApiKey);
$this->getEntityManager()->flush();
self::assertEquals(4 + 5 + 7, $this->repo->countVisits());
self::assertEquals(4, $this->repo->countVisits($apiKey1));
self::assertEquals(5 + 7, $this->repo->countVisits($apiKey2));
self::assertEquals(4 + 7, $this->repo->countVisits($domainApiKey));
}
private function createShortUrlsAndVisits(bool $withDomain = true): array
{
$shortUrl = new ShortUrl('');
@ -192,7 +239,24 @@ class VisitRepositoryTest extends DatabaseTestCase
$shortCode = $shortUrl->getShortCode();
$this->getEntityManager()->persist($shortUrl);
for ($i = 0; $i < 6; $i++) {
$this->createVisitsForShortUrl($shortUrl);
if ($withDomain) {
$shortUrlWithDomain = new ShortUrl('', ShortUrlMeta::fromRawData([
'customSlug' => $shortCode,
'domain' => $domain,
]));
$this->getEntityManager()->persist($shortUrlWithDomain);
$this->createVisitsForShortUrl($shortUrlWithDomain, 3);
$this->getEntityManager()->flush();
}
return [$shortCode, $domain, $shortUrl];
}
private function createVisitsForShortUrl(ShortUrl $shortUrl, int $amount = 6): void
{
for ($i = 0; $i < $amount; $i++) {
$visit = new Visit(
$shortUrl,
Visitor::emptyInstance(),
@ -201,26 +265,5 @@ class VisitRepositoryTest extends DatabaseTestCase
);
$this->getEntityManager()->persist($visit);
}
if ($withDomain) {
$shortUrlWithDomain = new ShortUrl('', ShortUrlMeta::fromRawData([
'customSlug' => $shortCode,
'domain' => $domain,
]));
$this->getEntityManager()->persist($shortUrlWithDomain);
for ($i = 0; $i < 3; $i++) {
$visit = new Visit(
$shortUrlWithDomain,
Visitor::emptyInstance(),
true,
Chronos::parse(sprintf('2016-01-0%s', $i + 1)),
);
$this->getEntityManager()->persist($visit);
}
$this->getEntityManager()->flush();
}
return [$shortCode, $domain, $shortUrl];
}
}

View file

@ -9,8 +9,12 @@ use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class DomainServiceTest extends TestCase
{
@ -22,20 +26,20 @@ class DomainServiceTest extends TestCase
public function setUp(): void
{
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->domainService = new DomainService($this->em->reveal());
$this->domainService = new DomainService($this->em->reveal(), 'default.com');
}
/**
* @test
* @dataProvider provideExcludedDomains
*/
public function listDomainsWithoutDelegatesIntoRepository(?string $excludedDomain, array $expectedResult): void
public function listDomainsDelegatesIntoRepository(array $domains, array $expectedResult, ?ApiKey $apiKey): void
{
$repo = $this->prophesize(DomainRepositoryInterface::class);
$getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal());
$findDomains = $repo->findDomainsWithout($excludedDomain)->willReturn($expectedResult);
$findDomains = $repo->findDomainsWithout('default.com', $apiKey)->willReturn($domains);
$result = $this->domainService->listDomainsWithout($excludedDomain);
$result = $this->domainService->listDomains($apiKey);
self::assertEquals($expectedResult, $result);
$getRepo->shouldHaveBeenCalledOnce();
@ -44,9 +48,67 @@ class DomainServiceTest extends TestCase
public function provideExcludedDomains(): iterable
{
yield 'no excluded domain' => [null, []];
yield 'foo.com excluded domain' => ['foo.com', []];
yield 'bar.com excluded domain' => ['bar.com', [new Domain('bar.com')]];
yield 'baz.com excluded domain' => ['baz.com', [new Domain('foo.com'), new Domain('bar.com')]];
$default = new DomainItem('default.com', true);
$adminApiKey = new ApiKey();
$domainSpecificApiKey = ApiKey::withRoles(RoleDefinition::forDomain('123'));
yield 'empty list without API key' => [[], [$default], null];
yield 'one item without API key' => [
[new Domain('bar.com')],
[$default, new DomainItem('bar.com', false)],
null,
];
yield 'multiple items without API key' => [
[new Domain('foo.com'), new Domain('bar.com')],
[$default, new DomainItem('foo.com', false), new DomainItem('bar.com', false)],
null,
];
yield 'empty list with admin API key' => [[], [$default], $adminApiKey];
yield 'one item with admin API key' => [
[new Domain('bar.com')],
[$default, new DomainItem('bar.com', false)],
$adminApiKey,
];
yield 'multiple items with admin API key' => [
[new Domain('foo.com'), new Domain('bar.com')],
[$default, new DomainItem('foo.com', false), new DomainItem('bar.com', false)],
$adminApiKey,
];
yield 'empty list with domain-specific API key' => [[], [], $domainSpecificApiKey];
yield 'one item with domain-specific API key' => [
[new Domain('bar.com')],
[new DomainItem('bar.com', false)],
$domainSpecificApiKey,
];
yield 'multiple items with domain-specific API key' => [
[new Domain('foo.com'), new Domain('bar.com')],
[new DomainItem('foo.com', false), new DomainItem('bar.com', false)],
$domainSpecificApiKey,
];
}
/** @test */
public function getDomainThrowsExceptionWhenDomainIsNotFound(): void
{
$find = $this->em->find(Domain::class, '123')->willReturn(null);
$this->expectException(DomainNotFoundException::class);
$find->shouldBeCalledOnce();
$this->domainService->getDomain('123');
}
/** @test */
public function getDomainReturnsEntityWhenFound(): void
{
$domain = new Domain('');
$find = $this->em->find(Domain::class, '123')->willReturn($domain);
$result = $this->domainService->getDomain('123');
self::assertSame($domain, $result);
$find->shouldHaveBeenCalledOnce();
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use function sprintf;
class DomainNotFoundExceptionTest extends TestCase
{
/** @test */
public function properlyCreatesExceptionFromNotFoundTag(): void
{
$id = '123';
$expectedMessage = sprintf('Domain with id "%s" could not be found', $id);
$e = DomainNotFoundException::fromId($id);
self::assertEquals($expectedMessage, $e->getMessage());
self::assertEquals($expectedMessage, $e->getDetail());
self::assertEquals('Domain not found', $e->getTitle());
self::assertEquals('DOMAIN_NOT_FOUND', $e->getType());
self::assertEquals(['id' => $id], $e->getAdditionalData());
self::assertEquals(404, $e->getStatus());
}
}

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
class ForbiddenTagOperationExceptionTest extends TestCase
{
/**
* @test
* @dataProvider provideExceptions
*/
public function createsExpectedExceptionForDeletion(
ForbiddenTagOperationException $e,
string $expectedMessage
): void {
$this->assertExceptionShape($e, $expectedMessage);
}
private function assertExceptionShape(ForbiddenTagOperationException $e, string $expectedMessage): void
{
self::assertEquals($expectedMessage, $e->getMessage());
self::assertEquals($expectedMessage, $e->getDetail());
self::assertEquals('Forbidden tag operation', $e->getTitle());
self::assertEquals('FORBIDDEN_OPERATION', $e->getType());
self::assertEquals(403, $e->getStatus());
}
public function provideExceptions(): iterable
{
yield 'deletion' => [ForbiddenTagOperationException::forDeletion(), 'You are not allowed to delete tags'];
yield 'renaming' => [ForbiddenTagOperationException::forRenaming(), 'You are not allowed to rename tags'];
}
}

View file

@ -2,22 +2,23 @@
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Exception;
namespace ShlinkioTest\Shlink\Core\Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
use function sprintf;
class TagConflictExceptionTest extends TestCase
{
/** @test */
public function properlyCreatesExceptionFromNotFoundTag(): void
public function properlyCreatesExceptionForExistingTag(): void
{
$oldName = 'foo';
$newName = 'bar';
$expectedMessage = sprintf('You cannot rename tag %s to %s, because it already exists', $oldName, $newName);
$e = TagConflictException::fromExistingTag($oldName, $newName);
$e = TagConflictException::forExistingTag(TagRenaming::fromNames($oldName, $newName));
self::assertEquals($expectedMessage, $e->getMessage());
self::assertEquals($expectedMessage, $e->getDetail());

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Exception;
namespace ShlinkioTest\Shlink\Core\Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;

View file

@ -11,6 +11,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlRepositoryAdapterTest extends TestCase
{
@ -41,11 +42,11 @@ class ShortUrlRepositoryAdapterTest extends TestCase
'endDate' => $endDate,
'orderBy' => $orderBy,
]);
$adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params);
$adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params, null);
$orderBy = $params->orderBy();
$dateRange = $params->dateRange();
$this->repo->findList(10, 5, $searchTerm, $tags, $orderBy, $dateRange)->shouldBeCalledOnce();
$this->repo->findList(10, 5, $searchTerm, $tags, $orderBy, $dateRange, null)->shouldBeCalledOnce();
$adapter->getItems(5, 10);
}
@ -65,10 +66,11 @@ class ShortUrlRepositoryAdapterTest extends TestCase
'startDate' => $startDate,
'endDate' => $endDate,
]);
$adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params);
$apiKey = new ApiKey();
$adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params, $apiKey);
$dateRange = $params->dateRange();
$this->repo->countList($searchTerm, $tags, $dateRange)->shouldBeCalledOnce();
$this->repo->countList($searchTerm, $tags, $dateRange, $apiKey->spec())->shouldBeCalledOnce();
$adapter->count();
}

View file

@ -11,18 +11,17 @@ use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class VisitsForTagPaginatorAdapterTest extends TestCase
{
use ProphecyTrait;
private VisitsForTagPaginatorAdapter $adapter;
private ObjectProphecy $repo;
protected function setUp(): void
{
$this->repo = $this->prophesize(VisitRepositoryInterface::class);
$this->adapter = new VisitsForTagPaginatorAdapter($this->repo->reveal(), 'foo', VisitsParams::fromRawData([]));
}
/** @test */
@ -31,10 +30,11 @@ class VisitsForTagPaginatorAdapterTest extends TestCase
$count = 3;
$limit = 1;
$offset = 5;
$findVisits = $this->repo->findVisitsByTag('foo', new DateRange(), $limit, $offset)->willReturn([]);
$adapter = $this->createAdapter(null);
$findVisits = $this->repo->findVisitsByTag('foo', new DateRange(), $limit, $offset, null)->willReturn([]);
for ($i = 0; $i < $count; $i++) {
$this->adapter->getItems($offset, $limit);
$adapter->getItems($offset, $limit);
}
$findVisits->shouldHaveBeenCalledTimes($count);
@ -44,12 +44,24 @@ class VisitsForTagPaginatorAdapterTest extends TestCase
public function repoIsCalledOnlyOnceForCount(): void
{
$count = 3;
$countVisits = $this->repo->countVisitsByTag('foo', new DateRange())->willReturn(3);
$apiKey = new ApiKey();
$adapter = $this->createAdapter($apiKey);
$countVisits = $this->repo->countVisitsByTag('foo', new DateRange(), $apiKey->spec())->willReturn(3);
for ($i = 0; $i < $count; $i++) {
$this->adapter->count();
$adapter->count();
}
$countVisits->shouldHaveBeenCalledOnce();
}
private function createAdapter(?ApiKey $apiKey): VisitsForTagPaginatorAdapter
{
return new VisitsForTagPaginatorAdapter(
$this->repo->reveal(),
'foo',
VisitsParams::fromRawData([]),
$apiKey,
);
}
}

View file

@ -12,22 +12,17 @@ use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class VisitsPaginatorAdapterTest extends TestCase
{
use ProphecyTrait;
private VisitsPaginatorAdapter $adapter;
private ObjectProphecy $repo;
protected function setUp(): void
{
$this->repo = $this->prophesize(VisitRepositoryInterface::class);
$this->adapter = new VisitsPaginatorAdapter(
$this->repo->reveal(),
new ShortUrlIdentifier(''),
VisitsParams::fromRawData([]),
);
}
/** @test */
@ -36,10 +31,13 @@ class VisitsPaginatorAdapterTest extends TestCase
$count = 3;
$limit = 1;
$offset = 5;
$findVisits = $this->repo->findVisitsByShortCode('', null, new DateRange(), $limit, $offset)->willReturn([]);
$adapter = $this->createAdapter(null);
$findVisits = $this->repo->findVisitsByShortCode('', null, new DateRange(), $limit, $offset, null)->willReturn(
[],
);
for ($i = 0; $i < $count; $i++) {
$this->adapter->getItems($offset, $limit);
$adapter->getItems($offset, $limit);
}
$findVisits->shouldHaveBeenCalledTimes($count);
@ -49,12 +47,24 @@ class VisitsPaginatorAdapterTest extends TestCase
public function repoIsCalledOnlyOnceForCount(): void
{
$count = 3;
$countVisits = $this->repo->countVisitsByShortCode('', null, new DateRange())->willReturn(3);
$apiKey = new ApiKey();
$adapter = $this->createAdapter($apiKey);
$countVisits = $this->repo->countVisitsByShortCode('', null, new DateRange(), $apiKey->spec())->willReturn(3);
for ($i = 0; $i < $count; $i++) {
$this->adapter->count();
$adapter->count();
}
$countVisits->shouldHaveBeenCalledOnce();
}
private function createAdapter(?ApiKey $apiKey): VisitsPaginatorAdapter
{
return new VisitsPaginatorAdapter(
$this->repo->reveal(),
new ShortUrlIdentifier(''),
VisitsParams::fromRawData([]),
$apiKey !== null ? $apiKey->spec() : null,
);
}
}

View file

@ -18,12 +18,15 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolver;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait;
use function Functional\map;
use function range;
class ShortUrlResolverTest extends TestCase
{
use ApiKeyHelpersTrait;
use ProphecyTrait;
private ShortUrlResolver $urlResolver;
@ -35,37 +38,43 @@ class ShortUrlResolverTest extends TestCase
$this->urlResolver = new ShortUrlResolver($this->em->reveal());
}
/** @test */
public function shortCodeIsProperlyParsed(): void
/**
* @test
* @dataProvider provideAdminApiKeys
*/
public function shortCodeIsProperlyParsed(?ApiKey $apiKey): void
{
$shortUrl = new ShortUrl('expected_url');
$shortCode = $shortUrl->getShortCode();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$findOne = $repo->findOne($shortCode, null)->willReturn($shortUrl);
$findOne = $repo->findOne($shortCode, null, $apiKey !== null ? $apiKey->spec() : null)->willReturn($shortUrl);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$result = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode));
$result = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey);
self::assertSame($shortUrl, $result);
$findOne->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
}
/** @test */
public function exceptionIsThrownIfShortcodeIsNotFound(): void
/**
* @test
* @dataProvider provideAdminApiKeys
*/
public function exceptionIsThrownIfShortcodeIsNotFound(?ApiKey $apiKey): void
{
$shortCode = 'abc123';
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$findOne = $repo->findOne($shortCode, null)->willReturn(null);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$findOne = $repo->findOne($shortCode, null, $apiKey !== null ? $apiKey->spec() : null)->willReturn(null);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal(), $apiKey);
$this->expectException(ShortUrlNotFoundException::class);
$findOne->shouldBeCalledOnce();
$getRepo->shouldBeCalledOnce();
$this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode));
$this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey);
}
/** @test */

View file

@ -20,11 +20,14 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Service\ShortUrlService;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait;
use function count;
class ShortUrlServiceTest extends TestCase
{
use ApiKeyHelpersTrait;
use ProphecyTrait;
private ShortUrlService $service;
@ -48,8 +51,11 @@ class ShortUrlServiceTest extends TestCase
);
}
/** @test */
public function listedUrlsAreReturnedFromEntityManager(): void
/**
* @test
* @dataProvider provideAdminApiKeys
*/
public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void
{
$list = [
new ShortUrl(''),
@ -63,25 +69,29 @@ class ShortUrlServiceTest extends TestCase
$repo->countList(Argument::cetera())->willReturn(count($list))->shouldBeCalledOnce();
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$list = $this->service->listShortUrls(ShortUrlsParams::emptyInstance());
$list = $this->service->listShortUrls(ShortUrlsParams::emptyInstance(), $apiKey);
self::assertEquals(4, $list->getCurrentItemCount());
}
/** @test */
public function providedTagsAreGetFromRepoAndSetToTheShortUrl(): void
/**
* @test
* @dataProvider provideAdminApiKeys
*/
public function providedTagsAreGetFromRepoAndSetToTheShortUrl(?ApiKey $apiKey): void
{
$shortUrl = $this->prophesize(ShortUrl::class);
$shortUrl->setTags(Argument::any())->shouldBeCalledOnce();
$shortCode = 'abc123';
$this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn($shortUrl->reveal())
->shouldBeCalledOnce();
$this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey)
->willReturn($shortUrl->reveal())
->shouldBeCalledOnce();
$tagRepo = $this->prophesize(EntityRepository::class);
$tagRepo->findOneBy(['name' => 'foo'])->willReturn(new Tag('foo'))->shouldBeCalledOnce();
$tagRepo->findOneBy(['name' => 'bar'])->willReturn(null)->shouldBeCalledOnce();
$this->em->getRepository(Tag::class)->willReturn($tagRepo->reveal());
$this->service->setTagsByShortCode(new ShortUrlIdentifier($shortCode), ['foo', 'bar']);
$this->service->setTagsByShortCode(new ShortUrlIdentifier($shortCode), ['foo', 'bar'], $apiKey);
}
/**
@ -90,15 +100,19 @@ class ShortUrlServiceTest extends TestCase
*/
public function updateMetadataByShortCodeUpdatesProvidedData(
int $expectedValidateCalls,
ShortUrlEdit $shortUrlEdit
ShortUrlEdit $shortUrlEdit,
?ApiKey $apiKey
): void {
$originalLongUrl = 'originalLongUrl';
$shortUrl = new ShortUrl($originalLongUrl);
$findShortUrl = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier('abc123'))->willReturn($shortUrl);
$findShortUrl = $this->urlResolver->resolveShortUrl(
new ShortUrlIdentifier('abc123'),
$apiKey,
)->willReturn($shortUrl);
$flush = $this->em->flush()->willReturn(null);
$result = $this->service->updateMetadataByShortCode(new ShortUrlIdentifier('abc123'), $shortUrlEdit);
$result = $this->service->updateMetadataByShortCode(new ShortUrlIdentifier('abc123'), $shortUrlEdit, $apiKey);
self::assertSame($shortUrl, $result);
self::assertEquals($shortUrlEdit->validSince(), $shortUrl->getValidSince());
@ -121,19 +135,19 @@ class ShortUrlServiceTest extends TestCase
'validUntil' => Chronos::parse('2017-01-05 00:00:00')->toAtomString(),
'maxVisits' => 5,
],
)];
), null];
yield 'long URL' => [1, ShortUrlEdit::fromRawData(
[
'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(),
'maxVisits' => 10,
'longUrl' => 'modifiedLongUrl',
],
)];
), new ApiKey()];
yield 'long URL with validation' => [1, ShortUrlEdit::fromRawData(
[
'longUrl' => 'modifiedLongUrl',
'validateUrl' => true,
],
)];
), null];
}
}

View file

@ -10,14 +10,20 @@ use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
use Shlinkio\Shlink\Core\Tag\TagService;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait;
class TagServiceTest extends TestCase
{
use ApiKeyHelpersTrait;
use ProphecyTrait;
private TagService $service;
@ -28,7 +34,7 @@ class TagServiceTest extends TestCase
{
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->repo = $this->prophesize(TagRepository::class);
$this->em->getRepository(Tag::class)->willReturn($this->repo->reveal())->shouldBeCalled();
$this->em->getRepository(Tag::class)->willReturn($this->repo->reveal());
$this->service = new TagService($this->em->reveal());
}
@ -38,37 +44,55 @@ class TagServiceTest extends TestCase
{
$expected = [new Tag('foo'), new Tag('bar')];
$find = $this->repo->findBy(Argument::cetera())->willReturn($expected);
$match = $this->repo->match(Argument::cetera())->willReturn($expected);
$result = $this->service->listTags();
self::assertEquals($expected, $result);
$find->shouldHaveBeenCalled();
$match->shouldHaveBeenCalled();
}
/** @test */
public function tagsInfoDelegatesOnRepository(): void
/**
* @test
* @dataProvider provideAdminApiKeys
*/
public function tagsInfoDelegatesOnRepository(?ApiKey $apiKey): void
{
$expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)];
$find = $this->repo->findTagsWithInfo()->willReturn($expected);
$find = $this->repo->findTagsWithInfo($apiKey === null ? null : $apiKey->spec())->willReturn($expected);
$result = $this->service->tagsInfo();
$result = $this->service->tagsInfo($apiKey);
self::assertEquals($expected, $result);
$find->shouldHaveBeenCalled();
}
/** @test */
public function deleteTagsDelegatesOnRepository(): void
/**
* @test
* @dataProvider provideAdminApiKeys
*/
public function deleteTagsDelegatesOnRepository(?ApiKey $apiKey): void
{
$delete = $this->repo->deleteByName(['foo', 'bar'])->willReturn(4);
$this->service->deleteTags(['foo', 'bar']);
$this->service->deleteTags(['foo', 'bar'], $apiKey);
$delete->shouldHaveBeenCalled();
}
/** @test */
public function deleteTagsThrowsExceptionWhenProvidedApiKeyIsNotAdmin(): void
{
$delete = $this->repo->deleteByName(['foo', 'bar']);
$this->expectException(ForbiddenTagOperationException::class);
$this->expectExceptionMessage('You are not allowed to delete tags');
$delete->shouldNotBeCalled();
$this->service->deleteTags(['foo', 'bar'], ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()));
}
/** @test */
public function createTagsPersistsEntities(): void
{
@ -84,15 +108,18 @@ class TagServiceTest extends TestCase
$flush->shouldHaveBeenCalled();
}
/** @test */
public function renameInvalidTagThrowsException(): void
/**
* @test
* @dataProvider provideAdminApiKeys
*/
public function renameInvalidTagThrowsException(?ApiKey $apiKey): void
{
$find = $this->repo->findOneBy(Argument::cetera())->willReturn(null);
$find->shouldBeCalled();
$this->expectException(TagNotFoundException::class);
$this->service->renameTag('foo', 'bar');
$this->service->renameTag(TagRenaming::fromNames('foo', 'bar'), $apiKey);
}
/**
@ -107,7 +134,7 @@ class TagServiceTest extends TestCase
$countTags = $this->repo->count(Argument::cetera())->willReturn($count);
$flush = $this->em->flush()->willReturn(null);
$tag = $this->service->renameTag($oldName, $newName);
$tag = $this->service->renameTag(TagRenaming::fromNames($oldName, $newName));
self::assertSame($expected, $tag);
self::assertEquals($newName, (string) $tag);
@ -122,8 +149,11 @@ class TagServiceTest extends TestCase
yield 'different names names' => ['foo', 'bar', 0];
}
/** @test */
public function renameTagToAnExistingNameThrowsException(): void
/**
* @test
* @dataProvider provideAdminApiKeys
*/
public function renameTagToAnExistingNameThrowsException(?ApiKey $apiKey): void
{
$find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
$countTags = $this->repo->count(Argument::cetera())->willReturn(1);
@ -134,6 +164,21 @@ class TagServiceTest extends TestCase
$flush->shouldNotBeCalled();
$this->expectException(TagConflictException::class);
$this->service->renameTag('foo', 'bar');
$this->service->renameTag(TagRenaming::fromNames('foo', 'bar'), $apiKey);
}
/** @test */
public function renamingTagThrowsExceptionWhenProvidedApiKeyIsNotAdmin(): void
{
$getRepo = $this->em->getRepository(Tag::class);
$this->expectExceptionMessage(ForbiddenTagOperationException::class);
$this->expectExceptionMessage('You are not allowed to rename tags');
$getRepo->shouldNotBeCalled();
$this->service->renameTag(
TagRenaming::fromNames('foo', 'bar'),
ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()),
);
}
}

View file

@ -25,12 +25,15 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\Core\Service\VisitsTracker;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait;
use function Functional\map;
use function range;
class VisitsTrackerTest extends TestCase
{
use ApiKeyHelpersTrait;
use ProphecyTrait;
private VisitsTracker $visitsTracker;
@ -42,7 +45,7 @@ class VisitsTrackerTest extends TestCase
$this->em = $this->prophesize(EntityManager::class);
$this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), true);
$this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), true);
}
/** @test */
@ -58,21 +61,27 @@ class VisitsTrackerTest extends TestCase
$this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled();
}
/** @test */
public function infoReturnsVisitsForCertainShortCode(): void
/**
* @test
* @dataProvider provideAdminApiKeys
*/
public function infoReturnsVisitsForCertainShortCode(?ApiKey $apiKey): void
{
$shortCode = '123ABC';
$spec = $apiKey === null ? null : $apiKey->spec();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$count = $repo->shortCodeIsInUse($shortCode, null)->willReturn(true);
$count = $repo->shortCodeIsInUse($shortCode, null, $spec)->willReturn(true);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
$list = map(range(0, 1), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance()));
$repo2 = $this->prophesize(VisitRepository::class);
$repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0)->willReturn($list);
$repo2->countVisitsByShortCode($shortCode, null, Argument::type(DateRange::class))->willReturn(1);
$repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0, $spec)->willReturn(
$list,
);
$repo2->countVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), $spec)->willReturn(1);
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
$paginator = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams());
$paginator = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams(), $apiKey);
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems()));
$count->shouldHaveBeenCalledOnce();
@ -83,7 +92,7 @@ class VisitsTrackerTest extends TestCase
{
$shortCode = '123ABC';
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$count = $repo->shortCodeIsInUse($shortCode, null)->willReturn(false);
$count = $repo->shortCodeIsInUse($shortCode, null, null)->willReturn(false);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
$this->expectException(ShortUrlNotFoundException::class);
@ -96,35 +105,40 @@ class VisitsTrackerTest extends TestCase
public function throwsExceptionWhenRequestingVisitsForInvalidTag(): void
{
$tag = 'foo';
$apiKey = new ApiKey();
$repo = $this->prophesize(TagRepository::class);
$count = $repo->count(['name' => $tag])->willReturn(0);
$tagExists = $repo->tagExists($tag, $apiKey)->willReturn(false);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$this->expectException(TagNotFoundException::class);
$count->shouldBeCalledOnce();
$tagExists->shouldBeCalledOnce();
$getRepo->shouldBeCalledOnce();
$this->visitsTracker->visitsForTag($tag, new VisitsParams());
$this->visitsTracker->visitsForTag($tag, new VisitsParams(), $apiKey);
}
/** @test */
public function visitsForTagAreReturnedAsExpected(): void
/**
* @test
* @dataProvider provideAdminApiKeys
*/
public function visitsForTagAreReturnedAsExpected(?ApiKey $apiKey): void
{
$tag = 'foo';
$repo = $this->prophesize(TagRepository::class);
$count = $repo->count(['name' => $tag])->willReturn(1);
$tagExists = $repo->tagExists($tag, $apiKey)->willReturn(true);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$spec = $apiKey === null ? null : $apiKey->spec();
$list = map(range(0, 1), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance()));
$repo2 = $this->prophesize(VisitRepository::class);
$repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0)->willReturn($list);
$repo2->countVisitsByTag($tag, Argument::type(DateRange::class))->willReturn(1);
$repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0, $spec)->willReturn($list);
$repo2->countVisitsByTag($tag, Argument::type(DateRange::class), $spec)->willReturn(1);
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
$paginator = $this->visitsTracker->visitsForTag($tag, new VisitsParams());
$paginator = $this->visitsTracker->visitsForTag($tag, new VisitsParams(), $apiKey);
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems()));
$count->shouldHaveBeenCalledOnce();
$tagExists->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
}
}

View file

@ -11,7 +11,6 @@ use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class PersistenceShortUrlRelationResolverTest extends TestCase
{
@ -63,38 +62,4 @@ class PersistenceShortUrlRelationResolverTest extends TestCase
yield 'not found domain' => [null, $authority];
yield 'found domain' => [new Domain($authority), $authority];
}
/** @test */
public function returnsEmptyWhenNoApiKeyIsProvided(): void
{
$getRepository = $this->em->getRepository(ApiKey::class);
self::assertNull($this->resolver->resolveApiKey(null));
$getRepository->shouldNotHaveBeenCalled();
}
/**
* @test
* @dataProvider provideFoundApiKeys
*/
public function triesToFindApiKeyWhenValueIsProvided(?ApiKey $foundApiKey, string $key): void
{
$repo = $this->prophesize(ObjectRepository::class);
$find = $repo->findOneBy(['key' => $key])->willReturn($foundApiKey);
$getRepository = $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$result = $this->resolver->resolveApiKey($key);
self::assertSame($result, $foundApiKey);
$find->shouldHaveBeenCalledOnce();
$getRepository->shouldHaveBeenCalledOnce();
}
public function provideFoundApiKeys(): iterable
{
$key = 'abc123';
yield 'not found api key' => [null, $key];
yield 'found api key' => [new ApiKey(), $key];
}
}

View file

@ -38,19 +38,4 @@ class SimpleShortUrlRelationResolverTest extends TestCase
yield 'empty domain' => [null];
yield 'non-empty domain' => ['domain.com'];
}
/**
* @test
* @dataProvider provideKeys
*/
public function alwaysReturnsNullForApiKeys(?string $key): void
{
self::assertNull($this->resolver->resolveApiKey($key));
}
public function provideKeys(): iterable
{
yield 'empty api key' => [null];
yield 'non-empty api key' => ['abc123'];
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Util;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
trait ApiKeyHelpersTrait
{
public function provideAdminApiKeys(): iterable
{
yield 'no API key' => [null];
yield 'admin API key' => [new ApiKey()];
}
}

View file

@ -36,7 +36,7 @@ class VisitsStatsHelperTest extends TestCase
public function returnsExpectedVisitsStats(int $expectedCount): void
{
$repo = $this->prophesize(VisitRepository::class);
$count = $repo->count([])->willReturn($expectedCount);
$count = $repo->countVisits(null)->willReturn($expectedCount);
$getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal());
$stats = $this->helper->getVisitsStats();

View file

@ -45,6 +45,7 @@ return [
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class => InvokableFactory::class,
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ConfigAbstractFactory::class,
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => ConfigAbstractFactory::class,
Middleware\ShortUrl\OverrideDomainMiddleware::class => ConfigAbstractFactory::class,
],
],
@ -74,13 +75,14 @@ return [
Action\Tag\DeleteTagsAction::class => [TagService::class],
Action\Tag\CreateTagsAction::class => [TagService::class],
Action\Tag\UpdateTagAction::class => [TagService::class],
Action\Domain\ListDomainsAction::class => [DomainService::class, 'config.url_shortener.domain.hostname'],
Action\Domain\ListDomainsAction::class => [DomainService::class],
Middleware\CrossDomainMiddleware::class => ['config.cors'],
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'],
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [
'config.url_shortener.default_short_codes_length',
],
Middleware\ShortUrl\OverrideDomainMiddleware::class => [DomainService::class],
],
];

View file

@ -8,6 +8,7 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata;
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
use Shlinkio\Shlink\Rest\Entity\ApiKeyRole;
use function Shlinkio\Shlink\Core\determineTableName;
@ -34,4 +35,11 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
$builder->createField('enabled', Types::BOOLEAN)
->build();
$builder->createOneToMany('roles', ApiKeyRole::class)
->mappedBy('apiKey')
->setIndexBy('roleName')
->cascadePersist()
->orphanRemoval()
->build();
};

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function Shlinkio\Shlink\Core\determineTableName;
return static function (ClassMetadata $metadata, array $emConfig): void {
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(determineTableName('api_key_roles', $emConfig));
$builder->createField('id', Types::BIGINT)
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
$builder->createField('roleName', Types::STRING)
->columnName('role_name')
->length(256)
->nullable(false)
->build();
$builder->createField('meta', Types::JSON)
->columnName('meta')
->nullable(false)
->build();
$builder->createManyToOne('apiKey', ApiKey::class)
->addJoinColumn('api_key_id', 'id', false, false, 'CASCADE')
->cascadePersist()
->build();
$builder->addUniqueConstraint(['role_name', 'api_key_id'], 'UQ_role_plus_api_key');
};

View file

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Rest;
$contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class;
$dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class;
$overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class;
return [
@ -16,9 +17,13 @@ return [
Action\ShortUrl\CreateShortUrlAction::getRouteDef([
$contentNegotiationMiddleware,
$dropDomainMiddleware,
$overrideDomainMiddleware,
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class,
]),
Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([$contentNegotiationMiddleware]),
Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([
$contentNegotiationMiddleware,
$overrideDomainMiddleware,
]),
Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]),

View file

@ -8,10 +8,8 @@ use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use function Functional\map;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
class ListDomainsAction extends AbstractRestAction
{
@ -19,33 +17,21 @@ class ListDomainsAction extends AbstractRestAction
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
private DomainServiceInterface $domainService;
private string $defaultDomain;
public function __construct(DomainServiceInterface $domainService, string $defaultDomain)
public function __construct(DomainServiceInterface $domainService)
{
$this->domainService = $domainService;
$this->defaultDomain = $defaultDomain;
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$regularDomains = $this->domainService->listDomainsWithout($this->defaultDomain);
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
$domainItems = $this->domainService->listDomains($apiKey);
return new JsonResponse([
'domains' => [
'data' => [
$this->mapDomain($this->defaultDomain, true),
...map($regularDomains, fn (Domain $domain) => $this->mapDomain($domain->getAuthority())),
],
'data' => $domainItems,
],
]);
}
private function mapDomain(string $domain, bool $isDefault = false): array
{
return [
'domain' => $domain,
'isDefault' => $isDefault,
];
}
}

View file

@ -10,6 +10,7 @@ use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
class DeleteShortUrlAction extends AbstractRestAction
{
@ -26,7 +27,10 @@ class DeleteShortUrlAction extends AbstractRestAction
public function handle(ServerRequestInterface $request): ResponseInterface
{
$identifier = ShortUrlIdentifier::fromApiRequest($request);
$this->deleteShortUrlService->deleteByShortCode($identifier);
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
$this->deleteShortUrlService->deleteByShortCode($identifier, false, $apiKey);
return new EmptyResponse();
}
}

View file

@ -11,6 +11,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
class EditShortUrlAction extends AbstractRestAction
{
@ -28,8 +29,9 @@ class EditShortUrlAction extends AbstractRestAction
{
$shortUrlEdit = ShortUrlEdit::fromRawData((array) $request->getParsedBody());
$identifier = ShortUrlIdentifier::fromApiRequest($request);
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
$this->shortUrlService->updateMetadataByShortCode($identifier, $shortUrlEdit);
$this->shortUrlService->updateMetadataByShortCode($identifier, $shortUrlEdit, $apiKey);
return new EmptyResponse();
}
}

View file

@ -11,6 +11,7 @@ use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
class EditShortUrlTagsAction extends AbstractRestAction
{
@ -35,8 +36,9 @@ class EditShortUrlTagsAction extends AbstractRestAction
}
['tags' => $tags] = $bodyParams;
$identifier = ShortUrlIdentifier::fromApiRequest($request);
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
$shortUrl = $this->shortUrlService->setTagsByShortCode($identifier, $tags);
$shortUrl = $this->shortUrlService->setTagsByShortCode($identifier, $tags, $apiKey);
return new JsonResponse(['tags' => $shortUrl->getTags()->toArray()]);
}
}

View file

@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
class ListShortUrlsAction extends AbstractRestAction
{
@ -31,7 +32,10 @@ class ListShortUrlsAction extends AbstractRestAction
public function handle(Request $request): Response
{
$shortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData($request->getQueryParams()));
$shortUrls = $this->shortUrlService->listShortUrls(
ShortUrlsParams::fromRawData($request->getQueryParams()),
AuthenticationMiddleware::apiKeyFromRequest($request),
);
return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls, new ShortUrlDataTransformer(
$this->domainConfig,
))]);

View file

@ -11,6 +11,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
class ResolveShortUrlAction extends AbstractRestAction
{
@ -29,7 +30,10 @@ class ResolveShortUrlAction extends AbstractRestAction
public function handle(Request $request): Response
{
$transformer = new ShortUrlDataTransformer($this->domainConfig);
$url = $this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromApiRequest($request));
$url = $this->urlResolver->resolveShortUrl(
ShortUrlIdentifier::fromApiRequest($request),
AuthenticationMiddleware::apiKeyFromRequest($request),
);
return new JsonResponse($transformer->transform($url));
}

View file

@ -34,10 +34,10 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction
protected function buildShortUrlData(Request $request): CreateShortUrlData
{
$query = $request->getQueryParams();
$apiKey = $query['apiKey'] ?? '';
$longUrl = $query['longUrl'] ?? null;
if (! $this->apiKeyService->check($apiKey)) {
$apiKeyResult = $this->apiKeyService->check($query['apiKey'] ?? '');
if (! $apiKeyResult->isValid()) {
throw ValidationException::fromArray([
'apiKey' => 'No API key was provided or it is not valid',
]);
@ -50,7 +50,9 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction
}
return new CreateShortUrlData($longUrl, [], ShortUrlMeta::fromRawData([
ShortUrlMetaInputFilter::API_KEY => $apiKey,
ShortUrlMetaInputFilter::API_KEY => $apiKeyResult->apiKey(),
// This will usually be null, unless this API key enforces one specific domain
ShortUrlMetaInputFilter::DOMAIN => $request->getAttribute(ShortUrlMetaInputFilter::DOMAIN),
]));
}
}

View file

@ -9,6 +9,7 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
class DeleteTagsAction extends AbstractRestAction
{
@ -22,18 +23,13 @@ class DeleteTagsAction extends AbstractRestAction
$this->tagService = $tagService;
}
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
*
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
$query = $request->getQueryParams();
$tags = $query['tags'] ?? [];
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
$this->tagService->deleteTags($tags);
$this->tagService->deleteTags($tags, $apiKey);
return new EmptyResponse();
}
}

View file

@ -10,6 +10,7 @@ use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
use function Functional\map;
@ -29,16 +30,17 @@ class ListTagsAction extends AbstractRestAction
{
$query = $request->getQueryParams();
$withStats = ($query['withStats'] ?? null) === 'true';
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
if (! $withStats) {
return new JsonResponse([
'tags' => [
'data' => $this->tagService->listTags(),
'data' => $this->tagService->listTags($apiKey),
],
]);
}
$tagsInfo = $this->tagService->tagsInfo();
$tagsInfo = $this->tagService->tagsInfo($apiKey);
$data = map($tagsInfo, fn (TagInfo $info) => (string) $info->tag());
return new JsonResponse([

View file

@ -7,9 +7,10 @@ namespace Shlinkio\Shlink\Rest\Action\Tag;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
class UpdateTagAction extends AbstractRestAction
{
@ -23,24 +24,12 @@ class UpdateTagAction extends AbstractRestAction
$this->tagService = $tagService;
}
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
*
* @throws \InvalidArgumentException
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
$body = $request->getParsedBody();
if (! isset($body['oldName'], $body['newName'])) {
throw ValidationException::fromArray([
'oldName' => 'oldName is required',
'newName' => 'newName is required',
]);
}
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
$this->tagService->renameTag($body['oldName'], $body['newName']);
$this->tagService->renameTag(TagRenaming::fromArray($body), $apiKey);
return new EmptyResponse();
}
}

View file

@ -9,6 +9,7 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
class GlobalVisitsAction extends AbstractRestAction
{
@ -24,8 +25,10 @@ class GlobalVisitsAction extends AbstractRestAction
public function handle(ServerRequestInterface $request): ResponseInterface
{
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
return new JsonResponse([
'visits' => $this->statsHelper->getVisitsStats(),
'visits' => $this->statsHelper->getVisitsStats($apiKey),
]);
}
}

View file

@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
class ShortUrlVisitsAction extends AbstractRestAction
{
@ -30,7 +31,9 @@ class ShortUrlVisitsAction extends AbstractRestAction
public function handle(Request $request): Response
{
$identifier = ShortUrlIdentifier::fromApiRequest($request);
$visits = $this->visitsTracker->info($identifier, VisitsParams::fromRawData($request->getQueryParams()));
$params = VisitsParams::fromRawData($request->getQueryParams());
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
$visits = $this->visitsTracker->info($identifier, $params, $apiKey);
return new JsonResponse([
'visits' => $this->serializePaginator($visits),

View file

@ -11,6 +11,7 @@ use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
class TagVisitsAction extends AbstractRestAction
{
@ -29,7 +30,9 @@ class TagVisitsAction extends AbstractRestAction
public function handle(Request $request): Response
{
$tag = $request->getAttribute('tag', '');
$visits = $this->visitsTracker->visitsForTag($tag, VisitsParams::fromRawData($request->getQueryParams()));
$params = VisitsParams::fromRawData($request->getQueryParams());
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
$visits = $this->visitsTracker->visitsForTag($tag, $params, $apiKey);
return new JsonResponse([
'visits' => $this->serializePaginator($visits),

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\ApiKey\Model;
use Shlinkio\Shlink\Rest\ApiKey\Role;
final class RoleDefinition
{
private string $roleName;
private array $meta;
private function __construct(string $roleName, array $meta)
{
$this->roleName = $roleName;
$this->meta = $meta;
}
public static function forAuthoredShortUrls(): self
{
return new self(Role::AUTHORED_SHORT_URLS, []);
}
public static function forDomain(string $domainId): self
{
return new self(Role::DOMAIN_SPECIFIC, ['domain_id' => $domainId]);
}
public function roleName(): string
{
return $this->roleName;
}
public function meta(): array
{
return $this->meta;
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\ApiKey;
use Happyr\DoctrineSpecification\Spec;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToApiKey;
use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToApiKeyInlined;
use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToDomain;
use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToDomainInlined;
use Shlinkio\Shlink\Rest\Entity\ApiKeyRole;
class Role
{
public const AUTHORED_SHORT_URLS = 'AUTHORED_SHORT_URLS';
public const DOMAIN_SPECIFIC = 'DOMAIN_SPECIFIC';
public static function toSpec(ApiKeyRole $role, bool $inlined): Specification
{
if ($role->name() === self::AUTHORED_SHORT_URLS) {
return $inlined ? new BelongsToApiKeyInlined($role->apiKey()) : new BelongsToApiKey($role->apiKey());
}
if ($role->name() === self::DOMAIN_SPECIFIC) {
$domainId = self::domainIdFromMeta($role->meta());
return $inlined ? new BelongsToDomainInlined($domainId) : new BelongsToDomain($domainId);
}
return Spec::andX();
}
public static function domainIdFromMeta(array $meta): string
{
return $meta['domain_id'] ?? '-1';
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\ApiKey\Spec;
use Happyr\DoctrineSpecification\BaseSpecification;
use Happyr\DoctrineSpecification\Spec;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class WithApiKeySpecsEnsuringJoin extends BaseSpecification
{
private ?ApiKey $apiKey;
private string $fieldToJoin;
public function __construct(?ApiKey $apiKey, string $fieldToJoin = 'shortUrls')
{
parent::__construct();
$this->apiKey = $apiKey;
$this->fieldToJoin = $fieldToJoin;
}
protected function getSpec(): Specification
{
return $this->apiKey === null || $this->apiKey->isAdmin() ? Spec::andX() : Spec::andX(
Spec::join($this->fieldToJoin, 's'),
$this->apiKey->spec(),
);
}
}

View file

@ -5,20 +5,52 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Entity;
use Cake\Chronos\Chronos;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Exception;
use Happyr\DoctrineSpecification\Spec;
use Happyr\DoctrineSpecification\Specification\Specification;
use Ramsey\Uuid\Uuid;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\ApiKey\Role;
class ApiKey extends AbstractEntity
{
private string $key;
private ?Chronos $expirationDate = null;
private bool $enabled;
/** @var Collection|ApiKeyRole[] */
private Collection $roles;
/**
* @throws Exception
*/
public function __construct(?Chronos $expirationDate = null)
{
$this->key = Uuid::uuid4()->toString();
$this->expirationDate = $expirationDate;
$this->enabled = true;
$this->roles = new ArrayCollection();
}
public static function withRoles(RoleDefinition ...$roleDefinitions): self
{
$apiKey = new self();
foreach ($roleDefinitions as $roleDefinition) {
$apiKey->registerRole($roleDefinition);
}
return $apiKey;
}
public static function withKey(string $key, ?Chronos $expirationDate = null): self
{
$apiKey = new self($expirationDate);
$apiKey->key = $key;
return $apiKey;
}
public function getExpirationDate(): ?Chronos
@ -54,4 +86,52 @@ class ApiKey extends AbstractEntity
{
return $this->key;
}
public function toString(): string
{
return $this->key;
}
public function spec(bool $inlined = false): Specification
{
$specs = $this->roles->map(fn (ApiKeyRole $role) => Role::toSpec($role, $inlined))->getValues();
return Spec::andX(...$specs);
}
public function isAdmin(): bool
{
return $this->roles->isEmpty();
}
public function hasRole(string $roleName): bool
{
return $this->roles->containsKey($roleName);
}
public function getRoleMeta(string $roleName): array
{
/** @var ApiKeyRole|null $role */
$role = $this->roles->get($roleName);
return $role === null ? [] : $role->meta();
}
public function registerRole(RoleDefinition $roleDefinition): void
{
$roleName = $roleDefinition->roleName();
$meta = $roleDefinition->meta();
if ($this->hasRole($roleName)) {
/** @var ApiKeyRole $role */
$role = $this->roles->get($roleName);
$role->updateMeta($meta);
} else {
$role = new ApiKeyRole($roleDefinition->roleName(), $roleDefinition->meta(), $this);
$this->roles[$roleName] = $role;
}
}
public function removeRole(string $roleName): void
{
$this->roles->remove($roleName);
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Entity;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
class ApiKeyRole extends AbstractEntity
{
private string $roleName;
private array $meta;
private ApiKey $apiKey;
public function __construct(string $roleName, array $meta, ApiKey $apiKey)
{
$this->roleName = $roleName;
$this->meta = $meta;
$this->apiKey = $apiKey;
}
public function name(): string
{
return $this->roleName;
}
public function meta(): array
{
return $this->meta;
}
public function updateMeta(array $newMeta): void
{
$this->meta = $newMeta;
}
public function apiKey(): ApiKey
{
return $this->apiKey;
}
}

View file

@ -11,6 +11,7 @@ use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Exception\MissingAuthenticationException;
use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
@ -43,20 +44,21 @@ class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterfa
return $handler->handle($request);
}
$apiKey = self::apiKeyFromRequest($request);
$apiKey = $request->getHeaderLine(self::API_KEY_HEADER);
if (empty($apiKey)) {
throw MissingAuthenticationException::fromExpectedTypes([self::API_KEY_HEADER]);
}
if (! $this->apiKeyService->check($apiKey)) {
$result = $this->apiKeyService->check($apiKey);
if (! $result->isValid()) {
throw VerifyAuthenticationException::forInvalidApiKey();
}
return $handler->handle($request);
return $handler->handle($request->withAttribute(ApiKey::class, $result->apiKey()));
}
public static function apiKeyFromRequest(Request $request): string
public static function apiKeyFromRequest(Request $request): ApiKey
{
return $request->getHeaderLine(self::API_KEY_HEADER);
return $request->getAttribute(ApiKey::class);
}
}

Some files were not shown because too many files have changed in this diff Show more