Fixed merge conflicts

This commit is contained in:
Alejandro Celaya
2022-08-09 18:59:55 +02:00
263 changed files with 4542 additions and 2289 deletions

View File

@@ -11,11 +11,13 @@ return [
Command\ShortUrl\CreateShortUrlCommand::NAME => Command\ShortUrl\CreateShortUrlCommand::class,
Command\ShortUrl\ResolveUrlCommand::NAME => Command\ShortUrl\ResolveUrlCommand::class,
Command\ShortUrl\ListShortUrlsCommand::NAME => Command\ShortUrl\ListShortUrlsCommand::class,
Command\ShortUrl\GetVisitsCommand::NAME => Command\ShortUrl\GetVisitsCommand::class,
Command\ShortUrl\GetShortUrlVisitsCommand::NAME => Command\ShortUrl\GetShortUrlVisitsCommand::class,
Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class,
Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class,
Command\Visit\DownloadGeoLiteDbCommand::NAME => Command\Visit\DownloadGeoLiteDbCommand::class,
Command\Visit\GetOrphanVisitsCommand::NAME => Command\Visit\GetOrphanVisitsCommand::class,
Command\Visit\GetNonOrphanVisitsCommand::NAME => Command\Visit\GetNonOrphanVisitsCommand::class,
Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class,
Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,
@@ -24,9 +26,11 @@ return [
Command\Tag\ListTagsCommand::NAME => Command\Tag\ListTagsCommand::class,
Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class,
Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class,
Command\Tag\GetTagVisitsCommand::NAME => Command\Tag\GetTagVisitsCommand::class,
Command\Domain\ListDomainsCommand::NAME => Command\Domain\ListDomainsCommand::class,
Command\Domain\DomainRedirectsCommand::NAME => Command\Domain\DomainRedirectsCommand::class,
Command\Domain\GetDomainVisitsCommand::NAME => Command\Domain\GetDomainVisitsCommand::class,
Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class,
Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class,

View File

@@ -11,6 +11,7 @@ use Laminas\ServiceManager\Factory\InvokableFactory;
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
@@ -42,11 +43,13 @@ return [
Command\ShortUrl\CreateShortUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\ListShortUrlsCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\GetVisitsCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\GetShortUrlVisitsCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class,
Command\Visit\DownloadGeoLiteDbCommand::class => ConfigAbstractFactory::class,
Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class,
Command\Visit\GetOrphanVisitsCommand::class => ConfigAbstractFactory::class,
Command\Visit\GetNonOrphanVisitsCommand::class => ConfigAbstractFactory::class,
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class,
@@ -55,12 +58,14 @@ return [
Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class,
Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class,
Command\Tag\DeleteTagsCommand::class => ConfigAbstractFactory::class,
Command\Tag\GetTagVisitsCommand::class => ConfigAbstractFactory::class,
Command\Db\CreateDatabaseCommand::class => ConfigAbstractFactory::class,
Command\Db\MigrateDatabaseCommand::class => ConfigAbstractFactory::class,
Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class,
Command\Domain\DomainRedirectsCommand::class => ConfigAbstractFactory::class,
Command\Domain\GetDomainVisitsCommand::class => ConfigAbstractFactory::class,
],
],
@@ -72,20 +77,19 @@ return [
TrackingOptions::class,
],
Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class],
ApiKey\RoleResolver::class => [DomainService::class],
ApiKey\RoleResolver::class => [DomainService::class, 'config.url_shortener.domain.hostname'],
Command\ShortUrl\CreateShortUrlCommand::class => [
Service\UrlShortener::class,
ShortUrlStringifier::class,
'config.url_shortener.default_short_codes_length',
'config.url_shortener.domain.hostname',
UrlShortenerOptions::class,
],
Command\ShortUrl\ResolveUrlCommand::class => [Service\ShortUrl\ShortUrlResolver::class],
Command\ShortUrl\ListShortUrlsCommand::class => [
Service\ShortUrlService::class,
ShortUrlDataTransformer::class,
],
Command\ShortUrl\GetVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
Command\Visit\DownloadGeoLiteDbCommand::class => [Util\GeolocationDbUpdater::class],
@@ -94,6 +98,8 @@ return [
IpLocationResolverInterface::class,
LockFactory::class,
],
Command\Visit\GetOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\Visit\GetNonOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
@@ -102,9 +108,11 @@ return [
Command\Tag\ListTagsCommand::class => [TagService::class],
Command\Tag\RenameTagCommand::class => [TagService::class],
Command\Tag\DeleteTagsCommand::class => [TagService::class],
Command\Tag\GetTagVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
Command\Domain\ListDomainsCommand::class => [DomainService::class],
Command\Domain\DomainRedirectsCommand::class => [DomainService::class],
Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
Command\Db\CreateDatabaseCommand::class => [
LockFactory::class,

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\ApiKey;
use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Symfony\Component\Console\Input\InputInterface;
@@ -12,24 +13,33 @@ use function is_string;
class RoleResolver implements RoleResolverInterface
{
public function __construct(private DomainServiceInterface $domainService)
public function __construct(private DomainServiceInterface $domainService, private string $defaultDomain)
{
}
public function determineRoles(InputInterface $input): array
{
$domainAuthority = $input->getOption('domain-only');
$author = $input->getOption('author-only');
$domainAuthority = $input->getOption(self::DOMAIN_ONLY_PARAM);
$author = $input->getOption(self::AUTHOR_ONLY_PARAM);
$roleDefinitions = [];
if ($author) {
$roleDefinitions[] = RoleDefinition::forAuthoredShortUrls();
}
if (is_string($domainAuthority)) {
$domain = $this->domainService->getOrCreate($domainAuthority);
$roleDefinitions[] = RoleDefinition::forDomain($domain);
$roleDefinitions[] = $this->resolveRoleForAuthority($domainAuthority);
}
return $roleDefinitions;
}
private function resolveRoleForAuthority(string $domainAuthority): RoleDefinition
{
if ($domainAuthority === $this->defaultDomain) {
throw InvalidRoleConfigException::forDomainOnlyWithDefaultDomain();
}
$domain = $this->domainService->getOrCreate($domainAuthority);
return RoleDefinition::forDomain($domain);
}
}

View File

@@ -73,13 +73,16 @@ class GenerateKeyCommand extends Command
$authorOnly,
'a',
InputOption::VALUE_NONE,
sprintf('Adds the "%s" role to the new API key.', Role::AUTHORED_SHORT_URLS),
sprintf('Adds the "%s" role to the new API key.', Role::AUTHORED_SHORT_URLS->value),
)
->addOption(
$domainOnly,
'd',
InputOption::VALUE_REQUIRED,
sprintf('Adds the "%s" role to the new API key, with the domain provided.', Role::DOMAIN_SPECIFIC),
sprintf(
'Adds the "%s" role to the new API key, with the domain provided.',
Role::DOMAIN_SPECIFIC->value,
),
)
->setHelp($help);
}
@@ -99,7 +102,7 @@ class GenerateKeyCommand extends Command
if (! $apiKey->isAdmin()) {
ShlinkTable::default($io)->render(
['Role name', 'Role metadata'],
$apiKey->mapRoles(fn (string $name, array $meta) => [$name, arrayToString($meta, 0)]),
$apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, 0)]),
null,
'Roles',
);

View File

@@ -60,10 +60,10 @@ class ListKeysCommand extends Command
}
$rowData[] = $expiration?->toAtomString() ?? '-';
$rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles(
fn (string $roleName, array $meta) =>
fn (Role $role, array $meta) =>
empty($meta)
? Role::toFriendlyName($roleName)
: sprintf('%s: %s', Role::toFriendlyName($roleName), Role::domainAuthorityFromMeta($meta)),
? Role::toFriendlyName($role)
: sprintf('%s: %s', Role::toFriendlyName($role), Role::domainAuthorityFromMeta($meta)),
));
return $rowData;

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Db;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\SqlitePlatform;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
use Symfony\Component\Console\Input\InputInterface;
@@ -14,6 +15,9 @@ use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Process\PhpExecutableFinder;
use function Functional\contains;
use function Functional\filter;
use const Shlinkio\Shlink\MIGRATIONS_TABLE;
class CreateDatabaseCommand extends AbstractDatabaseCommand
{
@@ -62,7 +66,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
private function checkDbExists(): void
{
if ($this->regularConn->getDatabasePlatform()->getName() === 'sqlite') {
if ($this->regularConn->getDriver()->getDatabasePlatform() instanceof SqlitePlatform) {
return;
}
@@ -70,7 +74,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
// Otherwise, it will fail to connect and will not be able to create the new database
$schemaManager = $this->noDbNameConn->createSchemaManager();
$databases = $schemaManager->listDatabases();
$shlinkDatabase = $this->regularConn->getDatabase();
$shlinkDatabase = $this->regularConn->getParams()['dbname'] ?? null;
if ($shlinkDatabase !== null && ! contains($databases, $shlinkDatabase)) {
$schemaManager->createDatabase($shlinkDatabase);
@@ -80,8 +84,9 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
private function schemaExists(): bool
{
// If at least one of the shlink tables exist, we will consider the database exists somehow.
// Any inconsistency should be taken care by the migrations
// We exclude the migrations table, in case db:migrate was run first by mistake.
// Any other inconsistency will be taken care by the migrations.
$schemaManager = $this->regularConn->createSchemaManager();
return ! empty($schemaManager->listTableNames());
return ! empty(filter($schemaManager->listTableNames(), fn (string $table) => $table !== MIGRATIONS_TABLE));
}
}

View File

@@ -53,7 +53,7 @@ class DomainRedirectsCommand extends Command
/** @var string[] $availableDomains */
$availableDomains = invoke(
filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault()),
filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault),
'toString',
);
if (empty($availableDomains)) {

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
class GetDomainVisitsCommand extends AbstractVisitsListCommand
{
public const NAME = 'domain:visits';
public function __construct(
VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
parent::__construct($visitsHelper);
}
protected function doConfigure(): void
{
$this
->setName(self::NAME)
->setDescription('Returns the list of visits for provided domain.')
->addArgument('domain', InputArgument::REQUIRED, 'The domain which visits we want to get.');
}
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$domain = $input->getArgument('domain');
return $this->visitsHelper->visitsForDomain($domain, new VisitsParams($dateRange));
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->getShortUrl();
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
}
}

View File

@@ -48,12 +48,12 @@ class ListDomainsCommand extends Command
$table->render(
$showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields,
map($domains, function (DomainItem $domain) use ($showRedirects) {
$commonValues = [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No'];
$commonValues = [$domain->toString(), $domain->isDefault ? 'Yes' : 'No'];
return $showRedirects
? [
...$commonValues,
$this->notFoundRedirectsToString($domain->notFoundRedirectConfig()),
$this->notFoundRedirectsToString($domain->notFoundRedirectConfig),
]
: $commonValues;
}),

View File

@@ -5,9 +5,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter;
@@ -19,6 +21,7 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_map;
use function explode;
use function Functional\curry;
use function Functional\flatten;
use function Functional\unique;
@@ -29,14 +32,15 @@ class CreateShortUrlCommand extends Command
public const NAME = 'short-url:create';
private ?SymfonyStyle $io;
private string $defaultDomain;
public function __construct(
private UrlShortenerInterface $urlShortener,
private ShortUrlStringifierInterface $stringifier,
private int $defaultShortCodeLength,
private string $defaultDomain,
private readonly UrlShortenerInterface $urlShortener,
private readonly ShortUrlStringifierInterface $stringifier,
private readonly UrlShortenerOptions $options,
) {
parent::__construct();
$this->defaultDomain = $this->options->domain()['hostname'] ?? '';
}
protected function configure(): void
@@ -150,11 +154,11 @@ class CreateShortUrlCommand extends Command
return ExitCodes::EXIT_FAILURE;
}
$explodeWithComma = curry('explode')(',');
$explodeWithComma = curry(explode(...))(',');
$tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
$customSlug = $input->getOption('custom-slug');
$maxVisits = $input->getOption('max-visits');
$shortCodeLength = $input->getOption('short-code-length') ?? $this->defaultShortCodeLength;
$shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength();
$doValidateUrl = $input->getOption('validate-url');
try {
@@ -171,6 +175,7 @@ class CreateShortUrlCommand extends Command
ShortUrlInputFilter::TAGS => $tags,
ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'),
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $this->options->multiSegmentSlugsEnabled(),
]));
$io->writeln([

View File

@@ -81,6 +81,6 @@ class DeleteShortUrlCommand extends Command
private function runDelete(SymfonyStyle $io, ShortUrlIdentifier $identifier, bool $ignoreThreshold): void
{
$this->deleteShortUrlService->deleteByShortCode($identifier, $ignoreThreshold);
$io->success(sprintf('Short URL with short code "%s" successfully deleted.', $identifier->shortCode()));
$io->success(sprintf('Short URL with short code "%s" successfully deleted.', $identifier->shortCode));
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
{
public const NAME = 'short-url:visits';
protected function doConfigure(): void
{
$this
->setName(self::NAME)
->setDescription('Returns the detailed visits information for provided short code')
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get.')
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code.');
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$shortCode = $input->getArgument('shortCode');
if (! empty($shortCode)) {
return;
}
$io = new SymfonyStyle($input, $output);
$shortCode = $io->ask('A short code was not provided. Which short code do you want to use?');
if (! empty($shortCode)) {
$input->setArgument('shortCode', $shortCode);
}
}
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$identifier = ShortUrlIdentifier::fromCli($input);
return $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange));
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
return [];
}
}

View File

@@ -1,88 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function Functional\map;
use function Functional\select_keys;
use function Shlinkio\Shlink\Common\buildDateRange;
use function sprintf;
class GetVisitsCommand extends AbstractWithDateRangeCommand
{
public const NAME = 'short-url:visits';
public function __construct(private VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct();
}
protected function doConfigure(): void
{
$this
->setName(self::NAME)
->setDescription('Returns the detailed visits information for provided short code')
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get.')
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code.');
}
protected function getStartDateDesc(string $optionName): string
{
return sprintf('Allows to filter visits, returning only those older than "%s".', $optionName);
}
protected function getEndDateDesc(string $optionName): string
{
return sprintf('Allows to filter visits, returning only those newer than "%s".', $optionName);
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$shortCode = $input->getArgument('shortCode');
if (! empty($shortCode)) {
return;
}
$io = new SymfonyStyle($input, $output);
$shortCode = $io->ask('A short code was not provided. Which short code do you want to use?');
if (! empty($shortCode)) {
$input->setArgument('shortCode', $shortCode);
}
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$identifier = ShortUrlIdentifier::fromCli($input);
$startDate = $this->getStartDateOption($input, $output);
$endDate = $this->getEndDateOption($input, $output);
$paginator = $this->visitsHelper->visitsForShortUrl(
$identifier,
new VisitsParams(buildDateRange($startDate, $endDate)),
);
$rows = map($paginator->getCurrentPageResults(), function (Visit $visit) {
$rowData = $visit->jsonSerialize();
$rowData['country'] = ($visit->getVisitLocation() ?? new UnknownVisitLocation())->getCountryName();
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
});
ShlinkTable::default($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
return ExitCodes::EXIT_SUCCESS;
}
}

View File

@@ -13,6 +13,7 @@ use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@@ -120,9 +121,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
$page = (int) $input->getOption('page');
$searchTerm = $input->getOption('search-term');
$tags = $input->getOption('tags');
$tagsMode = $input->getOption('including-all-tags') === true
? ShortUrlsParams::TAGS_MODE_ALL
: ShortUrlsParams::TAGS_MODE_ANY;
$tagsMode = $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
$tags = ! empty($tags) ? explode(',', $tags) : [];
$all = $input->getOption('all');
$startDate = $this->getStartDateOption($input, $output);
@@ -209,7 +208,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
}
if ($input->getOption('show-api-key')) {
$columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string =>
(string) $shortUrl->authorApiKey();
$shortUrl->authorApiKey()?->__toString() ?? '';
}
if ($input->getOption('show-api-key-name')) {
$columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): ?string =>

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
class GetTagVisitsCommand extends AbstractVisitsListCommand
{
public const NAME = 'tag:visits';
public function __construct(
VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
parent::__construct($visitsHelper);
}
protected function doConfigure(): void
{
$this
->setName(self::NAME)
->setDescription('Returns the list of visits for provided tag.')
->addArgument('tag', InputArgument::REQUIRED, 'The tag which visits we want to get.');
}
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$tag = $input->getArgument('tag');
return $this->visitsHelper->visitsForTag($tag, new VisitsParams($dateRange));
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->getShortUrl();
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
}
}

