mirror of
https://github.com/shlinkio/shlink.git
synced 2025-03-14 04:00:57 +03:00
Merge pull request #958 from acelaya-forks/feature/api-key-permissions
Feature/api key permissions
This commit is contained in:
commit
95e51665b1
143 changed files with 2520 additions and 626 deletions
|
@ -17,7 +17,7 @@ indocker
|
|||
docker-*
|
||||
phpstan.neon
|
||||
php*xml*
|
||||
infection.json
|
||||
infection*
|
||||
**/test*
|
||||
build*
|
||||
**/.*
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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' => '',
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
52
data/migrations/Version20210102174433.php
Normal file
52
data/migrations/Version20210102174433.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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": []
|
||||
|
|
|
@ -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
23
infection-db.json
Normal 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
|
||||
}
|
||||
}
|
|
@ -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": "."
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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([]);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
37
module/Core/src/Domain/Model/DomainItem.php
Normal file
37
module/Core/src/Domain/Model/DomainItem.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
32
module/Core/src/Exception/DomainNotFoundException.php
Normal file
32
module/Core/src/Exception/DomainNotFoundException.php
Normal 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;
|
||||
}
|
||||
}
|
39
module/Core/src/Exception/ForbiddenTagOperationException.php
Normal file
39
module/Core/src/Exception/ForbiddenTagOperationException.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
28
module/Core/src/ShortUrl/Spec/BelongsToApiKey.php
Normal file
28
module/Core/src/ShortUrl/Spec/BelongsToApiKey.php
Normal 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);
|
||||
}
|
||||
}
|
29
module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php
Normal file
29
module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php
Normal 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
|
||||
{
|
||||
}
|
||||
}
|
27
module/Core/src/ShortUrl/Spec/BelongsToDomain.php
Normal file
27
module/Core/src/ShortUrl/Spec/BelongsToDomain.php
Normal 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);
|
||||
}
|
||||
}
|
28
module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php
Normal file
28
module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php
Normal 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
|
||||
{
|
||||
}
|
||||
}
|
68
module/Core/src/Tag/Model/TagRenaming.php
Normal file
68
module/Core/src/Tag/Model/TagRenaming.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
30
module/Core/src/Tag/Spec/CountTagsWithName.php
Normal file
30
module/Core/src/Tag/Spec/CountTagsWithName.php
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
])),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
28
module/Core/test/Exception/DomainNotFoundExceptionTest.php
Normal file
28
module/Core/test/Exception/DomainNotFoundExceptionTest.php
Normal 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());
|
||||
}
|
||||
}
|
|
@ -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'];
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
16
module/Core/test/Util/ApiKeyHelpersTrait.php
Normal file
16
module/Core/test/Util/ApiKeyHelpersTrait.php
Normal 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()];
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
};
|
|
@ -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]),
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
))]);
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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([
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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),
|
||||
|
|
39
module/Rest/src/ApiKey/Model/RoleDefinition.php
Normal file
39
module/Rest/src/ApiKey/Model/RoleDefinition.php
Normal 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;
|
||||
}
|
||||
}
|
38
module/Rest/src/ApiKey/Role.php
Normal file
38
module/Rest/src/ApiKey/Role.php
Normal 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';
|
||||
}
|
||||
}
|
31
module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php
Normal file
31
module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
41
module/Rest/src/Entity/ApiKeyRole.php
Normal file
41
module/Rest/src/Entity/ApiKeyRole.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Add table
Reference in a new issue