View File

@@ -46,7 +46,7 @@ class ListTagsCommand extends Command
return map(
$tags,
static fn (TagInfo $tagInfo) => [$tagInfo->tag(), $tagInfo->shortUrlsCount(), $tagInfo->visitsCount()],
static fn (TagInfo $tagInfo) => [$tagInfo->tag, $tagInfo->shortUrlsCount, $tagInfo->visitsCount],
);
}
}

View File

@@ -19,7 +19,7 @@ class RenameTagCommand extends Command
{
public const NAME = 'tag:rename';
public function __construct(private TagServiceInterface $tagService)
public function __construct(private readonly TagServiceInterface $tagService)
{
parent::__construct();
}

View File

@@ -14,7 +14,7 @@ use function sprintf;
abstract class AbstractLockedCommand extends Command
{
public function __construct(private LockFactory $locker)
public function __construct(private readonly LockFactory $locker)
{
parent::__construct();
}
@@ -22,11 +22,11 @@ abstract class AbstractLockedCommand extends Command
final protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$lockConfig = $this->getLockConfig();
$lock = $this->locker->createLock($lockConfig->lockName(), $lockConfig->ttl(), $lockConfig->isBlocking());
$lock = $this->locker->createLock($lockConfig->lockName, $lockConfig->ttl, $lockConfig->isBlocking);
if (! $lock->acquire($lockConfig->isBlocking())) {
if (! $lock->acquire($lockConfig->isBlocking)) {
$output->writeln(
sprintf('<comment>Command "%s" is already in progress. Skipping.</comment>', $lockConfig->lockName()),
sprintf('<comment>Command "%s" is already in progress. Skipping.</comment>', $lockConfig->lockName),
);
return ExitCodes::EXIT_WARNING;
}

View File

@@ -9,9 +9,9 @@ final class LockedCommandConfig
public const DEFAULT_TTL = 600.0; // 10 minutes
private function __construct(
private string $lockName,
private bool $isBlocking,
private float $ttl = self::DEFAULT_TTL,
public readonly string $lockName,
public readonly bool $isBlocking,
public readonly float $ttl = self::DEFAULT_TTL,
) {
}
@@ -24,19 +24,4 @@ final class LockedCommandConfig
{
return new self($lockName, false);
}
public function lockName(): string
{
return $this->lockName;
}
public function isBlocking(): bool
{
return $this->isBlocking;
}
public function ttl(): float
{
return $this->ttl;
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function array_keys;
use function Functional\map;
use function Functional\select_keys;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly;
use function sprintf;
abstract class AbstractVisitsListCommand extends AbstractWithDateRangeCommand
{
public function __construct(protected readonly VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct();
}
final protected function getStartDateDesc(string $optionName): string
{
return sprintf('Allows to filter visits, returning only those older than "%s".', $optionName);
}
final protected function getEndDateDesc(string $optionName): string
{
return sprintf('Allows to filter visits, returning only those newer than "%s".', $optionName);
}
final protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$startDate = $this->getStartDateOption($input, $output);
$endDate = $this->getEndDateOption($input, $output);
$paginator = $this->getVisitsPaginator($input, buildDateRange($startDate, $endDate));
[$rows, $headers] = $this->resolveRowsAndHeaders($paginator);
ShlinkTable::default($output)->render($headers, $rows);
return ExitCodes::EXIT_SUCCESS;
}
private function resolveRowsAndHeaders(Paginator $paginator): array
{
$extraKeys = [];
$rows = map($paginator->getCurrentPageResults(), function (Visit $visit) use (&$extraKeys) {
$extraFields = $this->mapExtraFields($visit);
$extraKeys = array_keys($extraFields);
$rowData = [
...$visit->jsonSerialize(),
'country' => $visit->getVisitLocation()?->getCountryName() ?? 'Unknown',
'city' => $visit->getVisitLocation()?->getCityName() ?? 'Unknown',
...$extraFields,
];
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]);
});
$extra = map($extraKeys, camelCaseToHumanFriendly(...));
return [
$rows,
['Referer', 'Date', 'User agent', 'Country', 'City', ...$extra],
];
}
abstract protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator;
/**
* @return array<string, string>
*/
abstract protected function mapExtraFields(Visit $visit): array;
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputInterface;
class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
{
public const NAME = 'visit:non-orphan';
public function __construct(
VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
parent::__construct($visitsHelper);
}
protected function doConfigure(): void
{
$this
->setName(self::NAME)
->setDescription('Returns the list of non-orphan visits.');
}
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
return $this->visitsHelper->nonOrphanVisits(new VisitsParams($dateRange));
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->getShortUrl();
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Symfony\Component\Console\Input\InputInterface;
class GetOrphanVisitsCommand extends AbstractVisitsListCommand
{
public const NAME = 'visit:orphan';
protected function doConfigure(): void
{
$this
->setName(self::NAME)
->setDescription('Returns the list of orphan visits.');
}
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
return $this->visitsHelper->orphanVisits(new VisitsParams($dateRange));
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
return ['type' => $visit->type()->value];
}
}

View File

@@ -17,6 +17,7 @@ use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
@@ -80,12 +81,12 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
);
}
if ($all && $retry && ! $this->warnAndVerifyContinue($input)) {
if ($all && $retry && ! $this->warnAndVerifyContinue()) {
throw new RuntimeException('Execution aborted');
}
}
private function warnAndVerifyContinue(InputInterface $input): bool
private function warnAndVerifyContinue(): bool
{
$this->io->warning([
'You are about to process the location of all existing visits your short URLs received.',
@@ -103,7 +104,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
$all = $retry && $input->getOption('all');
try {
$this->checkDbUpdate($input);
$this->checkDbUpdate();
if ($all) {
$this->visitLocator->locateAllVisits($this);
@@ -166,7 +167,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
$this->io->writeln($message);
}
private function checkDbUpdate(InputInterface $input): void
private function checkDbUpdate(): void
{
$cliApp = $this->getApplication();
if ($cliApp === null) {
@@ -174,7 +175,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
}
$downloadDbCommand = $cliApp->find(DownloadGeoLiteDbCommand::NAME);
$exitCode = $downloadDbCommand->run($input, $this->io);
$exitCode = $downloadDbCommand->run(new ArrayInput([]), $this->io);
if ($exitCode === ExitCodes::EXIT_FAILURE) {
throw new RuntimeException('It is not possible to locate visits without a GeoLite2 db file.');

View File

@@ -13,7 +13,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
{
private bool $olderDbExists;
private function __construct(string $message, int $code = 0, ?Throwable $previous = null)
private function __construct(string $message, int $code, ?Throwable $previous)
{
parent::__construct($message, $code, $previous);
}
@@ -47,7 +47,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
$e = new self(sprintf(
'Build epoch with value "%s" from existing geolocation database, could not be parsed to integer.',
$buildEpoch,
));
), 0, null);
$e->olderDbExists = true;
return $e;

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Exception;
use InvalidArgumentException;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use function sprintf;
class InvalidRoleConfigException extends InvalidArgumentException implements ExceptionInterface
{
public static function forDomainOnlyWithDefaultDomain(): self
{
return new self(sprintf(
'You cannot create an API key with the "%s" role attached to the default domain. '
. 'The role is currently limited to non-default domains.',
Role::DOMAIN_SPECIFIC->value,
));
}
}

View File

@@ -66,9 +66,8 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
{
$buildTimestamp = $this->resolveBuildTimestamp($meta);
$buildDate = Chronos::createFromTimestamp($buildTimestamp);
$now = Chronos::now();
return $now->gt($buildDate->addDays(35));
return Chronos::now()->gt($buildDate->addDays(35));
}
private function resolveBuildTimestamp(Metadata $meta): int

View File

@@ -15,7 +15,7 @@ final class ShlinkTable
private const DEFAULT_STYLE_NAME = 'default';
private const TABLE_TITLE_STYLE = '<options=bold> %s </>';
private function __construct(private Table $baseTable, private bool $withRowSeparators)
private function __construct(private readonly Table $baseTable, private readonly bool $withRowSeparators)
{
}

View File

@@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolver;
use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
@@ -23,7 +24,7 @@ class RoleResolverTest extends TestCase
protected function setUp(): void
{
$this->domainService = $this->prophesize(DomainServiceInterface::class);
$this->resolver = new RoleResolver($this->domainService->reveal());
$this->resolver = new RoleResolver($this->domainService->reveal(), 'default.com');
}
/**
@@ -94,4 +95,16 @@ class RoleResolverTest extends TestCase
1,
];
}
/** @test */
public function exceptionIsThrownWhenTryingToAddDomainOnlyLinkedToDefaultDomain(): void
{
$input = $this->prophesize(InputInterface::class);
$input->getOption(RoleResolver::DOMAIN_ONLY_PARAM)->willReturn('default.com');
$input->getOption(RoleResolver::AUTHOR_ONLY_PARAM)->willReturn(null);
$this->expectException(InvalidRoleConfigException::class);
$this->resolver->determineRoles($input->reveal());
}
}

View File

@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Db;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\SqlitePlatform;
use Doctrine\DBAL\Schema\AbstractSchemaManager;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
@@ -19,6 +21,8 @@ use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\Process\PhpExecutableFinder;
use const Shlinkio\Shlink\MIGRATIONS_TABLE;
class CreateDatabaseCommandTest extends TestCase
{
use CliTestUtilsTrait;
@@ -27,7 +31,7 @@ class CreateDatabaseCommandTest extends TestCase
private ObjectProphecy $processHelper;
private ObjectProphecy $regularConn;
private ObjectProphecy $schemaManager;
private ObjectProphecy $databasePlatform;
private ObjectProphecy $driver;
public function setUp(): void
{
@@ -43,11 +47,12 @@ class CreateDatabaseCommandTest extends TestCase
$this->processHelper = $this->prophesize(ProcessRunnerInterface::class);
$this->schemaManager = $this->prophesize(AbstractSchemaManager::class);
$this->databasePlatform = $this->prophesize(AbstractPlatform::class);
$this->regularConn = $this->prophesize(Connection::class);
$this->regularConn->createSchemaManager()->willReturn($this->schemaManager->reveal());
$this->regularConn->getDatabasePlatform()->willReturn($this->databasePlatform->reveal());
$this->driver = $this->prophesize(Driver::class);
$this->regularConn->getDriver()->willReturn($this->driver->reveal());
$this->driver->getDatabasePlatform()->willReturn($this->prophesize(AbstractPlatform::class)->reveal());
$noDbNameConn = $this->prophesize(Connection::class);
$noDbNameConn->createSchemaManager()->willReturn($this->schemaManager->reveal());
@@ -66,7 +71,7 @@ class CreateDatabaseCommandTest extends TestCase
public function successMessageIsPrintedIfDatabaseAlreadyExists(): void
{
$shlinkDatabase = 'shlink_database';
$getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase);
$getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]);
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', $shlinkDatabase, 'bar']);
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
});
@@ -86,11 +91,11 @@ class CreateDatabaseCommandTest extends TestCase
public function databaseIsCreatedIfItDoesNotExist(): void
{
$shlinkDatabase = 'shlink_database';
$getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase);
$getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]);
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', 'bar']);
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
});
$listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table']);
$listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table', MIGRATIONS_TABLE]);
$this->commandTester->execute([]);
@@ -100,15 +105,18 @@ class CreateDatabaseCommandTest extends TestCase
$listTables->shouldHaveBeenCalledOnce();
}
/** @test */
public function tablesAreCreatedIfDatabaseIsEmpty(): void
/**
* @test
* @dataProvider provideEmptyDatabase
*/
public function tablesAreCreatedIfDatabaseIsEmpty(array $tables): void
{
$shlinkDatabase = 'shlink_database';
$getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase);
$getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]);
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', $shlinkDatabase, 'bar']);
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
});
$listTables = $this->schemaManager->listTableNames()->willReturn([]);
$listTables = $this->schemaManager->listTableNames()->willReturn($tables);
$runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [
'/usr/local/bin/php',
CreateDatabaseCommand::DOCTRINE_SCRIPT,
@@ -128,13 +136,19 @@ class CreateDatabaseCommandTest extends TestCase
$runCommand->shouldHaveBeenCalledOnce();
}
public function provideEmptyDatabase(): iterable
{
yield 'no tables' => [[]];
yield 'migrations table' => [[MIGRATIONS_TABLE]];
}
/** @test */
public function databaseCheckIsSkippedForSqlite(): void
{
$this->databasePlatform->getName()->willReturn('sqlite');
$this->driver->getDatabasePlatform()->willReturn($this->prophesize(SqlitePlatform::class)->reveal());
$shlinkDatabase = 'shlink_database';
$getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase);
$getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]);
$listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', 'bar']);
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
});

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Domain;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Domain\GetDomainVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester;
class GetDomainVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $visitsHelper;
private ObjectProphecy $stringifier;
protected function setUp(): void
{
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class);
$this->commandTester = $this->testerForCommand(
new GetDomainVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()),
);
}
/** @test */
public function outputIsProperlyGenerated(): void
{
$shortUrl = ShortUrl::createEmpty();
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$domain = 'doma.in';
$getVisits = $this->visitsHelper->visitsForDomain($domain, Argument::any())->willReturn(
new Paginator(new ArrayAdapter([$visit])),
);
$stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url');
$this->commandTester->execute(['domain' => $domain]);
$output = $this->commandTester->getDisplay();
self::assertEquals(
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+---------------------------+------------+---------+--------+---------------+
OUTPUT,
$output,
);
$getVisits->shouldHaveBeenCalledOnce();
$stringify->shouldHaveBeenCalledOnce();
}
}

View File

@@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
@@ -38,8 +39,7 @@ class CreateShortUrlCommandTest extends TestCase
$command = new CreateShortUrlCommand(
$this->urlShortener->reveal(),
$this->stringifier->reveal(),
5,
self::DEFAULT_DOMAIN,
new UrlShortenerOptions(['defaultShortCodesLength' => 5, 'domain' => ['hostname' => self::DEFAULT_DOMAIN]]),
);
$this->commandTester = $this->testerForCommand($command);
}

View File

@@ -36,10 +36,11 @@ class DeleteShortUrlCommandTest extends TestCase
public function successMessageIsPrintedIfUrlIsProperlyDeleted(): void
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode(new ShortUrlIdentifier($shortCode), false)->will(
function (): void {
},
);
$deleteByShortCode = $this->service->deleteByShortCode(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
false,
)->will(function (): void {
});
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
@@ -55,7 +56,7 @@ class DeleteShortUrlCommandTest extends TestCase
public function invalidShortCodePrintsMessage(): void
{
$shortCode = 'abc123';
$identifier = new ShortUrlIdentifier($shortCode);
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode);
$deleteByShortCode = $this->service->deleteByShortCode($identifier, false)->willThrow(
Exception\ShortUrlNotFoundException::fromNotFound($identifier),
);
@@ -77,7 +78,7 @@ class DeleteShortUrlCommandTest extends TestCase
string $expectedMessage,
): void {
$shortCode = 'abc123';
$identifier = new ShortUrlIdentifier($shortCode);
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode);
$deleteByShortCode = $this->service->deleteByShortCode($identifier, Argument::type('bool'))->will(
function (array $args) use ($shortCode): void {
$ignoreThreshold = array_pop($args);
@@ -114,12 +115,13 @@ class DeleteShortUrlCommandTest extends TestCase
public function deleteIsNotRetriedWhenThresholdIsReachedAndQuestionIsDeclined(): void
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode(new ShortUrlIdentifier($shortCode), false)->willThrow(
Exception\DeleteShortUrlException::fromVisitsThreshold(
10,
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
),
);
$deleteByShortCode = $this->service->deleteByShortCode(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
false,
)->willThrow(Exception\DeleteShortUrlException::fromVisitsThreshold(
10,
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
));
$this->commandTester->setInputs(['no']);
$this->commandTester->execute(['shortCode' => $shortCode]);

View File

@@ -9,7 +9,7 @@ use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GetShortUrlVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
@@ -23,9 +23,10 @@ use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester;
use function Shlinkio\Shlink\Common\buildDateRange;
use function sprintf;
class GetVisitsCommandTest extends TestCase
class GetShortUrlVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
@@ -35,7 +36,7 @@ class GetVisitsCommandTest extends TestCase
public function setUp(): void
{
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$command = new GetVisitsCommand($this->visitsHelper->reveal());
$command = new GetShortUrlVisitsCommand($this->visitsHelper->reveal());
$this->commandTester = $this->testerForCommand($command);
}
@@ -44,8 +45,8 @@ class GetVisitsCommandTest extends TestCase
{
$shortCode = 'abc123';
$this->visitsHelper->visitsForShortUrl(
new ShortUrlIdentifier($shortCode),
new VisitsParams(DateRange::emptyInstance()),
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
new VisitsParams(DateRange::allTime()),
)
->willReturn(new Paginator(new ArrayAdapter([])))
->shouldBeCalledOnce();
@@ -60,8 +61,8 @@ class GetVisitsCommandTest extends TestCase
$startDate = '2016-01-01';
$endDate = '2016-02-01';
$this->visitsHelper->visitsForShortUrl(
new ShortUrlIdentifier($shortCode),
new VisitsParams(DateRange::withStartAndEndDate(Chronos::parse($startDate), Chronos::parse($endDate))),
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
new VisitsParams(buildDateRange(Chronos::parse($startDate), Chronos::parse($endDate))),
)
->willReturn(new Paginator(new ArrayAdapter([])))
->shouldBeCalledOnce();
@@ -79,8 +80,8 @@ class GetVisitsCommandTest extends TestCase
$shortCode = 'abc123';
$startDate = 'foo';
$info = $this->visitsHelper->visitsForShortUrl(
new ShortUrlIdentifier($shortCode),
new VisitsParams(DateRange::emptyInstance()),
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
new VisitsParams(DateRange::allTime()),
)->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute([
@@ -99,19 +100,30 @@ class GetVisitsCommandTest extends TestCase
/** @test */
public function outputIsProperlyGenerated(): void
{
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$shortCode = 'abc123';
$this->visitsHelper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn(
new Paginator(new ArrayAdapter([
Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', '', 0, 0, '')),
),
])),
$this->visitsHelper->visitsForShortUrl(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
Argument::any(),
)->willReturn(
new Paginator(new ArrayAdapter([$visit])),
)->shouldBeCalledOnce();
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('foo', $output);
self::assertStringContainsString('Spain', $output);
self::assertStringContainsString('bar', $output);
self::assertEquals(
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+
| Referer | Date | User agent | Country | City |
+---------+---------------------------+------------+---------+--------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid |
+---------+---------------------------+------------+---------+--------+
OUTPUT,
$output,
);
}
}

View File

@@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@@ -205,23 +206,23 @@ class ListShortUrlsCommandTest extends TestCase
public function provideArgs(): iterable
{
yield [[], 1, null, [], ShortUrlsParams::TAGS_MODE_ANY];
yield [['--page' => $page = 3], $page, null, [], ShortUrlsParams::TAGS_MODE_ANY];
yield [['--including-all-tags' => true], 1, null, [], ShortUrlsParams::TAGS_MODE_ALL];
yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], ShortUrlsParams::TAGS_MODE_ANY];
yield [[], 1, null, [], TagsMode::ANY->value];
yield [['--page' => $page = 3], $page, null, [], TagsMode::ANY->value];
yield [['--including-all-tags' => true], 1, null, [], TagsMode::ALL->value];
yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], TagsMode::ANY->value];
yield [
['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'],
$page,
$searchTerm,
explode(',', $tags),
ShortUrlsParams::TAGS_MODE_ANY,
TagsMode::ANY->value,
];
yield [
['--start-date' => $startDate = '2019-01-01'],
1,
null,
[],
ShortUrlsParams::TAGS_MODE_ANY,
TagsMode::ANY->value,
$startDate,
];
yield [
@@ -229,7 +230,7 @@ class ListShortUrlsCommandTest extends TestCase
1,
null,
[],
ShortUrlsParams::TAGS_MODE_ANY,
TagsMode::ANY->value,
null,
$endDate,
];
@@ -238,7 +239,7 @@ class ListShortUrlsCommandTest extends TestCase
1,
null,
[],
ShortUrlsParams::TAGS_MODE_ANY,
TagsMode::ANY->value,
$startDate,
$endDate,
];
@@ -276,7 +277,7 @@ class ListShortUrlsCommandTest extends TestCase
'page' => 1,
'searchTerm' => null,
'tags' => [],
'tagsMode' => ShortUrlsParams::TAGS_MODE_ANY,
'tagsMode' => TagsMode::ANY->value,
'startDate' => null,
'endDate' => null,
'orderBy' => null,

View File

@@ -37,8 +37,9 @@ class ResolveUrlCommandTest extends TestCase
$shortCode = 'abc123';
$expectedUrl = 'http://domain.com/foo/bar';
$shortUrl = ShortUrl::withLongUrl($expectedUrl);
$this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn($shortUrl)
->shouldBeCalledOnce();
$this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode))->willReturn(
$shortUrl,
)->shouldBeCalledOnce();
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
@@ -48,8 +49,8 @@ class ResolveUrlCommandTest extends TestCase
/** @test */
public function incorrectShortCodeOutputsErrorMessage(): void
{
$identifier = new ShortUrlIdentifier('abc123');
$shortCode = $identifier->shortCode();
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain('abc123');
$shortCode = $identifier->shortCode;
$this->urlResolver->resolveShortUrl($identifier)
->willThrow(ShortUrlNotFoundException::fromNotFound($identifier))

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\GetTagVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester;
class GetTagVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $visitsHelper;
private ObjectProphecy $stringifier;
protected function setUp(): void
{
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class);
$this->commandTester = $this->testerForCommand(
new GetTagVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()),
);
}
/** @test */
public function outputIsProperlyGenerated(): void
{
$shortUrl = ShortUrl::createEmpty();
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$tag = 'abc123';
$getVisits = $this->visitsHelper->visitsForTag($tag, Argument::any())->willReturn(
new Paginator(new ArrayAdapter([$visit])),
);
$stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url');
$this->commandTester->execute(['tag' => $tag]);
$output = $this->commandTester->getDisplay();
self::assertEquals(
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+---------------------------+------------+---------+--------+---------------+
OUTPUT,
$output,
);
$getVisits->shouldHaveBeenCalledOnce();
$stringify->shouldHaveBeenCalledOnce();
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\GetNonOrphanVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester;
class GetNonOrphanVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $visitsHelper;
private ObjectProphecy $stringifier;
protected function setUp(): void
{
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class);
$this->commandTester = $this->testerForCommand(
new GetNonOrphanVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()),
);
}
/** @test */
public function outputIsProperlyGenerated(): void
{
$shortUrl = ShortUrl::createEmpty();
$visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$getVisits = $this->visitsHelper->nonOrphanVisits(Argument::any())->willReturn(
new Paginator(new ArrayAdapter([$visit])),
);
$stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url');
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
self::assertEquals(
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+---------------------------+------------+---------+--------+---------------+
OUTPUT,
$output,
);
$getVisits->shouldHaveBeenCalledOnce();
$stringify->shouldHaveBeenCalledOnce();
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\GetOrphanVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester;
class GetOrphanVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $visitsHelper;
protected function setUp(): void
{
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$this->commandTester = $this->testerForCommand(new GetOrphanVisitsCommand($this->visitsHelper->reveal()));
}
/** @test */
public function outputIsProperlyGenerated(): void
{
$visit = Visit::forBasePath(new Visitor('bar', 'foo', '', ''))->locate(
VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')),
);
$getVisits = $this->visitsHelper->orphanVisits(Argument::any())->willReturn(
new Paginator(new ArrayAdapter([$visit])),
);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
self::assertEquals(
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+----------+
| Referer | Date | User agent | Country | City | Type |
+---------+---------------------------+------------+---------+--------+----------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | base_url |
+---------+---------------------------+------------+---------+--------+----------+
OUTPUT,
$output,
);
$getVisits->shouldHaveBeenCalledOnce();
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use function sprintf;
class InvalidRoleConfigExceptionTest extends TestCase
{
/** @test */
public function forDomainOnlyWithDefaultDomainGeneratesExpectedException(): void
{
$e = InvalidRoleConfigException::forDomainOnlyWithDefaultDomain();
self::assertEquals(sprintf(
'You cannot create an API key with the "%s" role attached to the default domain. '
. 'The role is currently limited to non-default domains.',
Role::DOMAIN_SPECIFIC->value,
), $e->getMessage());
}
}

View File

@@ -30,6 +30,7 @@ class GeolocationDbUpdaterTest extends TestCase
private ObjectProphecy $dbUpdater;
private ObjectProphecy $geoLiteDbReader;
private TrackingOptions $trackingOptions;
private ObjectProphecy $lock;
public function setUp(): void
{
@@ -38,11 +39,11 @@ class GeolocationDbUpdaterTest extends TestCase
$this->trackingOptions = new TrackingOptions();
$locker = $this->prophesize(Lock\LockFactory::class);
$lock = $this->prophesize(Lock\LockInterface::class);
$lock->acquire(true)->willReturn(true);
$lock->release()->will(function (): void {
$this->lock = $this->prophesize(Lock\LockInterface::class);
$this->lock->acquire(true)->willReturn(true);
$this->lock->release()->will(function (): void {
});
$locker->createLock(Argument::type('string'))->willReturn($lock->reveal());
$locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal());
$this->geolocationDbUpdater = new GeolocationDbUpdater(
$this->dbUpdater->reveal(),
@@ -75,6 +76,8 @@ class GeolocationDbUpdaterTest extends TestCase
$fileExists->shouldHaveBeenCalledOnce();
$getMeta->shouldNotHaveBeenCalled();
$download->shouldHaveBeenCalledOnce();
$this->lock->acquire(true)->shouldHaveBeenCalledOnce();
$this->lock->release()->shouldHaveBeenCalledOnce();
}
/**

View File

@@ -27,6 +27,7 @@ return [
Options\UrlShortenerOptions::class => ConfigAbstractFactory::class,
Options\TrackingOptions::class => ConfigAbstractFactory::class,
Options\QrCodeOptions::class => ConfigAbstractFactory::class,
Options\RabbitMqOptions::class => ConfigAbstractFactory::class,
Options\WebhookOptions::class => ConfigAbstractFactory::class,
Service\UrlShortener::class => ConfigAbstractFactory::class,
@@ -63,7 +64,7 @@ return [
ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class,
ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => ConfigAbstractFactory::class,
Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class,
EventDispatcher\PublishingUpdatesGenerator::class => ConfigAbstractFactory::class,
Importer\ImportedLinksProcessor::class => ConfigAbstractFactory::class,
@@ -91,6 +92,7 @@ return [
Options\UrlShortenerOptions::class => ['config.url_shortener'],
Options\TrackingOptions::class => ['config.tracking'],
Options\QrCodeOptions::class => ['config.qr_codes'],
Options\RabbitMqOptions::class => ['config.rabbitmq'],
Options\WebhookOptions::class => ['config.visits_webhooks'],
Service\UrlShortener::class => [
@@ -98,6 +100,7 @@ return [
'em',
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
Service\ShortUrl\ShortCodeUniquenessHelper::class,
EventDispatcherInterface::class,
],
Visit\VisitsTracker::class => [
'em',
@@ -157,7 +160,7 @@ return [
Options\UrlShortenerOptions::class,
],
Mercure\MercureUpdatesGenerator::class => [
EventDispatcher\PublishingUpdatesGenerator::class => [
ShortUrl\Transformer\ShortUrlDataTransformer::class,
Visit\Transformer\OrphanVisitDataTransformer::class,
],

View File

@@ -6,9 +6,11 @@ namespace Shlinkio\Shlink\Core;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\Builder\FieldBuilder;
use Doctrine\ORM\Mapping\ClassMetadata;
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
return static function (ClassMetadata $metadata, array $emConfig): void {
$builder = new ClassMetadataBuilder($metadata);
@@ -61,10 +63,13 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->nullable()
->build();
$builder->createField('type', Types::STRING)
->columnName('type')
->length(255)
->build();
(new FieldBuilder($builder, [
'fieldName' => 'type',
'type' => Types::STRING,
'enumType' => VisitType::class,
]))->columnName('type')
->length(255)
->build();
$builder->createField('potentialBot', Types::BOOLEAN)
->columnName('potential_bot')

View File

@@ -5,12 +5,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use PhpAmqpLib\Connection\AMQPStreamConnection;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\Common\Cache\RedisPublishingHelper;
use Shlinkio\Shlink\Common\Mercure\MercureHubPublishingHelper;
use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelper;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Symfony\Component\Mercure\Hub;
return [
@@ -22,11 +23,17 @@ return [
],
'async' => [
EventDispatcher\Event\VisitLocated::class => [
EventDispatcher\NotifyVisitToMercure::class,
EventDispatcher\NotifyVisitToRabbitMq::class,
EventDispatcher\Mercure\NotifyVisitToMercure::class,
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class,
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class,
EventDispatcher\NotifyVisitToWebHooks::class,
EventDispatcher\UpdateGeoLiteDb::class,
],
EventDispatcher\Event\ShortUrlCreated::class => [
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class,
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class,
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class,
],
],
],
@@ -34,16 +41,32 @@ return [
'factories' => [
EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class,
EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => ConfigAbstractFactory::class,
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class,
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => ConfigAbstractFactory::class,
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => ConfigAbstractFactory::class,
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => ConfigAbstractFactory::class,
EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class,
],
'delegators' => [
EventDispatcher\NotifyVisitToMercure::class => [
EventDispatcher\Mercure\NotifyVisitToMercure::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\NotifyVisitToRabbitMq::class => [
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\NotifyVisitToWebHooks::class => [
@@ -68,18 +91,46 @@ return [
ShortUrl\Transformer\ShortUrlDataTransformer::class,
Options\AppOptions::class,
],
EventDispatcher\NotifyVisitToMercure::class => [
Hub::class,
Mercure\MercureUpdatesGenerator::class,
EventDispatcher\Mercure\NotifyVisitToMercure::class => [
MercureHubPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
],
EventDispatcher\NotifyVisitToRabbitMq::class => [
AMQPStreamConnection::class,
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [
MercureHubPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
],
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [
RabbitMqPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
Visit\Transformer\OrphanVisitDataTransformer::class,
'config.rabbitmq.enabled',
Options\RabbitMqOptions::class,
],
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [
RabbitMqPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
Options\RabbitMqOptions::class,
],
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [
RedisPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
'config.redis.pub_sub_enabled',
],
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [
RedisPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
'config.redis.pub_sub_enabled',
],
EventDispatcher\UpdateGeoLiteDb::class => [GeolocationDbUpdater::class, 'Logger_Shlink'],
],

View File

@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
use Fig\Http\Message\RequestMethodInterface as RequestMethod;
use RKA\Middleware\IpAddress;
use Shlinkio\Shlink\Core\Action;
return [
'routes' => [
[
'name' => Action\RobotsAction::class,
'path' => '/robots.txt',
'middleware' => [
Action\RobotsAction::class,
],
'allowed_methods' => [RequestMethod::METHOD_GET],
],
[
'name' => Action\RedirectAction::class,
'path' => '/{shortCode}',
'middleware' => [
IpAddress::class,
Action\RedirectAction::class,
],
'allowed_methods' => [RequestMethod::METHOD_GET],
],
[
'name' => Action\PixelAction::class,
'path' => '/{shortCode}/track',
'middleware' => [
IpAddress::class,
Action\PixelAction::class,
],
'allowed_methods' => [RequestMethod::METHOD_GET],
],
[
'name' => Action\QrCodeAction::class,
'path' => '/{shortCode}/qr-code',
'middleware' => [
Action\QrCodeAction::class,
],
'allowed_methods' => [RequestMethod::METHOD_GET],
],
],
];

View File

@@ -8,16 +8,19 @@ use Cake\Chronos\Chronos;
use DateTimeInterface;
use Doctrine\ORM\Mapping\Builder\FieldBuilder;
use Jaybizzle\CrawlerDetect\CrawlerDetect;
use Laminas\Filter\Word\CamelCaseToSeparator;
use Laminas\InputFilter\InputFilter;
use PUGX\Shortid\Factory as ShortIdFactory;
use Shlinkio\Shlink\Common\Util\DateRange;
use function date_default_timezone_get;
use function Functional\reduce_left;
use function is_array;
use function print_r;
use function Shlinkio\Shlink\Common\buildDateRange;
use function sprintf;
use function str_repeat;
use function ucfirst;
function generateRandomShortCode(int $length): string
{
@@ -32,7 +35,7 @@ function generateRandomShortCode(int $length): string
function parseDateFromQuery(array $query, string $dateName): ?Chronos
{
return empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName]);
return normalizeDate(empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName]));
}
function parseDateRangeFromQuery(array $query, string $startDateName, string $endDateName): DateRange
@@ -43,29 +46,15 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en
return buildDateRange($startDate, $endDate);
}
function parseDateField(string|DateTimeInterface|Chronos|null $date): ?Chronos
function normalizeDate(string|DateTimeInterface|Chronos|null $date): ?Chronos
{
if ($date === null || $date instanceof Chronos) {
return $date;
}
$parsedDate = match (true) {
$date === null || $date instanceof Chronos => $date,
$date instanceof DateTimeInterface => Chronos::instance($date),
default => Chronos::parse($date),
};
if ($date instanceof DateTimeInterface) {
return Chronos::instance($date);
}
return Chronos::parse($date);
}
function determineTableName(string $tableName, array $emConfig = []): string
{
$schema = $emConfig['connection']['schema'] ?? null;
// $tablePrefix = $emConfig['connection']['table_prefix'] ?? null; // TODO
if ($schema === null) {
return $tableName;
}
return sprintf('%s.%s', $schema, $tableName);
return $parsedDate?->setTimezone(date_default_timezone_get());
}
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
@@ -108,6 +97,18 @@ function isCrawler(string $userAgent): bool
return $detector->isCrawler($userAgent);
}
function determineTableName(string $tableName, array $emConfig = []): string
{
$schema = $emConfig['connection']['schema'] ?? null;
// $tablePrefix = $emConfig['connection']['table_prefix'] ?? null; // TODO
if ($schema === null) {
return $tableName;
}
return sprintf('%s.%s', $schema, $tableName);
}
function fieldWithUtf8Charset(FieldBuilder $field, array $emConfig, string $collation = 'unicode_ci'): FieldBuilder
{
return match ($emConfig['connection']['driver'] ?? null) {
@@ -116,3 +117,13 @@ function fieldWithUtf8Charset(FieldBuilder $field, array $emConfig, string $coll
default => $field,
};
}
function camelCaseToHumanFriendly(string $value): string
{
static $filter;
if ($filter === null) {
$filter = new CamelCaseToSeparator(' ');
}
return ucfirst($filter->filter($value));
}

View File

@@ -29,11 +29,11 @@ final class QrCodeParams
private const SUPPORTED_FORMATS = ['png', 'svg'];
private function __construct(
private int $size,
private int $margin,
private WriterInterface $writer,
private ErrorCorrectionLevelInterface $errorCorrectionLevel,
private RoundBlockSizeModeInterface $roundBlockSizeMode,
public readonly int $size,
public readonly int $margin,
public readonly WriterInterface $writer,
public readonly ErrorCorrectionLevelInterface $errorCorrectionLevel,
public readonly RoundBlockSizeModeInterface $roundBlockSizeMode,
) {
}
@@ -105,29 +105,4 @@ final class QrCodeParams
{
return strtolower(trim($param));
}
public function size(): int
{
return $this->size;
}
public function margin(): int
{
return $this->margin;
}
public function writer(): WriterInterface
{
return $this->writer;
}
public function errorCorrectionLevel(): ErrorCorrectionLevelInterface
{
return $this->errorCorrectionLevel;
}
public function roundBlockSizeMode(): RoundBlockSizeModeInterface
{
return $this->roundBlockSizeMode;
}
}

View File

@@ -42,11 +42,11 @@ class QrCodeAction implements MiddlewareInterface
$params = QrCodeParams::fromRequest($request, $this->defaultOptions);
$qrCodeBuilder = Builder::create()
->data($this->stringifier->stringify($shortUrl))
->size($params->size())
->margin($params->margin())
->writer($params->writer())
->errorCorrectionLevel($params->errorCorrectionLevel())
->roundBlockSizeMode($params->roundBlockSizeMode());
->size($params->size)
->margin($params->margin)
->writer($params->writer)
->errorCorrectionLevel($params->errorCorrectionLevel)
->roundBlockSizeMode($params->roundBlockSizeMode);
return new QrCodeResponse($qrCodeBuilder->build());
}

View File

@@ -13,7 +13,6 @@ class BasePathPrefixer
public function __invoke(array $config): array
{
$basePath = $config['router']['base_path'] ?? '';
$config['url_shortener']['domain']['hostname'] .= $basePath;
foreach (self::ELEMENTS_WITH_PATH as $configKey) {
$config[$configKey] = $this->prefixPathsWithBasePath($configKey, $config, $basePath);

View File

@@ -4,155 +4,84 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config;
use ReflectionClass;
use ReflectionClassConstant;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use function array_values;
use function Functional\contains;
use function Functional\map;
use function Shlinkio\Shlink\Config\env;
// TODO Convert to enum
/**
* @method static EnvVars DELETE_SHORT_URL_THRESHOLD()
* @method static EnvVars DB_DRIVER()
* @method static EnvVars DB_NAME()
* @method static EnvVars DB_USER()
* @method static EnvVars DB_PASSWORD()
* @method static EnvVars DB_HOST()
* @method static EnvVars DB_UNIX_SOCKET()
* @method static EnvVars DB_PORT()
* @method static EnvVars GEOLITE_LICENSE_KEY()
* @method static EnvVars REDIS_SERVERS()
* @method static EnvVars REDIS_SENTINEL_SERVICE()
* @method static EnvVars MERCURE_PUBLIC_HUB_URL()
* @method static EnvVars MERCURE_INTERNAL_HUB_URL()
* @method static EnvVars MERCURE_JWT_SECRET()
* @method static EnvVars DEFAULT_QR_CODE_SIZE()
* @method static EnvVars DEFAULT_QR_CODE_MARGIN()
* @method static EnvVars DEFAULT_QR_CODE_FORMAT()
* @method static EnvVars DEFAULT_QR_CODE_ERROR_CORRECTION()
* @method static EnvVars DEFAULT_QR_CODE_ROUND_BLOCK_SIZE()
* @method static EnvVars RABBITMQ_ENABLED()
* @method static EnvVars RABBITMQ_HOST()
* @method static EnvVars RABBITMQ_PORT()
* @method static EnvVars RABBITMQ_USER()
* @method static EnvVars RABBITMQ_PASSWORD()
* @method static EnvVars RABBITMQ_VHOST()
* @method static EnvVars DEFAULT_INVALID_SHORT_URL_REDIRECT()
* @method static EnvVars DEFAULT_REGULAR_404_REDIRECT()
* @method static EnvVars DEFAULT_BASE_URL_REDIRECT()
* @method static EnvVars REDIRECT_STATUS_CODE()
* @method static EnvVars REDIRECT_CACHE_LIFETIME()
* @method static EnvVars BASE_PATH()
* @method static EnvVars PORT()
* @method static EnvVars TASK_WORKER_NUM()
* @method static EnvVars WEB_WORKER_NUM()
* @method static EnvVars ANONYMIZE_REMOTE_ADDR()
* @method static EnvVars TRACK_ORPHAN_VISITS()
* @method static EnvVars DISABLE_TRACK_PARAM()
* @method static EnvVars DISABLE_TRACKING()
* @method static EnvVars DISABLE_IP_TRACKING()
* @method static EnvVars DISABLE_REFERRER_TRACKING()
* @method static EnvVars DISABLE_UA_TRACKING()
* @method static EnvVars DISABLE_TRACKING_FROM()
* @method static EnvVars DEFAULT_SHORT_CODES_LENGTH()
* @method static EnvVars IS_HTTPS_ENABLED()
* @method static EnvVars DEFAULT_DOMAIN()
* @method static EnvVars AUTO_RESOLVE_TITLES()
* @method static EnvVars REDIRECT_APPEND_EXTRA_PATH()
* @method static EnvVars VISITS_WEBHOOKS()
* @method static EnvVars NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS()
*/
final class EnvVars
enum EnvVars: string
{
public const DELETE_SHORT_URL_THRESHOLD = 'DELETE_SHORT_URL_THRESHOLD';
public const DB_DRIVER = 'DB_DRIVER';
public const DB_NAME = 'DB_NAME';
public const DB_USER = 'DB_USER';
public const DB_PASSWORD = 'DB_PASSWORD';
public const DB_HOST = 'DB_HOST';
public const DB_UNIX_SOCKET = 'DB_UNIX_SOCKET';
public const DB_PORT = 'DB_PORT';
public const GEOLITE_LICENSE_KEY = 'GEOLITE_LICENSE_KEY';
public const REDIS_SERVERS = 'REDIS_SERVERS';
public const REDIS_SENTINEL_SERVICE = 'REDIS_SENTINEL_SERVICE';
public const MERCURE_PUBLIC_HUB_URL = 'MERCURE_PUBLIC_HUB_URL';
public const MERCURE_INTERNAL_HUB_URL = 'MERCURE_INTERNAL_HUB_URL';
public const MERCURE_JWT_SECRET = 'MERCURE_JWT_SECRET';
public const DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE';
public const DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN';
public const DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT';
public const DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION';
public const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE';
public const RABBITMQ_ENABLED = 'RABBITMQ_ENABLED';
public const RABBITMQ_HOST = 'RABBITMQ_HOST';
public const RABBITMQ_PORT = 'RABBITMQ_PORT';
public const RABBITMQ_USER = 'RABBITMQ_USER';
public const RABBITMQ_PASSWORD = 'RABBITMQ_PASSWORD';
public const RABBITMQ_VHOST = 'RABBITMQ_VHOST';
public const DEFAULT_INVALID_SHORT_URL_REDIRECT = 'DEFAULT_INVALID_SHORT_URL_REDIRECT';
public const DEFAULT_REGULAR_404_REDIRECT = 'DEFAULT_REGULAR_404_REDIRECT';
public const DEFAULT_BASE_URL_REDIRECT = 'DEFAULT_BASE_URL_REDIRECT';
public const REDIRECT_STATUS_CODE = 'REDIRECT_STATUS_CODE';
public const REDIRECT_CACHE_LIFETIME = 'REDIRECT_CACHE_LIFETIME';
public const BASE_PATH = 'BASE_PATH';
public const PORT = 'PORT';
public const TASK_WORKER_NUM = 'TASK_WORKER_NUM';
public const WEB_WORKER_NUM = 'WEB_WORKER_NUM';
public const ANONYMIZE_REMOTE_ADDR = 'ANONYMIZE_REMOTE_ADDR';
public const TRACK_ORPHAN_VISITS = 'TRACK_ORPHAN_VISITS';
public const DISABLE_TRACK_PARAM = 'DISABLE_TRACK_PARAM';
public const DISABLE_TRACKING = 'DISABLE_TRACKING';
public const DISABLE_IP_TRACKING = 'DISABLE_IP_TRACKING';
public const DISABLE_REFERRER_TRACKING = 'DISABLE_REFERRER_TRACKING';
public const DISABLE_UA_TRACKING = 'DISABLE_UA_TRACKING';
public const DISABLE_TRACKING_FROM = 'DISABLE_TRACKING_FROM';
public const DEFAULT_SHORT_CODES_LENGTH = 'DEFAULT_SHORT_CODES_LENGTH';
public const IS_HTTPS_ENABLED = 'IS_HTTPS_ENABLED';
public const DEFAULT_DOMAIN = 'DEFAULT_DOMAIN';
public const AUTO_RESOLVE_TITLES = 'AUTO_RESOLVE_TITLES';
public const REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH';
case DELETE_SHORT_URL_THRESHOLD = 'DELETE_SHORT_URL_THRESHOLD';
case DB_DRIVER = 'DB_DRIVER';
case DB_NAME = 'DB_NAME';
case DB_USER = 'DB_USER';
case DB_PASSWORD = 'DB_PASSWORD';
case DB_HOST = 'DB_HOST';
case DB_UNIX_SOCKET = 'DB_UNIX_SOCKET';
case DB_PORT = 'DB_PORT';
case GEOLITE_LICENSE_KEY = 'GEOLITE_LICENSE_KEY';
case REDIS_SERVERS = 'REDIS_SERVERS';
case REDIS_SENTINEL_SERVICE = 'REDIS_SENTINEL_SERVICE';
case REDIS_PUB_SUB_ENABLED = 'REDIS_PUB_SUB_ENABLED';
case MERCURE_PUBLIC_HUB_URL = 'MERCURE_PUBLIC_HUB_URL';
case MERCURE_INTERNAL_HUB_URL = 'MERCURE_INTERNAL_HUB_URL';
case MERCURE_JWT_SECRET = 'MERCURE_JWT_SECRET';
case DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE';
case DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN';
case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT';
case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION';
case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE';
case RABBITMQ_ENABLED = 'RABBITMQ_ENABLED';
case RABBITMQ_HOST = 'RABBITMQ_HOST';
case RABBITMQ_PORT = 'RABBITMQ_PORT';
case RABBITMQ_USER = 'RABBITMQ_USER';
case RABBITMQ_PASSWORD = 'RABBITMQ_PASSWORD';
case RABBITMQ_VHOST = 'RABBITMQ_VHOST';
/** @deprecated */
public const VISITS_WEBHOOKS = 'VISITS_WEBHOOKS';
case RABBITMQ_LEGACY_VISITS_PUBLISHING = 'RABBITMQ_LEGACY_VISITS_PUBLISHING';
case DEFAULT_INVALID_SHORT_URL_REDIRECT = 'DEFAULT_INVALID_SHORT_URL_REDIRECT';
case DEFAULT_REGULAR_404_REDIRECT = 'DEFAULT_REGULAR_404_REDIRECT';
case DEFAULT_BASE_URL_REDIRECT = 'DEFAULT_BASE_URL_REDIRECT';
case REDIRECT_STATUS_CODE = 'REDIRECT_STATUS_CODE';
case REDIRECT_CACHE_LIFETIME = 'REDIRECT_CACHE_LIFETIME';
case BASE_PATH = 'BASE_PATH';
case PORT = 'PORT';
case TASK_WORKER_NUM = 'TASK_WORKER_NUM';
case WEB_WORKER_NUM = 'WEB_WORKER_NUM';
case ANONYMIZE_REMOTE_ADDR = 'ANONYMIZE_REMOTE_ADDR';
case TRACK_ORPHAN_VISITS = 'TRACK_ORPHAN_VISITS';
case DISABLE_TRACK_PARAM = 'DISABLE_TRACK_PARAM';
case DISABLE_TRACKING = 'DISABLE_TRACKING';
case DISABLE_IP_TRACKING = 'DISABLE_IP_TRACKING';
case DISABLE_REFERRER_TRACKING = 'DISABLE_REFERRER_TRACKING';
case DISABLE_UA_TRACKING = 'DISABLE_UA_TRACKING';
case DISABLE_TRACKING_FROM = 'DISABLE_TRACKING_FROM';
case DEFAULT_SHORT_CODES_LENGTH = 'DEFAULT_SHORT_CODES_LENGTH';
case IS_HTTPS_ENABLED = 'IS_HTTPS_ENABLED';
case DEFAULT_DOMAIN = 'DEFAULT_DOMAIN';
case AUTO_RESOLVE_TITLES = 'AUTO_RESOLVE_TITLES';
case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH';
case TIMEZONE = 'TIMEZONE';
case MULTI_SEGMENT_SLUGS_ENABLED = 'MULTI_SEGMENT_SLUGS_ENABLED';
/** @deprecated */
public const NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS = 'NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS';
/**
* @return string[]
*/
public static function cases(): array
{
static $constants;
if ($constants !== null) {
return $constants;
}
$ref = new ReflectionClass(self::class);
return $constants = array_values($ref->getConstants(ReflectionClassConstant::IS_PUBLIC));
}
private function __construct(private string $envVar)
{
}
public static function __callStatic(string $name, array $arguments): self
{
if (! contains(self::cases(), $name)) {
throw new InvalidArgumentException('Invalid env var: "' . $name . '"');
}
return new self($name);
}
case VISITS_WEBHOOKS = 'VISITS_WEBHOOKS';
/** @deprecated */
case NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS = 'NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS';
public function loadFromEnv(mixed $default = null): mixed
{
return env($this->envVar, $default);
return env($this->value, $default);
}
public function existsInEnv(): bool
{
return $this->loadFromEnv() !== null;
}
/**
* @return string[]
*/
public static function values(): array
{
static $values;
return $values ?? ($values = map(self::cases(), static fn (EnvVars $envVar) => $envVar->value));
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config;
use function Functional\map;
use function str_replace;
class MultiSegmentSlugProcessor
{
private const SINGLE_SHORT_CODE_PATTERN = '{shortCode}';
private const MULTI_SHORT_CODE_PATTERN = '{shortCode:.+}';
public function __invoke(array $config): array
{
$multiSegmentEnabled = $config['url_shortener']['multi_segment_slugs_enabled'] ?? false;
if (! $multiSegmentEnabled) {
return $config;
}
$config['routes'] = map($config['routes'] ?? [], static function (array $route): array {
['path' => $path] = $route;
$route['path'] = str_replace(self::SINGLE_SHORT_CODE_PATTERN, self::MULTI_SHORT_CODE_PATTERN, $path);
return $route;
});
return $config;
}
}

View File

@@ -13,7 +13,9 @@ use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
use function Functional\compose;
use function Functional\id;
use function str_replace;
use function urlencode;
class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
{
@@ -71,10 +73,10 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
$replacePlaceholderForPattern(self::ORIGINAL_PATH_PLACEHOLDER, $path, $modifier),
);
$replacePlaceholdersInPath = compose(
$replacePlaceholders('\Functional\id'),
static fn (?string $path) => $path === null ? null : str_replace('//', '/', $path), // Fix duplicated bars
$replacePlaceholders(id(...)),
static fn (?string $path) => $path === null ? null : str_replace('//', '/', $path),
);
$replacePlaceholdersInQuery = $replacePlaceholders('\urlencode');
$replacePlaceholdersInQuery = $replacePlaceholders(urlencode(...));
return $redirectUri
->withPath($replacePlaceholdersInPath($redirectUri->getPath()))

View File

@@ -9,9 +9,9 @@ use JsonSerializable;
final class NotFoundRedirects implements JsonSerializable
{
private function __construct(
private ?string $baseUrlRedirect,
private ?string $regular404Redirect,
private ?string $invalidShortUrlRedirect,
public readonly ?string $baseUrlRedirect,
public readonly ?string $regular404Redirect,
public readonly ?string $invalidShortUrlRedirect,
) {
}
@@ -33,21 +33,6 @@ final class NotFoundRedirects implements JsonSerializable
return new self($config->baseUrlRedirect(), $config->regular404Redirect(), $config->invalidShortUrlRedirect());
}
public function baseUrlRedirect(): ?string
{
return $this->baseUrlRedirect;
}
public function regular404Redirect(): ?string
{
return $this->regular404Redirect;
}
public function invalidShortUrlRedirect(): ?string
{
return $this->invalidShortUrlRedirect;
}
public function jsonSerialize(): array
{
return [

View File

@@ -12,9 +12,9 @@ use Shlinkio\Shlink\Core\Entity\Domain;
final class DomainItem implements JsonSerializable
{
private function __construct(
private string $authority,
private NotFoundRedirectConfigInterface $notFoundRedirectConfig,
private bool $isDefault,
private readonly string $authority,
public readonly NotFoundRedirectConfigInterface $notFoundRedirectConfig,
public readonly bool $isDefault,
) {
}
@@ -23,9 +23,9 @@ final class DomainItem implements JsonSerializable
return new self($domain->getAuthority(), $domain, false);
}
public static function forDefaultDomain(string $authority, NotFoundRedirectConfigInterface $config): self
public static function forDefaultDomain(string $defaultDomain, NotFoundRedirectConfigInterface $config): self
{
return new self($authority, $config, true);
return new self($defaultDomain, $config, true);
}
public function jsonSerialize(): array
@@ -41,14 +41,4 @@ final class DomainItem implements JsonSerializable
{
return $this->authority;
}
public function isDefault(): bool
{
return $this->isDefault;
}
public function notFoundRedirectConfig(): NotFoundRedirectConfigInterface
{
return $this->notFoundRedirectConfig;
}
}

View File

@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain\Repository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Happyr\DoctrineSpecification\Spec;
use Shlinkio\Shlink\Core\Domain\Spec\IsDomain;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
@@ -40,8 +40,25 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain
{
$qb = $this->createQueryBuilder('d');
$qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d')
$qb = $this->createDomainQueryBuilder($authority, $apiKey);
$qb->select('d');
return $qb->getQuery()->getOneOrNullResult();
}
public function domainExists(string $authority, ?ApiKey $apiKey = null): bool
{
$qb = $this->createDomainQueryBuilder($authority, $apiKey);
$qb->select('COUNT(d.id)');
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
}
private function createDomainQueryBuilder(string $authority, ?ApiKey $apiKey): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(Domain::class, 'd')
->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d')
->where($qb->expr()->eq('d.authority', ':authority'))
->setParameter('authority', $authority)
->setMaxResults(1);
@@ -51,7 +68,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
$this->applySpecification($qb, $spec, $alias);
}
return $qb->getQuery()->getOneOrNullResult();
return $qb;
}
private function determineExtraSpecs(?ApiKey $apiKey): iterable
@@ -59,10 +76,9 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
// FIXME The $apiKey->spec() method cannot be used here, as it returns a single spec which assumes the
// ShortUrl is the root entity. Here, the Domain is the root entity.
// Think on a way to centralize the conditional behavior and make $apiKey->spec() more flexible.
yield from $apiKey?->mapRoles(fn (string $roleName, array $meta) => match ($roleName) {
yield from $apiKey?->mapRoles(fn (Role $role, array $meta) => match ($role) {
Role::DOMAIN_SPECIFIC => ['d', new IsDomain(Role::domainIdFromMeta($meta))],
Role::AUTHORED_SHORT_URLS => ['s', new BelongsToApiKey($apiKey)],
default => [null, Spec::andX()],
}) ?? [];
}
}

View File

@@ -17,4 +17,6 @@ interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificatio
public function findDomains(?ApiKey $apiKey = null): array;
public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain;
public function domainExists(string $authority, ?ApiKey $apiKey = null): bool;
}

View File

@@ -66,8 +66,8 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec
public function configureNotFoundRedirects(NotFoundRedirects $redirects): void
{
$this->baseUrlRedirect = $redirects->baseUrlRedirect();
$this->regular404Redirect = $redirects->regular404Redirect();
$this->invalidShortUrlRedirect = $redirects->invalidShortUrlRedirect();
$this->baseUrlRedirect = $redirects->baseUrlRedirect;
$this->regular404Redirect = $redirects->regular404Redirect;
$this->invalidShortUrlRedirect = $redirects->invalidShortUrlRedirect;
}
}

View File

@@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver;
use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@@ -93,31 +94,31 @@ class ShortUrl extends AbstractEntity
): self {
$meta = [
ShortUrlInputFilter::VALIDATE_URL => false,
ShortUrlInputFilter::LONG_URL => $url->longUrl(),
ShortUrlInputFilter::DOMAIN => $url->domain(),
ShortUrlInputFilter::TAGS => $url->tags(),
ShortUrlInputFilter::TITLE => $url->title(),
ShortUrlInputFilter::MAX_VISITS => $url->meta()->maxVisits(),
ShortUrlInputFilter::LONG_URL => $url->longUrl,
ShortUrlInputFilter::DOMAIN => $url->domain,
ShortUrlInputFilter::TAGS => $url->tags,
ShortUrlInputFilter::TITLE => $url->title,
ShortUrlInputFilter::MAX_VISITS => $url->meta->maxVisits,
];
if ($importShortCode) {
$meta[ShortUrlInputFilter::CUSTOM_SLUG] = $url->shortCode();
$meta[ShortUrlInputFilter::CUSTOM_SLUG] = $url->shortCode;
}
$instance = self::fromMeta(ShortUrlMeta::fromRawData($meta), $relationResolver);
$validSince = $url->meta()->validSince();
$validSince = $url->meta->validSince;
if ($validSince !== null) {
$instance->validSince = Chronos::instance($validSince);
}
$validUntil = $url->meta()->validUntil();
$validUntil = $url->meta->validUntil;
if ($validUntil !== null) {
$instance->validUntil = Chronos::instance($validUntil);
}
$instance->importSource = $url->source();
$instance->importOriginalShortCode = $url->shortCode();
$instance->dateCreated = Chronos::instance($url->createdAt());
$instance->importSource = $url->source->value;
$instance->importOriginalShortCode = $url->shortCode;
$instance->dateCreated = Chronos::instance($url->createdAt);
return $instance;
}
@@ -174,7 +175,7 @@ class ShortUrl extends AbstractEntity
{
/** @var Selectable $visits */
$visits = $this->visits;
$criteria = Criteria::create()->where(Criteria::expr()->eq('type', Visit::TYPE_IMPORTED))
$criteria = Criteria::create()->where(Criteria::expr()->eq('type', VisitType::IMPORTED))
->orderBy(['id' => 'DESC'])
->setMaxResults(1);

View File

@@ -10,30 +10,24 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit;
use function Shlinkio\Shlink\Core\isCrawler;
class Visit extends AbstractEntity implements JsonSerializable
{
public const TYPE_VALID_SHORT_URL = 'valid_short_url';
public const TYPE_IMPORTED = 'imported';
public const TYPE_INVALID_SHORT_URL = 'invalid_short_url';
public const TYPE_BASE_URL = 'base_url';
public const TYPE_REGULAR_404 = 'regular_404';
private string $referer;
private Chronos $date;
private ?string $remoteAddr = null;
private ?string $visitedUrl = null;
private string $userAgent;
private string $type;
private VisitType $type;
private ?ShortUrl $shortUrl;
private ?VisitLocation $visitLocation = null;
private bool $potentialBot;
private function __construct(?ShortUrl $shortUrl, string $type)
private function __construct(?ShortUrl $shortUrl, VisitType $type)
{
$this->shortUrl = $shortUrl;
$this->date = Chronos::now();
@@ -42,7 +36,7 @@ class Visit extends AbstractEntity implements JsonSerializable
public static function forValidShortUrl(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true): self
{
$instance = new self($shortUrl, self::TYPE_VALID_SHORT_URL);
$instance = new self($shortUrl, VisitType::VALID_SHORT_URL);
$instance->hydrateFromVisitor($visitor, $anonymize);
return $instance;
@@ -50,13 +44,13 @@ class Visit extends AbstractEntity implements JsonSerializable
public static function fromImport(ShortUrl $shortUrl, ImportedShlinkVisit $importedVisit): self
{
$instance = new self($shortUrl, self::TYPE_IMPORTED);
$instance->userAgent = $importedVisit->userAgent();
$instance = new self($shortUrl, VisitType::IMPORTED);
$instance->userAgent = $importedVisit->userAgent;
$instance->potentialBot = isCrawler($instance->userAgent);
$instance->referer = $importedVisit->referer();
$instance->date = Chronos::instance($importedVisit->date());
$instance->referer = $importedVisit->referer;
$instance->date = Chronos::instance($importedVisit->date);
$importedLocation = $importedVisit->location();
$importedLocation = $importedVisit->location;
$instance->visitLocation = $importedLocation !== null ? VisitLocation::fromImport($importedLocation) : null;
return $instance;
@@ -64,7 +58,7 @@ class Visit extends AbstractEntity implements JsonSerializable
public static function forBasePath(Visitor $visitor, bool $anonymize = true): self
{
$instance = new self(null, self::TYPE_BASE_URL);
$instance = new self(null, VisitType::BASE_URL);
$instance->hydrateFromVisitor($visitor, $anonymize);
return $instance;
@@ -72,7 +66,7 @@ class Visit extends AbstractEntity implements JsonSerializable
public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self
{
$instance = new self(null, self::TYPE_INVALID_SHORT_URL);
$instance = new self(null, VisitType::INVALID_SHORT_URL);
$instance->hydrateFromVisitor($visitor, $anonymize);
return $instance;
@@ -80,7 +74,7 @@ class Visit extends AbstractEntity implements JsonSerializable
public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self
{
$instance = new self(null, self::TYPE_REGULAR_404);
$instance = new self(null, VisitType::REGULAR_404);
$instance->hydrateFromVisitor($visitor, $anonymize);
return $instance;
@@ -88,10 +82,10 @@ class Visit extends AbstractEntity implements JsonSerializable
private function hydrateFromVisitor(Visitor $visitor, bool $anonymize = true): void
{
$this->userAgent = $visitor->getUserAgent();
$this->referer = $visitor->getReferer();
$this->remoteAddr = $this->processAddress($anonymize, $visitor->getRemoteAddress());
$this->visitedUrl = $visitor->getVisitedUrl();
$this->userAgent = $visitor->userAgent;
$this->referer = $visitor->referer;
$this->remoteAddr = $this->processAddress($anonymize, $visitor->remoteAddress);
$this->visitedUrl = $visitor->visitedUrl;
$this->potentialBot = $visitor->isPotentialBot();
}
@@ -124,7 +118,7 @@ class Visit extends AbstractEntity implements JsonSerializable
return $this->shortUrl;
}
public function getVisitLocation(): ?VisitLocationInterface
public function getVisitLocation(): ?VisitLocation
{
return $this->visitLocation;
}
@@ -150,7 +144,7 @@ class Visit extends AbstractEntity implements JsonSerializable
return $this->visitedUrl;
}
public function type(): string
public function type(): VisitType
{
return $this->type;
}
@@ -159,11 +153,19 @@ class Visit extends AbstractEntity implements JsonSerializable
* Needed only for ArrayCollections to be able to apply criteria filtering
* @internal
*/
public function getType(): string
public function getType(): VisitType
{
return $this->type();
}
/**
* @internal
*/
public function getDate(): Chronos
{
return $this->date;
}
public function jsonSerialize(): array
{
return [
@@ -174,12 +176,4 @@ class Visit extends AbstractEntity implements JsonSerializable
'potentialBot' => $this->potentialBot,
];
}
/**
* @internal
*/
public function getDate(): Chronos
{
return $this->date;
}
}

View File

@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Entity;
use JsonSerializable;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisitLocation;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
class VisitLocation extends AbstractEntity implements VisitLocationInterface
class VisitLocation extends AbstractEntity implements JsonSerializable
{
private string $countryCode;
private string $countryName;
@@ -28,13 +28,13 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
{
$instance = new self();
$instance->countryCode = $location->countryCode();
$instance->countryName = $location->countryName();
$instance->regionName = $location->regionName();
$instance->cityName = $location->city();
$instance->latitude = $location->latitude();
$instance->longitude = $location->longitude();
$instance->timezone = $location->timeZone();
$instance->countryCode = $location->countryCode;
$instance->countryName = $location->countryName;
$instance->regionName = $location->regionName;
$instance->cityName = $location->city;
$instance->latitude = $location->latitude;
$instance->longitude = $location->longitude;
$instance->timezone = $location->timeZone;
$instance->computeIsEmpty();
return $instance;
@@ -44,13 +44,13 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
{
$instance = new self();
$instance->countryCode = $location->countryCode();
$instance->countryName = $location->countryName();
$instance->regionName = $location->regionName();
$instance->cityName = $location->cityName();
$instance->latitude = $location->latitude();
$instance->longitude = $location->longitude();
$instance->timezone = $location->timeZone();
$instance->countryCode = $location->countryCode;
$instance->countryName = $location->countryName;
$instance->regionName = $location->regionName;
$instance->cityName = $location->cityName;
$instance->latitude = $location->latitude;
$instance->longitude = $location->longitude;
$instance->timezone = $location->timezone;
$instance->computeIsEmpty();
return $instance;

View File

@@ -7,27 +7,27 @@ namespace Shlinkio\Shlink\Core\ErrorHandler\Model;
use Mezzio\Router\RouteResult;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
use function rtrim;
class NotFoundType
{
private function __construct(private string $type)
private function __construct(private readonly ?VisitType $type)
{
}
public static function fromRequest(ServerRequestInterface $request, string $basePath): self
{
/** @var RouteResult $routeResult */
$routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
$routeResult = $request->getAttribute(RouteResult::class) ?? RouteResult::fromRouteFailure(null);
$isBaseUrl = rtrim($request->getUri()->getPath(), '/') === $basePath;
$type = match (true) {
$isBaseUrl => Visit::TYPE_BASE_URL,
$routeResult->isFailure() => Visit::TYPE_REGULAR_404,
$routeResult->getMatchedRouteName() === RedirectAction::class => Visit::TYPE_INVALID_SHORT_URL,
default => self::class,
$isBaseUrl => VisitType::BASE_URL,
$routeResult->isFailure() => VisitType::REGULAR_404,
$routeResult->getMatchedRouteName() === RedirectAction::class => VisitType::INVALID_SHORT_URL,
default => null,
};
return new self($type);
@@ -35,16 +35,16 @@ class NotFoundType
public function isBaseUrl(): bool
{
return $this->type === Visit::TYPE_BASE_URL;
return $this->type === VisitType::BASE_URL;
}
public function isRegularNotFound(): bool
{
return $this->type === Visit::TYPE_REGULAR_404;
return $this->type === VisitType::REGULAR_404;
}
public function isInvalidShortUrl(): bool
{
return $this->type === Visit::TYPE_INVALID_SHORT_URL;
return $this->type === VisitType::INVALID_SHORT_URL;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\Async;
abstract class AbstractAsyncListener
{
abstract protected function isEnabled(): bool;
abstract protected function getRemoteSystem(): RemoteSystem;
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\Async;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlCreated;
use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface;
use Throwable;
abstract class AbstractNotifyNewShortUrlListener extends AbstractAsyncListener
{
public function __construct(
private readonly PublishingHelperInterface $publishingHelper,
private readonly PublishingUpdatesGeneratorInterface $updatesGenerator,
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
) {
}
public function __invoke(ShortUrlCreated $shortUrlCreated): void
{
if (! $this->isEnabled()) {
return;
}
$shortUrlId = $shortUrlCreated->shortUrlId;
$shortUrl = $this->em->find(ShortUrl::class, $shortUrlId);
$name = $this->getRemoteSystem()->value;
if ($shortUrl === null) {
$this->logger->warning(
'Tried to notify {name} for new short URL with id "{shortUrlId}", but it does not exist.',
['shortUrlId' => $shortUrlId, 'name' => $name],
);
return;
}
try {
$this->publishingHelper->publishUpdate($this->updatesGenerator->newShortUrlUpdate($shortUrl));
} catch (Throwable $e) {
$this->logger->debug(
'Error while trying to notify {name} with new short URL. {e}',
['e' => $e, 'name' => $name],
);
}
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\Async;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\Update;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface;
use Throwable;
use function Functional\each;
abstract class AbstractNotifyVisitListener extends AbstractAsyncListener
{
public function __construct(
private readonly PublishingHelperInterface $publishingHelper,
private readonly PublishingUpdatesGeneratorInterface $updatesGenerator,
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
) {
}
public function __invoke(VisitLocated $visitLocated): void
{
if (! $this->isEnabled()) {
return;
}
$visitId = $visitLocated->visitId;
$visit = $this->em->find(Visit::class, $visitId);
$name = $this->getRemoteSystem()->value;
if ($visit === null) {
$this->logger->warning(
'Tried to notify {name} for visit with id "{visitId}", but it does not exist.',
['visitId' => $visitId, 'name' => $name],
);
return;
}
$updates = $this->determineUpdatesForVisit($visit);
try {
each($updates, fn (Update $update) => $this->publishingHelper->publishUpdate($update));
} catch (Throwable $e) {
$this->logger->debug(
'Error while trying to notify {name} with new visit. {e}',
['e' => $e, 'name' => $name],
);
}
}
/**
* @return Update[]
*/
protected function determineUpdatesForVisit(Visit $visit): array
{
if ($visit->isOrphan()) {
return [$this->updatesGenerator->newOrphanVisitUpdate($visit)];
}
return [
$this->updatesGenerator->newShortUrlVisitUpdate($visit),
$this->updatesGenerator->newVisitUpdate($visit),
];
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\Async;
enum RemoteSystem: string
{
case MERCURE = 'Mercure';
case RABBIT_MQ = 'RabbitMQ';
case REDIS_PUB_SUB = 'Redis pub/sub';
}

View File

@@ -8,15 +8,10 @@ use JsonSerializable;
abstract class AbstractVisitEvent implements JsonSerializable
{
public function __construct(protected string $visitId)
public function __construct(public readonly string $visitId)
{
}
public function visitId(): string
{
return $this->visitId;
}
public function jsonSerialize(): array
{
return ['visitId' => $this->visitId];

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\Event;
use JsonSerializable;
final class ShortUrlCreated implements JsonSerializable
{
public function __construct(public readonly string $shortUrlId)
{
}
public function jsonSerialize(): array
{
return [
'shortUrlId' => $this->shortUrlId,
];
}
}

View File

@@ -6,13 +6,8 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Event;
final class UrlVisited extends AbstractVisitEvent
{
public function __construct(string $visitId, private ?string $originalIpAddress = null)
public function __construct(string $visitId, public readonly ?string $originalIpAddress = null)
{
parent::__construct($visitId);
}
public function originalIpAddress(): ?string
{
return $this->originalIpAddress;
}
}

View File

@@ -30,7 +30,7 @@ class LocateVisit
public function __invoke(UrlVisited $shortUrlVisited): void
{
$visitId = $shortUrlVisited->visitId();
$visitId = $shortUrlVisited->visitId;
/** @var Visit|null $visit */
$visit = $this->em->find(Visit::class, $visitId);
@@ -41,7 +41,7 @@ class LocateVisit
return;
}
$this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit);
$this->locateVisit($visitId, $shortUrlVisited->originalIpAddress, $visit);
$this->eventDispatcher->dispatch(new VisitLocated($visitId));
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\Mercure;
use Shlinkio\Shlink\Core\EventDispatcher\Async\AbstractNotifyNewShortUrlListener;
use Shlinkio\Shlink\Core\EventDispatcher\Async\RemoteSystem;
class NotifyNewShortUrlToMercure extends AbstractNotifyNewShortUrlListener
{
protected function isEnabled(): bool
{
return true;
}
protected function getRemoteSystem(): RemoteSystem
{
return RemoteSystem::MERCURE;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\Mercure;
use Shlinkio\Shlink\Core\EventDispatcher\Async\AbstractNotifyVisitListener;
use Shlinkio\Shlink\Core\EventDispatcher\Async\RemoteSystem;
class NotifyVisitToMercure extends AbstractNotifyVisitListener
{
protected function isEnabled(): bool
{
return true;
}
protected function getRemoteSystem(): RemoteSystem
{
return RemoteSystem::MERCURE;
}
}

View File

@@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGeneratorInterface;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Throwable;
use function Functional\each;
class NotifyVisitToMercure
{
public function __construct(
private HubInterface $hub,
private MercureUpdatesGeneratorInterface $updatesGenerator,
private EntityManagerInterface $em,
private LoggerInterface $logger,
) {
}
public function __invoke(VisitLocated $shortUrlLocated): void
{
$visitId = $shortUrlLocated->visitId();
/** @var Visit|null $visit */
$visit = $this->em->find(Visit::class, $visitId);
if ($visit === null) {
$this->logger->warning('Tried to notify mercure for visit with id "{visitId}", but it does not exist.', [
'visitId' => $visitId,
]);
return;
}
try {
each($this->determineUpdatesForVisit($visit), fn (Update $update) => $this->hub->publish($update));
} catch (Throwable $e) {
$this->logger->debug('Error while trying to notify mercure hub with new visit. {e}', [
'e' => $e,
]);
}
}
/**
* @return Update[]
*/
private function determineUpdatesForVisit(Visit $visit): array
{
if ($visit->isOrphan()) {
return [$this->updatesGenerator->newOrphanVisitUpdate($visit)];
}
return [
$this->updatesGenerator->newShortUrlVisitUpdate($visit),
$this->updatesGenerator->newVisitUpdate($visit),
];
}
}

View File

@@ -1,102 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher;
use Doctrine\ORM\EntityManagerInterface;
use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Exchange\AMQPExchangeType;
use PhpAmqpLib\Message\AMQPMessage;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Throwable;
use function Shlinkio\Shlink\Common\json_encode;
use function sprintf;
class NotifyVisitToRabbitMq
{
private const NEW_VISIT_QUEUE = 'https://shlink.io/new-visit';
private const NEW_ORPHAN_VISIT_QUEUE = 'https://shlink.io/new-orphan-visit';
public function __construct(
private AMQPStreamConnection $connection,
private EntityManagerInterface $em,
private LoggerInterface $logger,
private DataTransformerInterface $orphanVisitTransformer,
private bool $isEnabled,
) {
}
public function __invoke(VisitLocated $shortUrlLocated): void
{
if (! $this->isEnabled) {
return;
}
$visitId = $shortUrlLocated->visitId();
$visit = $this->em->find(Visit::class, $visitId);
if ($visit === null) {
$this->logger->warning('Tried to notify RabbitMQ for visit with id "{visitId}", but it does not exist.', [
'visitId' => $visitId,
]);
return;
}
if (! $this->connection->isConnected()) {
$this->connection->reconnect();
}
$queues = $this->determineQueuesToPublishTo($visit);
$message = $this->visitToMessage($visit);
try {
$channel = $this->connection->channel();
foreach ($queues as $queue) {
// Declare an exchange and a queue that will persist server restarts
$exchange = $queue; // We use the same name for the exchange and the queue
$channel->exchange_declare($exchange, AMQPExchangeType::DIRECT, false, true, false);
$channel->queue_declare($queue, false, true, false, false);
// Bind the exchange and the queue together, and publish the message
$channel->queue_bind($queue, $exchange);
$channel->basic_publish($message, $exchange);
}
$channel->close();
} catch (Throwable $e) {
$this->logger->debug('Error while trying to notify RabbitMQ with new visit. {e}', ['e' => $e]);
} finally {
$this->connection->close();
}
}
/**
* @return string[]
*/
private function determineQueuesToPublishTo(Visit $visit): array
{
if ($visit->isOrphan()) {
return [self::NEW_ORPHAN_VISIT_QUEUE];
}
return [
self::NEW_VISIT_QUEUE,
sprintf('%s/%s', self::NEW_VISIT_QUEUE, $visit->getShortUrl()?->getShortCode()),
];
}
private function visitToMessage(Visit $visit): AMQPMessage
{
$messageBody = json_encode(! $visit->isOrphan() ? $visit : $this->orphanVisitTransformer->transform($visit));
return new AMQPMessage($messageBody, [
'content_type' => 'application/json',
'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT,
]);
}
}

View File

@@ -40,7 +40,7 @@ class NotifyVisitToWebHooks
return;
}
$visitId = $shortUrlLocated->visitId();
$visitId = $shortUrlLocated->visitId;
/** @var Visit|null $visit */
$visit = $this->em->find(Visit::class, $visitId);

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\Update;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
final class PublishingUpdatesGenerator implements PublishingUpdatesGeneratorInterface
{
public function __construct(
private readonly DataTransformerInterface $shortUrlTransformer,
private readonly DataTransformerInterface $orphanVisitTransformer,
) {
}
public function newVisitUpdate(Visit $visit): Update
{
return Update::forTopicAndPayload(Topic::NEW_VISIT->value, [
'shortUrl' => $this->shortUrlTransformer->transform($visit->getShortUrl()),
'visit' => $visit->jsonSerialize(),
]);
}
public function newOrphanVisitUpdate(Visit $visit): Update
{
return Update::forTopicAndPayload(Topic::NEW_ORPHAN_VISIT->value, [
'visit' => $this->orphanVisitTransformer->transform($visit),
]);
}
public function newShortUrlVisitUpdate(Visit $visit): Update
{
$shortUrl = $visit->getShortUrl();
$topic = Topic::newShortUrlVisit($shortUrl?->getShortCode());
return Update::forTopicAndPayload($topic, [
'shortUrl' => $this->shortUrlTransformer->transform($shortUrl),
'visit' => $visit->jsonSerialize(),
]);
}
public function newShortUrlUpdate(ShortUrl $shortUrl): Update
{
return Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, [
'shortUrl' => $this->shortUrlTransformer->transform($shortUrl),
]);
}
}

View File

@@ -2,16 +2,19 @@
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Mercure;
namespace Shlinkio\Shlink\Core\EventDispatcher;
use Shlinkio\Shlink\Common\UpdatePublishing\Update;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Symfony\Component\Mercure\Update;
interface MercureUpdatesGeneratorInterface
interface PublishingUpdatesGeneratorInterface
{
public function newVisitUpdate(Visit $visit): Update;
public function newOrphanVisitUpdate(Visit $visit): Update;
public function newShortUrlVisitUpdate(Visit $visit): Update;
public function newShortUrlUpdate(ShortUrl $shortUrl): Update;
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\RabbitMq;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface;
use Shlinkio\Shlink\Core\EventDispatcher\Async\AbstractNotifyNewShortUrlListener;
use Shlinkio\Shlink\Core\EventDispatcher\Async\RemoteSystem;
use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface;
use Shlinkio\Shlink\Core\Options\RabbitMqOptions;
class NotifyNewShortUrlToRabbitMq extends AbstractNotifyNewShortUrlListener
{
public function __construct(
PublishingHelperInterface $rabbitMqHelper,
PublishingUpdatesGeneratorInterface $updatesGenerator,
EntityManagerInterface $em,
LoggerInterface $logger,
private readonly RabbitMqOptions $options,
) {
parent::__construct($rabbitMqHelper, $updatesGenerator, $em, $logger);
}
protected function isEnabled(): bool
{
return $this->options->isEnabled();
}
protected function getRemoteSystem(): RemoteSystem
{
return RemoteSystem::RABBIT_MQ;
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\RabbitMq;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\Update;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\Async\AbstractNotifyVisitListener;
use Shlinkio\Shlink\Core\EventDispatcher\Async\RemoteSystem;
use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface;
use Shlinkio\Shlink\Core\EventDispatcher\Topic;
use Shlinkio\Shlink\Core\Options\RabbitMqOptions;
class NotifyVisitToRabbitMq extends AbstractNotifyVisitListener
{
public function __construct(
PublishingHelperInterface $rabbitMqHelper,
PublishingUpdatesGeneratorInterface $updatesGenerator,
EntityManagerInterface $em,
LoggerInterface $logger,
private readonly DataTransformerInterface $orphanVisitTransformer,
private readonly RabbitMqOptions $options,
) {
parent::__construct($rabbitMqHelper, $updatesGenerator, $em, $logger);
}
/**
* @return Update[]
*/
protected function determineUpdatesForVisit(Visit $visit): array
{
// Once the two deprecated cases below have been removed, make parent method private
if (! $this->options->legacyVisitsPublishing()) {
return parent::determineUpdatesForVisit($visit);
}
// This was defined incorrectly.
// According to the spec, both the visit and the short URL it belongs to, should be published.
// The shape should be ['visit' => [...], 'shortUrl' => ?[...]]
// However, this would be a breaking change, so we need a flag that determines the shape of the payload.
return $visit->isOrphan()
? [
Update::forTopicAndPayload(
Topic::NEW_ORPHAN_VISIT->value,
$this->orphanVisitTransformer->transform($visit),
),
]
: [
Update::forTopicAndPayload(Topic::NEW_VISIT->value, $visit->jsonSerialize()),
Update::forTopicAndPayload(
Topic::newShortUrlVisit($visit->getShortUrl()?->getShortCode()),
$visit->jsonSerialize(),
),
];
}
protected function isEnabled(): bool
{
return $this->options->isEnabled();
}
protected function getRemoteSystem(): RemoteSystem
{
return RemoteSystem::RABBIT_MQ;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\RedisPubSub;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface;
use Shlinkio\Shlink\Core\EventDispatcher\Async\AbstractNotifyNewShortUrlListener;
use Shlinkio\Shlink\Core\EventDispatcher\Async\RemoteSystem;
use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface;
class NotifyNewShortUrlToRedis extends AbstractNotifyNewShortUrlListener
{
public function __construct(
PublishingHelperInterface $redisHelper,
PublishingUpdatesGeneratorInterface $updatesGenerator,
EntityManagerInterface $em,
LoggerInterface $logger,
private readonly bool $enabled,
) {
parent::__construct($redisHelper, $updatesGenerator, $em, $logger);
}
protected function isEnabled(): bool
{
return $this->enabled;
}
protected function getRemoteSystem(): RemoteSystem
{
return RemoteSystem::REDIS_PUB_SUB;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\RedisPubSub;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface;
use Shlinkio\Shlink\Core\EventDispatcher\Async\AbstractNotifyVisitListener;
use Shlinkio\Shlink\Core\EventDispatcher\Async\RemoteSystem;
use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface;
class NotifyVisitToRedis extends AbstractNotifyVisitListener
{
public function __construct(
PublishingHelperInterface $redisHelper,
PublishingUpdatesGeneratorInterface $updatesGenerator,
EntityManagerInterface $em,
LoggerInterface $logger,
private readonly bool $enabled,
) {
parent::__construct($redisHelper, $updatesGenerator, $em, $logger);
}
protected function isEnabled(): bool
{
return $this->enabled;
}
protected function getRemoteSystem(): RemoteSystem
{
return RemoteSystem::REDIS_PUB_SUB;
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher;
use function sprintf;
enum Topic: string
{
case NEW_VISIT = 'https://shlink.io/new-visit';
case NEW_ORPHAN_VISIT = 'https://shlink.io/new-orphan-visit';
case NEW_SHORT_URL = 'https://shlink.io/new-short-url';
public static function newShortUrlVisit(?string $shortCode): string
{
return sprintf('%s/%s', self::NEW_VISIT->value, $shortCode ?? '');
}
}

View File

@@ -20,8 +20,8 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE
public static function fromVisitsThreshold(int $threshold, ShortUrlIdentifier $identifier): self
{
$shortCode = $identifier->shortCode();
$domain = $identifier->domain();
$shortCode = $identifier->shortCode;
$domain = $identifier->domain;
$suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain);
$e = new self(sprintf(
'Impossible to delete short URL with short code "%s"%s, since it has more than "%s" visits.',

View File

@@ -38,6 +38,6 @@ class NonUniqueSlugException extends InvalidArgumentException implements Problem
public static function fromImport(ImportedShlinkUrl $importedUrl): self
{
return self::fromSlug($importedUrl->shortCode(), $importedUrl->domain());
return self::fromSlug($importedUrl->shortCode, $importedUrl->domain);
}
}

View File

@@ -20,8 +20,8 @@ class ShortUrlNotFoundException extends DomainException implements ProblemDetail
public static function fromNotFound(ShortUrlIdentifier $identifier): self
{
$shortCode = $identifier->shortCode();
$domain = $identifier->domain();
$shortCode = $identifier->shortCode;
$domain = $identifier->domain;
$suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain);
$e = new self(sprintf('No URL found with short code "%s"%s', $shortCode, $suffix));

View File

@@ -13,8 +13,11 @@ use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface;
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Importer\Sources\ImportSources;
use Shlinkio\Shlink\Importer\Params\ImportParams;
use Shlinkio\Shlink\Importer\Sources\ImportSource;
use Symfony\Component\Console\Style\OutputStyle;
use Symfony\Component\Console\Style\StyleInterface;
use Throwable;
use function sprintf;
@@ -23,45 +26,49 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
private ShortUrlRepositoryInterface $shortUrlRepo;
public function __construct(
private EntityManagerInterface $em,
private ShortUrlRelationResolverInterface $relationResolver,
private ShortCodeUniquenessHelperInterface $shortCodeHelper,
private DoctrineBatchHelperInterface $batchHelper,
private readonly EntityManagerInterface $em,
private readonly ShortUrlRelationResolverInterface $relationResolver,
private readonly ShortCodeUniquenessHelperInterface $shortCodeHelper,
private readonly DoctrineBatchHelperInterface $batchHelper,
) {
$this->shortUrlRepo = $this->em->getRepository(ShortUrl::class);
}
/**
* @param iterable|ImportedShlinkUrl[] $shlinkUrls
* @param iterable<ImportedShlinkUrl> $shlinkUrls
*/
public function process(StyleInterface $io, iterable $shlinkUrls, array $params): void
public function process(StyleInterface $io, iterable $shlinkUrls, ImportParams $params): void
{
$importShortCodes = $params['import_short_codes'];
$source = $params['source'];
$iterable = $this->batchHelper->wrapIterable($shlinkUrls, $source === ImportSources::SHLINK ? 10 : 100);
$importShortCodes = $params->importShortCodes;
$source = $params->source;
$iterable = $this->batchHelper->wrapIterable($shlinkUrls, $source === ImportSource::SHLINK ? 10 : 100);
/** @var ImportedShlinkUrl $importedUrl */
foreach ($iterable as $importedUrl) {
$skipOnShortCodeConflict = static function () use ($io, $importedUrl): bool {
$action = $io->choice(sprintf(
'Failed to import URL "%s" because its short-code "%s" is already in use. Do you want to generate '
. 'a new one or skip it?',
$importedUrl->longUrl(),
$importedUrl->shortCode(),
), ['Generate new short-code', 'Skip'], 1);
return $action === 'Skip';
};
$longUrl = $importedUrl->longUrl();
$skipOnShortCodeConflict = static fn (): bool => $io->choice(sprintf(
'Failed to import URL "%s" because its short-code "%s" is already in use. Do you want to generate '
. 'a new one or skip it?',
$importedUrl->longUrl,
$importedUrl->shortCode,
), ['Generate new short-code', 'Skip'], 1) === 'Skip';
$longUrl = $importedUrl->longUrl;
try {
$shortUrlImporting = $this->resolveShortUrl($importedUrl, $importShortCodes, $skipOnShortCodeConflict);
} catch (NonUniqueSlugException) {
$io->text(sprintf('%s: <fg=red>Error</>', $longUrl));
continue;
} catch (Throwable $e) {
$io->text(sprintf('%s: <comment>Skipped</comment>. Reason: %s.', $longUrl, $e->getMessage()));
if ($io instanceof OutputStyle && $io->isVerbose()) {
$io->text($e->__toString());
}
continue;
}
$resultMessage = $shortUrlImporting->importVisits($importedUrl->visits(), $this->em);
$resultMessage = $shortUrlImporting->importVisits($importedUrl->visits, $this->em);
$io->text(sprintf('%s: %s', $longUrl, $resultMessage));
}
}

View File

@@ -14,7 +14,7 @@ use function sprintf;
final class ShortUrlImporting
{
private function __construct(private ShortUrl $shortUrl, private bool $isNew)
private function __construct(private readonly ShortUrl $shortUrl, private readonly bool $isNew)
{
}
@@ -29,7 +29,7 @@ final class ShortUrlImporting
}
/**
* @param iterable|ImportedShlinkVisit[] $visits
* @param iterable<ImportedShlinkVisit> $visits
*/
public function importVisits(iterable $visits, EntityManagerInterface $em): string
{
@@ -38,7 +38,7 @@ final class ShortUrlImporting
$importedVisits = 0;
foreach ($visits as $importedVisit) {
// Skip visits which are older than the most recent already imported visit's date
if ($mostRecentImportedDate?->gte(Chronos::instance($importedVisit->date()))) {
if ($mostRecentImportedDate?->gte(Chronos::instance($importedVisit->date))) {
continue;
}

View File

@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Mercure;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\Entity\Visit;
use Symfony\Component\Mercure\Update;
use function Shlinkio\Shlink\Common\json_encode;
use function sprintf;
final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface
{
private const NEW_VISIT_TOPIC = 'https://shlink.io/new-visit';
private const NEW_ORPHAN_VISIT_TOPIC = 'https://shlink.io/new-orphan-visit';
public function __construct(
private DataTransformerInterface $shortUrlTransformer,
private DataTransformerInterface $orphanVisitTransformer,
) {
}
public function newVisitUpdate(Visit $visit): Update
{
return new Update(self::NEW_VISIT_TOPIC, json_encode([
'shortUrl' => $this->shortUrlTransformer->transform($visit->getShortUrl()),
'visit' => $visit,
]));
}
public function newOrphanVisitUpdate(Visit $visit): Update
{
return new Update(self::NEW_ORPHAN_VISIT_TOPIC, json_encode([
'visit' => $this->orphanVisitTransformer->transform($visit),
]));
}
public function newShortUrlVisitUpdate(Visit $visit): Update
{
$shortUrl = $visit->getShortUrl();
$topic = sprintf('%s/%s', self::NEW_VISIT_TOPIC, $shortUrl?->getShortCode());
return new Update($topic, json_encode([
'shortUrl' => $this->shortUrlTransformer->transform($shortUrl),
'visit' => $visit,
]));
}
}

View File

@@ -10,8 +10,8 @@ abstract class AbstractInfinitePaginableListParams
{
private const FIRST_PAGE = 1;
private int $page;
private int $itemsPerPage;
public readonly int $page;
public readonly int $itemsPerPage;
protected function __construct(?int $page, ?int $itemsPerPage)
{
@@ -28,14 +28,4 @@ abstract class AbstractInfinitePaginableListParams
{
return $itemsPerPage === null || $itemsPerPage < 0 ? Paginator::ALL_ITEMS : $itemsPerPage;
}
public function getPage(): int
{
return $this->page;
}
public function getItemsPerPage(): int
{
return $this->itemsPerPage;
}
}

View File

@@ -8,7 +8,7 @@ final class Ordering
{
private const DEFAULT_DIR = 'ASC';
private function __construct(private ?string $field, private string $dir)
private function __construct(public readonly ?string $field, public readonly string $direction)
{
}
@@ -26,16 +26,6 @@ final class Ordering
return self::fromTuple([null, null]);
}
public function orderField(): ?string
{
return $this->field;
}
public function orderDirection(): string
{
return $this->dir;
}
public function hasOrderField(): bool
{
return $this->field !== null;

View File

@@ -12,8 +12,9 @@ use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter;
use function array_key_exists;
use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
use function Shlinkio\Shlink\Core\parseDateField;
use function Shlinkio\Shlink\Core\normalizeDate;
// TODO Rename to ShortUrlEdition
final class ShortUrlEdit implements TitleResolutionModelInterface
{
private bool $longUrlPropWasProvided = false;
@@ -69,8 +70,8 @@ final class ShortUrlEdit implements TitleResolutionModelInterface
$this->forwardQueryPropWasProvided = array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data);
$this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL);
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
$this->validSince = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
$this->validUntil = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS);
$this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false;
$this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS);

View File

@@ -10,7 +10,7 @@ use Symfony\Component\Console\Input\InputInterface;
final class ShortUrlIdentifier
{
public function __construct(private string $shortCode, private ?string $domain = null)
private function __construct(public readonly string $shortCode, public readonly ?string $domain = null)
{
}
@@ -54,14 +54,4 @@ final class ShortUrlIdentifier
{
return new self($shortCode, $domain);
}
public function shortCode(): string
{
return $this->shortCode;
}
public function domain(): ?string
{
return $this->domain;
}
}

View File

@@ -12,10 +12,11 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
use function Shlinkio\Shlink\Core\parseDateField;
use function Shlinkio\Shlink\Core\normalizeDate;
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
// TODO Rename to ShortUrlCreation
final class ShortUrlMeta implements TitleResolutionModelInterface
{
private string $longUrl;
@@ -68,8 +69,8 @@ final class ShortUrlMeta implements TitleResolutionModelInterface
}
$this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL);
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
$this->validSince = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
$this->validUntil = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
$this->customSlug = $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG);
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS);
$this->findIfExists = $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS);

View File

@@ -6,24 +6,22 @@ namespace Shlinkio\Shlink\Core\Model;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\parseDateField;
use function Shlinkio\Shlink\Core\normalizeDate;
final class ShortUrlsParams
{
public const ORDERABLE_FIELDS = ['longUrl', 'shortCode', 'dateCreated', 'title', 'visits'];
public const DEFAULT_ITEMS_PER_PAGE = 10;
public const TAGS_MODE_ANY = 'any';
public const TAGS_MODE_ALL = 'all';
private int $page;
private int $itemsPerPage;
private ?string $searchTerm;
private array $tags;
/** @var self::TAGS_MODE_ANY|self::TAGS_MODE_ALL */
private string $tagsMode = self::TAGS_MODE_ANY;
private TagsMode $tagsMode = TagsMode::ANY;
private Ordering $orderBy;
private ?DateRange $dateRange;
@@ -61,14 +59,23 @@ final class ShortUrlsParams
$this->searchTerm = $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM);
$this->tags = (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS);
$this->dateRange = buildDateRange(
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
normalizeDate($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
normalizeDate($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
);
$this->orderBy = Ordering::fromTuple($inputFilter->getValue(ShortUrlsParamsInputFilter::ORDER_BY));
$this->itemsPerPage = (int) (
$inputFilter->getValue(ShortUrlsParamsInputFilter::ITEMS_PER_PAGE) ?? self::DEFAULT_ITEMS_PER_PAGE
);
$this->tagsMode = $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE) ?? self::TAGS_MODE_ANY;
$this->tagsMode = $this->resolveTagsMode($inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE));
}
private function resolveTagsMode(?string $rawTagsMode): TagsMode
{
if ($rawTagsMode === null) {
return TagsMode::ANY;
}
return TagsMode::tryFrom($rawTagsMode) ?? TagsMode::ANY;
}
public function page(): int
@@ -101,10 +108,7 @@ final class ShortUrlsParams
return $this->dateRange;
}
/**
* @return self::TAGS_MODE_ANY|self::TAGS_MODE_ALL
*/
public function tagsMode(): string
public function tagsMode(): TagsMode
{
return $this->tagsMode;
}

View File

@@ -18,10 +18,10 @@ final class Visitor
public const REMOTE_ADDRESS_MAX_LENGTH = 256;
public const VISITED_URL_MAX_LENGTH = 2048;
private string $userAgent;
private string $referer;
private string $visitedUrl;
private ?string $remoteAddress;
public readonly string $userAgent;
public readonly string $referer;
public readonly string $visitedUrl;
public readonly ?string $remoteAddress;
private bool $potentialBot;
public function __construct(string $userAgent, string $referer, ?string $remoteAddress, string $visitedUrl)
@@ -61,26 +61,6 @@ final class Visitor
return new self('cf-facebook', '', null, '');
}
public function getUserAgent(): string
{
return $this->userAgent;
}
public function getReferer(): string
{
return $this->referer;
}
public function getRemoteAddress(): ?string
{
return $this->remoteAddress;
}
public function getVisitedUrl(): string
{
return $this->visitedUrl;
}
public function isPotentialBot(): bool
{
return $this->potentialBot;

View File

@@ -10,16 +10,16 @@ use function Shlinkio\Shlink\Core\parseDateRangeFromQuery;
final class VisitsParams extends AbstractInfinitePaginableListParams
{
private DateRange $dateRange;
public readonly DateRange $dateRange;
public function __construct(
?DateRange $dateRange = null,
?int $page = null,
?int $itemsPerPage = null,
private bool $excludeBots = false,
public readonly bool $excludeBots = false,
) {
parent::__construct($page, $itemsPerPage);
$this->dateRange = $dateRange ?? DateRange::emptyInstance();
$this->dateRange = $dateRange ?? DateRange::allTime();
}
public static function fromRawData(array $query): self
@@ -31,14 +31,4 @@ final class VisitsParams extends AbstractInfinitePaginableListParams
isset($query['excludeBots']),
);
}
public function getDateRange(): DateRange
{
return $this->dateRange;
}
public function excludeBots(): bool
{
return $this->excludeBots;
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Options;
use Laminas\Stdlib\AbstractOptions;
class RabbitMqOptions extends AbstractOptions
{
protected $__strictMode__ = false; // phpcs:ignore
private bool $enabled = false;
/** @deprecated */
private bool $legacyVisitsPublishing = false;
public function isEnabled(): bool
{
return $this->enabled;
}
protected function setEnabled(bool $enabled): self
{
$this->enabled = $enabled;
return $this;
}
/** @deprecated */
public function legacyVisitsPublishing(): bool
{
return $this->legacyVisitsPublishing;
}
/** @deprecated */
protected function setLegacyVisitsPublishing(bool $legacyVisitsPublishing): self
{
$this->legacyVisitsPublishing = $legacyVisitsPublishing;
return $this;
}
}

View File

@@ -6,12 +6,39 @@ namespace Shlinkio\Shlink\Core\Options;
use Laminas\Stdlib\AbstractOptions;
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
class UrlShortenerOptions extends AbstractOptions
{
protected $__strictMode__ = false; // phpcs:ignore
private array $domain = [];
private int $defaultShortCodesLength = DEFAULT_SHORT_CODES_LENGTH;
private bool $autoResolveTitles = false;
private bool $appendExtraPath = false;
private bool $multiSegmentSlugsEnabled = false;
public function domain(): array
{
return $this->domain;
}
protected function setDomain(array $domain): self
{
$this->domain = $domain;
return $this;
}
public function defaultShortCodesLength(): int
{
return $this->defaultShortCodesLength;
}
protected function setDefaultShortCodesLength(int $defaultShortCodesLength): self
{
$this->defaultShortCodesLength = $defaultShortCodesLength;
return $this;
}
public function autoResolveTitles(): bool
{
@@ -32,4 +59,14 @@ class UrlShortenerOptions extends AbstractOptions
{
$this->appendExtraPath = $appendExtraPath;
}
public function multiSegmentSlugsEnabled(): bool
{
return $this->multiSegmentSlugsEnabled;
}
protected function setMultiSegmentSlugsEnabled(bool $multiSegmentSlugsEnabled): void
{
$this->multiSegmentSlugsEnabled = $multiSegmentSlugsEnabled;
}
}

View File

@@ -15,7 +15,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\Ordering;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
@@ -47,8 +47,8 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
private function processOrderByForList(QueryBuilder $qb, Ordering $orderBy): array
{
$fieldName = $orderBy->orderField();
$order = $orderBy->orderDirection();
$fieldName = $orderBy->field;
$order = $orderBy->direction;
if ($fieldName === 'visits') {
// FIXME This query is inefficient.
@@ -84,13 +84,13 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
->where('1=1');
$dateRange = $filtering->dateRange();
if ($dateRange?->startDate() !== null) {
if ($dateRange?->startDate !== null) {
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
$qb->setParameter('startDate', $dateRange->startDate(), ChronosDateTimeType::CHRONOS_DATETIME);
$qb->setParameter('startDate', $dateRange->startDate, ChronosDateTimeType::CHRONOS_DATETIME);
}
if ($dateRange?->endDate() !== null) {
if ($dateRange?->endDate !== null) {
$qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate'));
$qb->setParameter('endDate', $dateRange->endDate(), ChronosDateTimeType::CHRONOS_DATETIME);
$qb->setParameter('endDate', $dateRange->endDate, ChronosDateTimeType::CHRONOS_DATETIME);
}
$searchTerm = $filtering->searchTerm();
@@ -102,22 +102,29 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
$qb->leftJoin('s.tags', 't');
}
// Apply search conditions
// Apply general search conditions
$conditions = [
$qb->expr()->like('s.longUrl', ':searchPattern'),
$qb->expr()->like('s.shortCode', ':searchPattern'),
$qb->expr()->like('s.title', ':searchPattern'),
$qb->expr()->like('d.authority', ':searchPattern'),
];
// Apply tag conditions, only when not filtering by all provided tags
$tagsMode = $filtering->tagsMode() ?? TagsMode::ANY;
if (empty($tags) || $tagsMode === TagsMode::ANY) {
$conditions[] = $qb->expr()->like('t.name', ':searchPattern');
}
$qb->leftJoin('s.domain', 'd')
->andWhere($qb->expr()->orX(
$qb->expr()->like('s.longUrl', ':searchPattern'),
$qb->expr()->like('s.shortCode', ':searchPattern'),
$qb->expr()->like('s.title', ':searchPattern'),
$qb->expr()->like('t.name', ':searchPattern'),
$qb->expr()->like('d.authority', ':searchPattern'),
))
->andWhere($qb->expr()->orX(...$conditions))
->setParameter('searchPattern', '%' . $searchTerm . '%');
}
// Filter by tags if provided
if (! empty($tags)) {
$tagsMode = $filtering->tagsMode() ?? ShortUrlsParams::TAGS_MODE_ANY;
$tagsMode === ShortUrlsParams::TAGS_MODE_ANY
$tagsMode = $filtering->tagsMode() ?? TagsMode::ANY;
$tagsMode === TagsMode::ANY
? $qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags))
: $this->joinAllTags($qb, $tags);
}
@@ -146,8 +153,8 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
$query = $this->getEntityManager()->createQuery($dql);
$query->setMaxResults(1)
->setParameters([
'shortCode' => $identifier->shortCode(),
'domain' => $identifier->domain(),
'shortCode' => $identifier->shortCode,
'domain' => $identifier->domain,
]);
// Since we ordered by domain, we will have first the URL matching provided domain, followed by the one
@@ -198,10 +205,10 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
$qb->from(ShortUrl::class, 's')
->where($qb->expr()->isNotNull('s.shortCode'))
->andWhere($qb->expr()->eq('s.shortCode', ':slug'))
->setParameter('slug', $identifier->shortCode())
->setParameter('slug', $identifier->shortCode)
->setMaxResults(1);
$this->whereDomainIs($qb, $identifier->domain());
$this->whereDomainIs($qb, $identifier->domain);
$this->applySpecification($qb, $spec, 's');
@@ -277,12 +284,12 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
{
$qb = $this->createQueryBuilder('s');
$qb->andWhere($qb->expr()->eq('s.importOriginalShortCode', ':shortCode'))
->setParameter('shortCode', $url->shortCode())
->setParameter('shortCode', $url->shortCode)
->andWhere($qb->expr()->eq('s.importSource', ':importSource'))
->setParameter('importSource', $url->source())
->setParameter('importSource', $url->source->value)
->setMaxResults(1);
$this->whereDomainIs($qb, $url->domain());
$this->whereDomainIs($qb, $url->domain);
return $qb->getQuery()->getOneOrNullResult();
}

View File

@@ -41,8 +41,8 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito
*/
public function findTagsWithInfo(?TagsListFiltering $filtering = null): array
{
$orderField = $filtering?->orderBy()?->orderField();
$orderDir = $filtering?->orderBy()?->orderDirection();
$orderField = $filtering?->orderBy?->field;
$orderDir = $filtering?->orderBy?->direction;
$orderMainQuery = contains(['shortUrlsCount', 'visitsCount'], $orderField);
$conn = $this->getEntityManager()->getConnection();
@@ -51,16 +51,16 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito
if (! $orderMainQuery) {
$subQb->orderBy('t.name', $orderDir ?? 'ASC')
->setMaxResults($filtering?->limit() ?? PHP_INT_MAX)
->setFirstResult($filtering?->offset() ?? 0);
->setMaxResults($filtering?->limit ?? PHP_INT_MAX)
->setFirstResult($filtering?->offset ?? 0);
}
$searchTerm = $filtering?->searchTerm();
$searchTerm = $filtering?->searchTerm;
if ($searchTerm !== null) {
$subQb->andWhere($subQb->expr()->like('t.name', $conn->quote('%' . $searchTerm . '%')));
}
$apiKey = $filtering?->apiKey();
$apiKey = $filtering?->apiKey;
$this->applySpecification($subQb, new WithInlinedApiKeySpecsEnsuringJoin($apiKey), 't');
// A native query builder needs to be used here, because DQL and ORM query builders do not support
@@ -74,21 +74,20 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito
'COUNT(DISTINCT s.id) AS short_urls_count',
'COUNT(DISTINCT v.id) AS visits_count',
)
->from('(' . $subQb->getQuery()->getSQL() . ')', 't')
->from('(' . $subQb->getQuery()->getSQL() . ')', 't') // @phpstan-ignore-line
->leftJoin('t', 'short_urls_in_tags', 'st', $nativeQb->expr()->eq('t.id_0', 'st.tag_id'))
->leftJoin('st', 'short_urls', 's', $nativeQb->expr()->eq('s.id', 'st.short_url_id'))
->leftJoin('st', 'visits', 'v', $nativeQb->expr()->eq('s.id', 'v.short_url_id'))
->groupBy('t.id_0', 't.name_1');
// Apply API key role conditions to the native query too, as they will affect the amounts on the aggregates
$apiKey?->mapRoles(static fn (string $roleName, array $meta) => match ($roleName) {
$apiKey?->mapRoles(static fn (Role $role, array $meta) => match ($role) {
Role::DOMAIN_SPECIFIC => $nativeQb->andWhere(
$nativeQb->expr()->eq('s.domain_id', $conn->quote(Role::domainIdFromMeta($meta))),
),
Role::AUTHORED_SHORT_URLS => $nativeQb->andWhere(
$nativeQb->expr()->eq('s.author_api_key_id', $conn->quote($apiKey->getId())),
),
default => $nativeQb,
});
if ($orderMainQuery) {
@@ -97,8 +96,8 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito
$orderField === 'shortUrlsCount' ? 'short_urls_count' : 'visits_count',
$orderDir ?? 'ASC',
)
->setMaxResults($filtering?->limit() ?? PHP_INT_MAX)
->setFirstResult($filtering?->offset() ?? 0);
->setMaxResults($filtering?->limit ?? PHP_INT_MAX)
->setFirstResult($filtering?->offset ?? 0);
}
// Add ordering by tag name, as a fallback in case of same amount, or as default ordering

View File

@@ -86,7 +86,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
public function findVisitsByShortCode(ShortUrlIdentifier $identifier, VisitsListFiltering $filtering): array
{
$qb = $this->createVisitsByShortCodeQueryBuilder($identifier, $filtering);
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset());
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset);
}
public function countVisitsByShortCode(ShortUrlIdentifier $identifier, VisitsCountFiltering $filtering): int
@@ -103,7 +103,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
): QueryBuilder {
/** @var ShortUrlRepositoryInterface $shortUrlRepo */
$shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class);
$shortUrlId = $shortUrlRepo->findOne($identifier, $filtering->apiKey()?->spec())?->getId() ?? '-1';
$shortUrlId = $shortUrlRepo->findOne($identifier, $filtering->apiKey?->spec())?->getId() ?? '-1';
// Parameters in this query need to be part of the query itself, as we need to use it as sub-query later
// Since they are not provided by the caller, it's reasonably safe
@@ -111,12 +111,12 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
$qb->from(Visit::class, 'v')
->where($qb->expr()->eq('v.shortUrl', $shortUrlId));
if ($filtering->excludeBots()) {
if ($filtering->excludeBots) {
$qb->andWhere($qb->expr()->eq('v.potentialBot', 'false'));
}
// Apply date range filtering
$this->applyDatesInline($qb, $filtering->dateRange());
$this->applyDatesInline($qb, $filtering->dateRange);
return $qb;
}
@@ -124,7 +124,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
public function findVisitsByTag(string $tag, VisitsListFiltering $filtering): array
{
$qb = $this->createVisitsByTagQueryBuilder($tag, $filtering);
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset());
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset);
}
public function countVisitsByTag(string $tag, VisitsCountFiltering $filtering): int
@@ -144,12 +144,53 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
->join('s.tags', 't')
->where($qb->expr()->eq('t.name', $this->getEntityManager()->getConnection()->quote($tag)));
if ($filtering->excludeBots()) {
if ($filtering->excludeBots) {
$qb->andWhere($qb->expr()->eq('v.potentialBot', 'false'));
}
$this->applyDatesInline($qb, $filtering->dateRange());
$this->applySpecification($qb, $filtering->apiKey()?->inlinedSpec(), 'v');
$this->applyDatesInline($qb, $filtering->dateRange);
$this->applySpecification($qb, $filtering->apiKey?->inlinedSpec(), 'v');
return $qb;
}
/**
* @return Visit[]
*/
public function findVisitsByDomain(string $domain, VisitsListFiltering $filtering): array
{
$qb = $this->createVisitsByDomainQueryBuilder($domain, $filtering);
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset);
}
public function countVisitsByDomain(string $domain, VisitsCountFiltering $filtering): int
{
$qb = $this->createVisitsByDomainQueryBuilder($domain, $filtering);
$qb->select('COUNT(v.id)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
private function createVisitsByDomainQueryBuilder(string $domain, VisitsCountFiltering $filtering): QueryBuilder
{
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later.
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(Visit::class, 'v')
->join('v.shortUrl', 's');
if ($domain === 'DEFAULT') {
$qb->where($qb->expr()->isNull('s.domain'));
} else {
$qb->join('s.domain', 'd')
->where($qb->expr()->eq('d.authority', $this->getEntityManager()->getConnection()->quote($domain)));
}
if ($filtering->excludeBots) {
$qb->andWhere($qb->expr()->eq('v.potentialBot', 'false'));
}
$this->applyDatesInline($qb, $filtering->dateRange);
$this->applySpecification($qb, $filtering->apiKey?->inlinedSpec(), 'v');
return $qb;
}
@@ -158,7 +199,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
{
$qb = $this->createAllVisitsQueryBuilder($filtering);
$qb->andWhere($qb->expr()->isNull('v.shortUrl'));
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset());
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset);
}
public function countOrphanVisits(VisitsCountFiltering $filtering): int
@@ -174,9 +215,9 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
$qb = $this->createAllVisitsQueryBuilder($filtering);
$qb->andWhere($qb->expr()->isNotNull('v.shortUrl'));
$this->applySpecification($qb, $filtering->apiKey()?->inlinedSpec());
$this->applySpecification($qb, $filtering->apiKey?->inlinedSpec());
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset());
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset);
}
public function countNonOrphanVisits(VisitsCountFiltering $filtering): int
@@ -191,11 +232,11 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(Visit::class, 'v');
if ($filtering->excludeBots()) {
if ($filtering->excludeBots) {
$qb->andWhere($qb->expr()->eq('v.potentialBot', 'false'));
}
$this->applyDatesInline($qb, $filtering->dateRange());
$this->applyDatesInline($qb, $filtering->dateRange);
return $qb;
}
@@ -204,11 +245,11 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
{
$conn = $this->getEntityManager()->getConnection();
if ($dateRange?->startDate() !== null) {
$qb->andWhere($qb->expr()->gte('v.date', $conn->quote($dateRange->startDate()->toDateTimeString())));
if ($dateRange?->startDate !== null) {
$qb->andWhere($qb->expr()->gte('v.date', $conn->quote($dateRange->startDate->toDateTimeString())));
}
if ($dateRange?->endDate() !== null) {
$qb->andWhere($qb->expr()->lte('v.date', $conn->quote($dateRange->endDate()->toDateTimeString())));
if ($dateRange?->endDate !== null) {
$qb->andWhere($qb->expr()->lte('v.date', $conn->quote($dateRange->endDate->toDateTimeString())));
}
}
@@ -231,6 +272,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
$nativeQb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$nativeQb->select('v.id AS visit_id', 'v.*', 'vl.*')
->from('visits', 'v')
// @phpstan-ignore-next-line
->join('v', '(' . $subQuery . ')', 'sq', $nativeQb->expr()->eq('sq.id_0', 'v.id'))
->leftJoin('v', 'visit_locations', 'vl', $nativeQb->expr()->eq('v.visit_location_id', 'vl.id'))
->orderBy('v.id', 'DESC');

View File

@@ -45,6 +45,13 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification
public function countVisitsByTag(string $tag, VisitsCountFiltering $filtering): int;
/**
* @return Visit[]
*/
public function findVisitsByDomain(string $domain, VisitsListFiltering $filtering): array;
public function countVisitsByDomain(string $domain, VisitsCountFiltering $filtering): int;
/**
* @return Visit[]
*/

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