├── .dockerignore ├── .github ├── DISCUSSION_TEMPLATE │ └── help-wanted.yml ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── Bug.yml │ ├── Feature_Request.yml │ └── config.yml ├── actions │ └── ci-setup │ │ └── action.yml └── workflows │ ├── ci-db-tests.yml │ ├── ci-docker-image-build.yml │ ├── ci-tests.yml │ ├── ci.yml │ ├── publish-docker-image.yml │ ├── publish-openapi-spec.yml │ └── publish-release.yml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── UPGRADE.md ├── bin ├── cli ├── doctrine ├── roadrunner-worker.php └── test │ ├── run-api-tests.sh │ └── run-cli-tests.sh ├── composer.json ├── config ├── autoload │ ├── cache.global.php │ ├── common.global.php │ ├── cors.global.php │ ├── dependencies.global.php │ ├── entity-manager.global.php │ ├── error-handler.global.php │ ├── geolite2.global.php │ ├── installer.global.php │ ├── ip-address.global.php │ ├── locks.global.php │ ├── logger.global.php │ ├── mercure.global.php │ ├── middleware-pipeline.global.php │ ├── rabbit.global.php │ ├── router.global.php │ └── routes.config.php ├── cli-app.php ├── cli-config.php ├── config.php ├── constants.php ├── container.php ├── entity-manager.php ├── params │ └── shlink_dev_env.php.dist ├── roadrunner │ ├── .rr.dev.yml │ ├── .rr.test.yml │ └── .rr.yml └── run.php ├── data └── migrations_template.txt ├── docker-compose.ci.yml ├── docker ├── README.md ├── config │ └── php.ini └── docker-entrypoint.sh ├── module ├── CLI │ ├── config │ │ ├── cli.config.php │ │ └── dependencies.config.php │ ├── src │ │ ├── ApiKey │ │ │ ├── RoleResolver.php │ │ │ └── RoleResolverInterface.php │ │ ├── Command │ │ │ ├── Api │ │ │ │ ├── DisableKeyCommand.php │ │ │ │ ├── GenerateKeyCommand.php │ │ │ │ ├── InitialApiKeyCommand.php │ │ │ │ ├── ListKeysCommand.php │ │ │ │ └── RenameApiKeyCommand.php │ │ │ ├── Config │ │ │ │ └── ReadEnvVarCommand.php │ │ │ ├── Db │ │ │ │ ├── AbstractDatabaseCommand.php │ │ │ │ ├── CreateDatabaseCommand.php │ │ │ │ └── MigrateDatabaseCommand.php │ │ │ ├── Domain │ │ │ │ ├── DomainRedirectsCommand.php │ │ │ │ ├── GetDomainVisitsCommand.php │ │ │ │ └── ListDomainsCommand.php │ │ │ ├── Integration │ │ │ │ └── MatomoSendVisitsCommand.php │ │ │ ├── RedirectRule │ │ │ │ └── ManageRedirectRulesCommand.php │ │ │ ├── ShortUrl │ │ │ │ ├── CreateShortUrlCommand.php │ │ │ │ ├── DeleteExpiredShortUrlsCommand.php │ │ │ │ ├── DeleteShortUrlCommand.php │ │ │ │ ├── DeleteShortUrlVisitsCommand.php │ │ │ │ ├── EditShortUrlCommand.php │ │ │ │ ├── GetShortUrlVisitsCommand.php │ │ │ │ ├── ListShortUrlsCommand.php │ │ │ │ └── ResolveUrlCommand.php │ │ │ ├── Tag │ │ │ │ ├── DeleteTagsCommand.php │ │ │ │ ├── GetTagVisitsCommand.php │ │ │ │ ├── ListTagsCommand.php │ │ │ │ └── RenameTagCommand.php │ │ │ ├── Util │ │ │ │ ├── AbstractLockedCommand.php │ │ │ │ └── LockedCommandConfig.php │ │ │ └── Visit │ │ │ │ ├── AbstractDeleteVisitsCommand.php │ │ │ │ ├── AbstractVisitsListCommand.php │ │ │ │ ├── DeleteOrphanVisitsCommand.php │ │ │ │ ├── DownloadGeoLiteDbCommand.php │ │ │ │ ├── GetNonOrphanVisitsCommand.php │ │ │ │ ├── GetOrphanVisitsCommand.php │ │ │ │ └── LocateVisitsCommand.php │ │ ├── ConfigProvider.php │ │ ├── Exception │ │ │ ├── ExceptionInterface.php │ │ │ └── InvalidRoleConfigException.php │ │ ├── Factory │ │ │ └── ApplicationFactory.php │ │ ├── Input │ │ │ ├── DateOption.php │ │ │ ├── EndDateOption.php │ │ │ ├── ShortUrlDataInput.php │ │ │ ├── ShortUrlDataOption.php │ │ │ ├── ShortUrlIdentifierInput.php │ │ │ └── StartDateOption.php │ │ ├── RedirectRule │ │ │ ├── RedirectRuleHandler.php │ │ │ ├── RedirectRuleHandlerAction.php │ │ │ └── RedirectRuleHandlerInterface.php │ │ └── Util │ │ │ ├── ProcessRunner.php │ │ │ ├── ProcessRunnerInterface.php │ │ │ └── ShlinkTable.php │ └── test-cli │ │ └── Command │ │ ├── CreateShortUrlTest.php │ │ ├── GenerateApiKeyTest.php │ │ ├── ImportShortUrlsTest.php │ │ ├── InitialApiKeyTest.php │ │ ├── ListApiKeysTest.php │ │ ├── ListShortUrlsTest.php │ │ └── ManageRedirectRulesTest.php ├── Core │ ├── config │ │ ├── dependencies.config.php │ │ ├── entities-mappings │ │ │ ├── Shlinkio.Shlink.Core.Domain.Entity.Domain.php │ │ │ ├── Shlinkio.Shlink.Core.Geolocation.Entity.GeolocationDbUpdate.php │ │ │ ├── Shlinkio.Shlink.Core.RedirectRule.Entity.RedirectCondition.php │ │ │ ├── Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php │ │ │ ├── Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php │ │ │ ├── Shlinkio.Shlink.Core.Tag.Entity.Tag.php │ │ │ ├── Shlinkio.Shlink.Core.Visit.Entity.OrphanVisitsCount.php │ │ │ ├── Shlinkio.Shlink.Core.Visit.Entity.ShortUrlVisitsCount.php │ │ │ ├── Shlinkio.Shlink.Core.Visit.Entity.Visit.php │ │ │ └── Shlinkio.Shlink.Core.Visit.Entity.VisitLocation.php │ │ ├── entity-manager.config.php │ │ └── event_dispatcher.config.php │ ├── functions │ │ ├── array-utils.php │ │ └── functions.php │ ├── migrations │ │ ├── Version20230211171904.php │ │ ├── Version20230303164233.php │ │ ├── Version20240220214031.php │ │ ├── Version20240224115725.php │ │ ├── Version20240226214216.php │ │ ├── Version20240227080629.php │ │ ├── Version20240306132518.php │ │ ├── Version20240318084804.php │ │ ├── Version20240331111103.php │ │ ├── Version20240331111447.php │ │ ├── Version20241105094747.php │ │ ├── Version20241105215309.php │ │ ├── Version20241124112257.php │ │ ├── Version20241125213106.php │ │ ├── Version20241212131058.php │ │ └── Version20250215100756.php │ ├── src │ │ ├── Action │ │ │ ├── AbstractTrackingAction.php │ │ │ ├── Model │ │ │ │ └── QrCodeParams.php │ │ │ ├── PixelAction.php │ │ │ ├── QrCodeAction.php │ │ │ ├── RedirectAction.php │ │ │ └── RobotsAction.php │ │ ├── Config │ │ │ ├── EmptyNotFoundRedirectConfig.php │ │ │ ├── EnvVars.php │ │ │ ├── NotFoundRedirectConfigInterface.php │ │ │ ├── NotFoundRedirectResolver.php │ │ │ ├── NotFoundRedirectResolverInterface.php │ │ │ ├── NotFoundRedirects.php │ │ │ ├── Options │ │ │ │ ├── AppOptions.php │ │ │ │ ├── DeleteShortUrlsOptions.php │ │ │ │ ├── ExtraPathMode.php │ │ │ │ ├── NotFoundRedirectOptions.php │ │ │ │ ├── QrCodeOptions.php │ │ │ │ ├── RabbitMqOptions.php │ │ │ │ ├── RedirectOptions.php │ │ │ │ ├── RobotsOptions.php │ │ │ │ ├── TrackingOptions.php │ │ │ │ └── UrlShortenerOptions.php │ │ │ └── PostProcessor │ │ │ │ ├── BasePathPrefixer.php │ │ │ │ ├── MultiSegmentSlugProcessor.php │ │ │ │ └── ShortUrlMethodsProcessor.php │ │ ├── ConfigProvider.php │ │ ├── Crawling │ │ │ ├── CrawlingHelper.php │ │ │ └── CrawlingHelperInterface.php │ │ ├── Domain │ │ │ ├── DomainService.php │ │ │ ├── DomainServiceInterface.php │ │ │ ├── Entity │ │ │ │ └── Domain.php │ │ │ ├── Model │ │ │ │ └── DomainItem.php │ │ │ ├── Repository │ │ │ │ ├── DomainRepository.php │ │ │ │ └── DomainRepositoryInterface.php │ │ │ ├── Spec │ │ │ │ └── IsDomain.php │ │ │ └── Validation │ │ │ │ └── DomainRedirectsInputFilter.php │ │ ├── ErrorHandler │ │ │ ├── Model │ │ │ │ └── NotFoundType.php │ │ │ ├── NotFoundRedirectHandler.php │ │ │ ├── NotFoundTemplateHandler.php │ │ │ ├── NotFoundTrackerMiddleware.php │ │ │ └── NotFoundTypeResolverMiddleware.php │ │ ├── EventDispatcher │ │ │ ├── Async │ │ │ │ ├── AbstractAsyncListener.php │ │ │ │ ├── AbstractNotifyNewShortUrlListener.php │ │ │ │ ├── AbstractNotifyVisitListener.php │ │ │ │ └── RemoteSystem.php │ │ │ ├── CloseDbConnectionEventListener.php │ │ │ ├── CloseDbConnectionEventListenerDelegator.php │ │ │ ├── Event │ │ │ │ ├── GeoLiteDbCreated.php │ │ │ │ ├── ShortUrlCreated.php │ │ │ │ └── UrlVisited.php │ │ │ ├── Helper │ │ │ │ ├── EnabledListenerChecker.php │ │ │ │ └── RequestIdProvider.php │ │ │ ├── LocateUnlocatedVisits.php │ │ │ ├── Matomo │ │ │ │ └── SendVisitToMatomo.php │ │ │ ├── Mercure │ │ │ │ ├── NotifyNewShortUrlToMercure.php │ │ │ │ └── NotifyVisitToMercure.php │ │ │ ├── PublishingUpdatesGenerator.php │ │ │ ├── PublishingUpdatesGeneratorInterface.php │ │ │ ├── RabbitMq │ │ │ │ ├── NotifyNewShortUrlToRabbitMq.php │ │ │ │ └── NotifyVisitToRabbitMq.php │ │ │ ├── RedisPubSub │ │ │ │ ├── NotifyNewShortUrlToRedis.php │ │ │ │ └── NotifyVisitToRedis.php │ │ │ ├── Topic.php │ │ │ └── UpdateGeoLiteDb.php │ │ ├── Exception │ │ │ ├── DeleteShortUrlException.php │ │ │ ├── DomainException.php │ │ │ ├── DomainNotFoundException.php │ │ │ ├── ExceptionInterface.php │ │ │ ├── ForbiddenTagOperationException.php │ │ │ ├── GeolocationDbUpdateFailedException.php │ │ │ ├── InvalidArgumentException.php │ │ │ ├── InvalidIpFormatException.php │ │ │ ├── IpCannotBeLocatedException.php │ │ │ ├── MalformedBodyException.php │ │ │ ├── NonUniqueSlugException.php │ │ │ ├── RuntimeException.php │ │ │ ├── ShortCodeCannotBeRegeneratedException.php │ │ │ ├── ShortUrlNotFoundException.php │ │ │ ├── TagConflictException.php │ │ │ ├── TagNotFoundException.php │ │ │ └── ValidationException.php │ │ ├── Geolocation │ │ │ ├── Entity │ │ │ │ ├── GeolocationDbUpdate.php │ │ │ │ └── GeolocationDbUpdateStatus.php │ │ │ ├── GeolocationDbUpdater.php │ │ │ ├── GeolocationDbUpdaterInterface.php │ │ │ ├── GeolocationDownloadProgressHandlerInterface.php │ │ │ ├── GeolocationResult.php │ │ │ └── Middleware │ │ │ │ └── IpGeolocationMiddleware.php │ │ ├── Importer │ │ │ ├── ImportedLinksProcessor.php │ │ │ └── ShortUrlImporting.php │ │ ├── Matomo │ │ │ ├── MatomoOptions.php │ │ │ ├── MatomoTrackerBuilder.php │ │ │ ├── MatomoTrackerBuilderInterface.php │ │ │ ├── MatomoVisitSender.php │ │ │ ├── MatomoVisitSenderInterface.php │ │ │ ├── Model │ │ │ │ └── SendVisitsResult.php │ │ │ └── VisitSendingProgressTrackerInterface.php │ │ ├── Middleware │ │ │ └── ReverseForwardedAddressesMiddlewareDecorator.php │ │ ├── Model │ │ │ ├── AbstractInfinitePaginableListParams.php │ │ │ ├── BulkDeleteResult.php │ │ │ ├── DeviceType.php │ │ │ ├── Ordering.php │ │ │ └── Renaming.php │ │ ├── Paginator │ │ │ └── Adapter │ │ │ │ └── AbstractCacheableCountPaginatorAdapter.php │ │ ├── RedirectRule │ │ │ ├── Entity │ │ │ │ ├── RedirectCondition.php │ │ │ │ └── ShortUrlRedirectRule.php │ │ │ ├── Model │ │ │ │ ├── RedirectConditionType.php │ │ │ │ ├── RedirectRulesData.php │ │ │ │ └── Validation │ │ │ │ │ └── RedirectRulesInputFilter.php │ │ │ ├── ShortUrlRedirectRuleService.php │ │ │ ├── ShortUrlRedirectRuleServiceInterface.php │ │ │ ├── ShortUrlRedirectionResolver.php │ │ │ └── ShortUrlRedirectionResolverInterface.php │ │ ├── Repository │ │ │ └── EntityRepositoryInterface.php │ │ ├── ShortUrl │ │ │ ├── DeleteShortUrlService.php │ │ │ ├── DeleteShortUrlServiceInterface.php │ │ │ ├── Entity │ │ │ │ └── ShortUrl.php │ │ │ ├── Helper │ │ │ │ ├── ShortCodeUniquenessHelper.php │ │ │ │ ├── ShortCodeUniquenessHelperInterface.php │ │ │ │ ├── ShortUrlRedirectionBuilder.php │ │ │ │ ├── ShortUrlRedirectionBuilderInterface.php │ │ │ │ ├── ShortUrlStringifier.php │ │ │ │ ├── ShortUrlStringifierInterface.php │ │ │ │ ├── ShortUrlTitleResolutionHelper.php │ │ │ │ ├── ShortUrlTitleResolutionHelperInterface.php │ │ │ │ └── TitleResolutionModelInterface.php │ │ │ ├── Middleware │ │ │ │ ├── ExtraPathRedirectMiddleware.php │ │ │ │ └── TrimTrailingSlashMiddleware.php │ │ │ ├── Model │ │ │ │ ├── ExpiredShortUrlsConditions.php │ │ │ │ ├── OrderableField.php │ │ │ │ ├── ShortUrlCreation.php │ │ │ │ ├── ShortUrlEdition.php │ │ │ │ ├── ShortUrlIdentifier.php │ │ │ │ ├── ShortUrlMode.php │ │ │ │ ├── ShortUrlWithDeps.php │ │ │ │ ├── ShortUrlsParams.php │ │ │ │ ├── TagsMode.php │ │ │ │ ├── UrlShorteningResult.php │ │ │ │ └── Validation │ │ │ │ │ ├── CustomSlugFilter.php │ │ │ │ │ ├── CustomSlugValidator.php │ │ │ │ │ ├── ShortUrlInputFilter.php │ │ │ │ │ └── ShortUrlsParamsInputFilter.php │ │ │ ├── Paginator │ │ │ │ └── Adapter │ │ │ │ │ └── ShortUrlRepositoryAdapter.php │ │ │ ├── Persistence │ │ │ │ ├── ShortUrlsCountFiltering.php │ │ │ │ └── ShortUrlsListFiltering.php │ │ │ ├── Repository │ │ │ │ ├── CrawlableShortCodesQuery.php │ │ │ │ ├── CrawlableShortCodesQueryInterface.php │ │ │ │ ├── ExpiredShortUrlsRepository.php │ │ │ │ ├── ExpiredShortUrlsRepositoryInterface.php │ │ │ │ ├── ShortUrlListRepository.php │ │ │ │ ├── ShortUrlListRepositoryInterface.php │ │ │ │ ├── ShortUrlRepository.php │ │ │ │ └── ShortUrlRepositoryInterface.php │ │ │ ├── Resolver │ │ │ │ ├── PersistenceShortUrlRelationResolver.php │ │ │ │ ├── ShortUrlRelationResolverInterface.php │ │ │ │ └── SimpleShortUrlRelationResolver.php │ │ │ ├── ShortUrlListService.php │ │ │ ├── ShortUrlListServiceInterface.php │ │ │ ├── ShortUrlResolver.php │ │ │ ├── ShortUrlResolverInterface.php │ │ │ ├── ShortUrlService.php │ │ │ ├── ShortUrlServiceInterface.php │ │ │ ├── ShortUrlVisitsDeleter.php │ │ │ ├── ShortUrlVisitsDeleterInterface.php │ │ │ ├── Spec │ │ │ │ ├── BelongsToApiKey.php │ │ │ │ ├── BelongsToApiKeyInlined.php │ │ │ │ ├── BelongsToDomain.php │ │ │ │ └── BelongsToDomainInlined.php │ │ │ ├── Transformer │ │ │ │ ├── ShortUrlDataTransformer.php │ │ │ │ └── ShortUrlDataTransformerInterface.php │ │ │ ├── UrlShortener.php │ │ │ └── UrlShortenerInterface.php │ │ ├── Spec │ │ │ └── InDateRange.php │ │ ├── Tag │ │ │ ├── Entity │ │ │ │ └── Tag.php │ │ │ ├── Model │ │ │ │ ├── OrderableField.php │ │ │ │ ├── TagInfo.php │ │ │ │ ├── TagsListFiltering.php │ │ │ │ └── TagsParams.php │ │ │ ├── Paginator │ │ │ │ └── Adapter │ │ │ │ │ ├── AbstractTagsPaginatorAdapter.php │ │ │ │ │ ├── TagsInfoPaginatorAdapter.php │ │ │ │ │ └── TagsPaginatorAdapter.php │ │ │ ├── Repository │ │ │ │ ├── TagRepository.php │ │ │ │ └── TagRepositoryInterface.php │ │ │ ├── Spec │ │ │ │ └── CountTagsWithName.php │ │ │ ├── TagService.php │ │ │ └── TagServiceInterface.php │ │ ├── Util │ │ │ ├── DoctrineBatchHelper.php │ │ │ ├── DoctrineBatchHelperInterface.php │ │ │ ├── IpAddressUtils.php │ │ │ ├── RedirectResponseHelper.php │ │ │ ├── RedirectResponseHelperInterface.php │ │ │ └── RedirectStatus.php │ │ └── Visit │ │ │ ├── Entity │ │ │ ├── OrphanVisitsCount.php │ │ │ ├── ShortUrlVisitsCount.php │ │ │ ├── Visit.php │ │ │ └── VisitLocation.php │ │ │ ├── Geolocation │ │ │ ├── VisitGeolocationHelperInterface.php │ │ │ ├── VisitLocator.php │ │ │ ├── VisitLocatorInterface.php │ │ │ ├── VisitToLocationHelper.php │ │ │ └── VisitToLocationHelperInterface.php │ │ │ ├── Listener │ │ │ ├── OrphanVisitsCountTracker.php │ │ │ └── ShortUrlVisitsCountTracker.php │ │ │ ├── Model │ │ │ ├── OrphanVisitType.php │ │ │ ├── OrphanVisitsParams.php │ │ │ ├── UnlocatableIpType.php │ │ │ ├── VisitType.php │ │ │ ├── Visitor.php │ │ │ ├── VisitsParams.php │ │ │ ├── VisitsStats.php │ │ │ └── VisitsSummary.php │ │ │ ├── Paginator │ │ │ └── Adapter │ │ │ │ ├── DomainVisitsPaginatorAdapter.php │ │ │ │ ├── NonOrphanVisitsPaginatorAdapter.php │ │ │ │ ├── OrphanVisitsPaginatorAdapter.php │ │ │ │ ├── ShortUrlVisitsPaginatorAdapter.php │ │ │ │ └── TagVisitsPaginatorAdapter.php │ │ │ ├── Persistence │ │ │ ├── OrphanVisitsCountFiltering.php │ │ │ ├── OrphanVisitsListFiltering.php │ │ │ ├── VisitsCountFiltering.php │ │ │ └── VisitsListFiltering.php │ │ │ ├── Repository │ │ │ ├── OrphanVisitsCountRepository.php │ │ │ ├── OrphanVisitsCountRepositoryInterface.php │ │ │ ├── ShortUrlVisitsCountRepository.php │ │ │ ├── ShortUrlVisitsCountRepositoryInterface.php │ │ │ ├── VisitDeleterRepository.php │ │ │ ├── VisitDeleterRepositoryInterface.php │ │ │ ├── VisitIterationRepository.php │ │ │ ├── VisitIterationRepositoryInterface.php │ │ │ ├── VisitRepository.php │ │ │ └── VisitRepositoryInterface.php │ │ │ ├── RequestTracker.php │ │ │ ├── RequestTrackerInterface.php │ │ │ ├── Spec │ │ │ ├── CountOfNonOrphanVisits.php │ │ │ └── CountOfOrphanVisits.php │ │ │ ├── VisitsDeleter.php │ │ │ ├── VisitsDeleterInterface.php │ │ │ ├── VisitsStatsHelper.php │ │ │ ├── VisitsStatsHelperInterface.php │ │ │ ├── VisitsTracker.php │ │ │ └── VisitsTrackerInterface.php │ ├── templates │ │ ├── 404.html │ │ └── invalid-short-code.html │ └── test-api │ │ └── Action │ │ ├── QrCodeTest.php │ │ ├── RedirectTest.php │ │ └── RobotsTest.php └── Rest │ ├── config │ ├── access-logs.config.php │ ├── auth.config.php │ ├── dependencies.config.php │ ├── entities-mappings │ │ ├── Shlinkio.Shlink.Rest.Entity.ApiKey.php │ │ └── Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php │ └── entity-manager.config.php │ ├── src │ ├── Action │ │ ├── AbstractRestAction.php │ │ ├── Domain │ │ │ ├── DomainRedirectsAction.php │ │ │ ├── ListDomainsAction.php │ │ │ └── Request │ │ │ │ └── DomainRedirectsRequest.php │ │ ├── HealthAction.php │ │ ├── MercureInfoAction.php │ │ ├── RedirectRule │ │ │ ├── ListRedirectRulesAction.php │ │ │ └── SetRedirectRulesAction.php │ │ ├── ShortUrl │ │ │ ├── AbstractCreateShortUrlAction.php │ │ │ ├── CreateShortUrlAction.php │ │ │ ├── DeleteShortUrlAction.php │ │ │ ├── DeleteShortUrlVisitsAction.php │ │ │ ├── EditShortUrlAction.php │ │ │ ├── ListShortUrlsAction.php │ │ │ ├── ResolveShortUrlAction.php │ │ │ └── SingleStepCreateShortUrlAction.php │ │ ├── Tag │ │ │ ├── DeleteTagsAction.php │ │ │ ├── ListTagsAction.php │ │ │ ├── TagsStatsAction.php │ │ │ └── UpdateTagAction.php │ │ └── Visit │ │ │ ├── AbstractListVisitsAction.php │ │ │ ├── DeleteOrphanVisitsAction.php │ │ │ ├── DomainVisitsAction.php │ │ │ ├── GlobalVisitsAction.php │ │ │ ├── NonOrphanVisitsAction.php │ │ │ ├── OrphanVisitsAction.php │ │ │ ├── ShortUrlVisitsAction.php │ │ │ └── TagVisitsAction.php │ ├── ApiKey │ │ ├── Model │ │ │ ├── ApiKeyMeta.php │ │ │ └── RoleDefinition.php │ │ ├── Repository │ │ │ ├── ApiKeyRepository.php │ │ │ └── ApiKeyRepositoryInterface.php │ │ ├── Role.php │ │ └── Spec │ │ │ └── WithApiKeySpecsEnsuringJoin.php │ ├── ConfigProvider.php │ ├── Entity │ │ ├── ApiKey.php │ │ └── ApiKeyRole.php │ ├── Exception │ │ ├── ApiKeyConflictException.php │ │ ├── ApiKeyNotFoundException.php │ │ ├── ExceptionInterface.php │ │ ├── MercureException.php │ │ ├── MissingAuthenticationException.php │ │ ├── RuntimeException.php │ │ └── VerifyAuthenticationException.php │ ├── Middleware │ │ ├── AuthenticationMiddleware.php │ │ ├── BodyParserMiddleware.php │ │ ├── CrossDomainMiddleware.php │ │ ├── EmptyResponseImplicitOptionsMiddlewareFactory.php │ │ ├── Mercure │ │ │ └── NotConfiguredMercureErrorHandler.php │ │ └── ShortUrl │ │ │ ├── CreateShortUrlContentNegotiationMiddleware.php │ │ │ ├── DefaultShortCodesLengthMiddleware.php │ │ │ ├── DropDefaultDomainFromRequestMiddleware.php │ │ │ └── OverrideDomainMiddleware.php │ └── Service │ │ ├── ApiKeyCheckResult.php │ │ ├── ApiKeyService.php │ │ └── ApiKeyServiceInterface.php │ └── test-db │ └── ApiKey │ └── Repository │ └── ApiKeyRepositoryTest.php ├── phpstan.neon ├── phpunit-cli.xml └── public ├── .htaccess ├── favicon.ico └── index.php /.dockerignore: -------------------------------------------------------------------------------- 1 | bin/rr 2 | config/autoload/*local* 3 | config/params/shlink_dev_env.* 4 | data/infra 5 | data/cache/* 6 | data/log/* 7 | data/locks/* 8 | data/proxies/* 9 | data/migrations_template.txt 10 | data/GeoLite2-City* 11 | data/database.sqlite 12 | data/shlink-tests.db 13 | CHANGELOG.md 14 | CONTRIBUTING.md 15 | UPGRADE.md 16 | composer.lock 17 | vendor 18 | docs 19 | indocker 20 | docker-* 21 | phpstan.neon 22 | php*xml* 23 | **/test* 24 | build* 25 | **/.* 26 | !config/roadrunner/.rr.yml 27 | -------------------------------------------------------------------------------- /.github/DISCUSSION_TEMPLATE/help-wanted.yml: -------------------------------------------------------------------------------- 1 | title: 'Help wanted' 2 | body: 3 | - type: input 4 | validations: 5 | required: true 6 | attributes: 7 | label: Shlink version 8 | placeholder: x.y.z 9 | - type: input 10 | validations: 11 | required: true 12 | attributes: 13 | label: PHP version 14 | placeholder: x.y.z 15 | - type: dropdown 16 | validations: 17 | required: true 18 | attributes: 19 | label: How do you serve Shlink 20 | options: 21 | - Self-hosted Apache 22 | - Self-hosted nginx 23 | - Self-hosted RoadRunner 24 | - Docker image 25 | - Other (explain in summary) 26 | - type: dropdown 27 | validations: 28 | required: true 29 | attributes: 30 | label: Database engine 31 | options: 32 | - MySQL 33 | - MariaDB 34 | - PostgreSQL 35 | - MicrosoftSQL 36 | - SQLite 37 | - type: input 38 | validations: 39 | required: true 40 | attributes: 41 | label: Database version 42 | placeholder: x.y.z 43 | - type: textarea 44 | validations: 45 | required: true 46 | attributes: 47 | label: Summary 48 | value: '' 49 | 50 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ['acelaya'] 2 | custom: ['https://slnk.to/donate'] 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_Request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Do you find Shlink is missing some important feature that would make it more useful? 3 | labels: ['feature'] 4 | body: 5 | - type: textarea 6 | validations: 7 | required: true 8 | attributes: 9 | label: Summary 10 | value: '' 11 | - type: textarea 12 | validations: 13 | required: true 14 | attributes: 15 | label: Use case 16 | value: '' 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Question - Support 4 | about: Do you need help setting up or using Shlink? 5 | url: https://github.com/shlinkio/shlink/discussions/new?category=help-wanted 6 | -------------------------------------------------------------------------------- /.github/workflows/ci-docker-image-build.yml: -------------------------------------------------------------------------------- 1 | name: Test docker image build 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - 'Dockerfile' 7 | 8 | jobs: 9 | build-docker-image: 10 | uses: shlinkio/github-actions/.github/workflows/docker-image-build-ci.yml@main 11 | with: 12 | platforms: 'linux/arm64/v8,linux/amd64' 13 | -------------------------------------------------------------------------------- /.github/workflows/publish-docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish docker image 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build-image: 10 | strategy: 11 | matrix: 12 | include: 13 | - runtime: 'rr' 14 | platforms: 'linux/arm64/v8,linux/amd64' 15 | - runtime: 'rr' 16 | tag-suffix: 'roadrunner' 17 | platforms: 'linux/arm64/v8,linux/amd64' 18 | uses: shlinkio/github-actions/.github/workflows/docker-publish-image.yml@main 19 | secrets: inherit 20 | with: 21 | image-name: shlinkio/shlink 22 | version-arg-name: SHLINK_VERSION 23 | platforms: ${{ matrix.platforms }} 24 | tags-suffix: ${{ matrix.tag-suffix }} 25 | extra-build-args: | 26 | SHLINK_RUNTIME=${{ matrix.runtime }} 27 | -------------------------------------------------------------------------------- /.github/workflows/publish-openapi-spec.yml: -------------------------------------------------------------------------------- 1 | name: Publish openapi spec 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-24.04 11 | strategy: 12 | matrix: 13 | php-version: ['8.3'] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Determine version 17 | id: determine_version 18 | run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 19 | shell: bash 20 | - uses: './.github/actions/ci-setup' 21 | with: 22 | php-version: ${{ matrix.php-version }} 23 | extensions-cache-key: publish-openapi-spec-extensions-${{ matrix.php-version }} 24 | - run: composer openapi:inline 25 | - run: mkdir ${{ steps.determine_version.outputs.version }} 26 | - run: mv docs/swagger/openapi-inlined.json ${{ steps.determine_version.outputs.version }}/open-api-spec.json 27 | - name: Publish spec 28 | uses: JamesIves/github-pages-deploy-action@v4 29 | with: 30 | token: ${{ secrets.OAS_PUBLISH_TOKEN }} 31 | repository-name: 'shlinkio/shlink-open-api-specs' 32 | branch: main 33 | folder: ${{ steps.determine_version.outputs.version }} 34 | target-folder: specs/${{ steps.determine_version.outputs.version }} 35 | clean: false 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2024 Alejandro Celaya 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bin/cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | run(); 11 | -------------------------------------------------------------------------------- /bin/doctrine: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | get(Application::class); 21 | $worker = $container->get(PSR7Worker::class); 22 | 23 | while ($req = $worker->waitRequest()) { 24 | try { 25 | $worker->respond($app->handle($req)); 26 | } catch (Throwable $e) { 27 | $worker->getWorker()->error((string) $e); 28 | } 29 | } 30 | } else { 31 | $requestIdMiddleware = $container->get(RequestIdMiddleware::class); 32 | $container->get(RoadRunnerTaskConsumerToListener::class)->listenForTasks( 33 | fn (string $requestId) => $requestIdMiddleware->setCurrentRequestId($requestId), 34 | ); 35 | } 36 | })(); 37 | -------------------------------------------------------------------------------- /bin/test/run-api-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | export APP_ENV=test 4 | export TEST_ENV=api 5 | export TEST_RUNTIME="${TEST_RUNTIME:-"rr"}" # rr is the only runtime currently supported 6 | export DB_DRIVER="${DB_DRIVER:-"postgres"}" 7 | export GENERATE_COVERAGE="${GENERATE_COVERAGE:-"no"}" 8 | 9 | [ "$GENERATE_COVERAGE" != 'no' ] && export XDEBUG_MODE=coverage 10 | 11 | # Reset logs 12 | OUTPUT_LOGS=data/log/api-tests/output.log 13 | rm -rf data/log/api-tests 14 | mkdir data/log/api-tests 15 | touch $OUTPUT_LOGS 16 | 17 | # Try to stop server just in case it hanged in last execution 18 | [ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -f -w . 19 | 20 | echo 'Starting server...' 21 | [ "$TEST_RUNTIME" = 'rr' ] && bin/rr serve -p -w . -c=config/roadrunner/.rr.test.yml \ 22 | -o=logs.output="${PWD}/${OUTPUT_LOGS}" \ 23 | -o=logs.channels.http.output="${PWD}/${OUTPUT_LOGS}" \ 24 | -o=logs.channels.server.output="${PWD}/${OUTPUT_LOGS}" & 25 | sleep 2 # Let's give the server a couple of seconds to start 26 | 27 | vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --testdox-summary $* 28 | TESTS_EXIT_CODE=$? 29 | 30 | [ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -w . 31 | 32 | # Exit this script with the same code as the tests. If tests failed, this script has to fail 33 | exit $TESTS_EXIT_CODE 34 | -------------------------------------------------------------------------------- /bin/test/run-cli-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | export APP_ENV=test 4 | export TEST_ENV=cli 5 | export DB_DRIVER="${DB_DRIVER:-"maria"}" 6 | export GENERATE_COVERAGE="${GENERATE_COVERAGE:-"no"}" 7 | 8 | [ "$GENERATE_COVERAGE" != 'no' ] && export XDEBUG_MODE=coverage 9 | 10 | vendor/bin/phpunit --order-by=random --testdox --testdox-summary -c phpunit-cli.xml $* 11 | TESTS_EXIT_CODE=$? 12 | 13 | # Exit this script with the same code as the tests. If tests failed, this script has to fail 14 | exit $TESTS_EXIT_CODE 15 | -------------------------------------------------------------------------------- /config/autoload/cache.global.php: -------------------------------------------------------------------------------- 1 | loadFromEnv(); 9 | $redis = ['pub_sub_enabled' => $redisServers !== null && EnvVars::REDIS_PUB_SUB_ENABLED->loadFromEnv()]; 10 | $cacheRedisBlock = $redisServers === null ? [] : [ 11 | 'redis' => [ 12 | 'servers' => $redisServers, 13 | 'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(), 14 | ], 15 | ]; 16 | 17 | return [ 18 | 'cache' => [ 19 | 'namespace' => EnvVars::CACHE_NAMESPACE->loadFromEnv(), 20 | ...$cacheRedisBlock, 21 | ], 22 | 'redis' => $redis, 23 | ]; 24 | })(); 25 | -------------------------------------------------------------------------------- /config/autoload/common.global.php: -------------------------------------------------------------------------------- 1 | $isDev, 14 | 15 | // Disabling config cache for cli, ensures it's never used for RoadRunner, and also that console 16 | // commands don't generate a cache file that's then used by php-fpm web executions 17 | ConfigAggregator::ENABLE_CACHE => ! $isDev && PHP_SAPI !== 'cli', 18 | 19 | ]; 20 | })(); 21 | -------------------------------------------------------------------------------- /config/autoload/cors.global.php: -------------------------------------------------------------------------------- 1 | [ 8 | 'max_age' => 3600, 9 | ], 10 | 11 | ]; 12 | -------------------------------------------------------------------------------- /config/autoload/error-handler.global.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'default_types_map' => [ 15 | 404 => toProblemDetailsType('not-found'), 16 | 500 => toProblemDetailsType('internal-server-error'), 17 | ], 18 | ], 19 | 20 | 'error_handler' => [ 21 | 'listeners' => [Logger\ErrorLogger::class], 22 | ], 23 | 24 | 'dependencies' => [ 25 | 'delegators' => [ 26 | ErrorHandler::class => [ 27 | Logger\ErrorHandlerListenerAttachingDelegator::class, 28 | ], 29 | ProblemDetailsMiddleware::class => [ 30 | Logger\ErrorHandlerListenerAttachingDelegator::class, 31 | ], 32 | ], 33 | ], 34 | 35 | ]; 36 | -------------------------------------------------------------------------------- /config/autoload/geolite2.global.php: -------------------------------------------------------------------------------- 1 | [ 10 | 'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb', 11 | 'temp_dir' => __DIR__ . '/../../data', 12 | 'license_key' => EnvVars::GEOLITE_LICENSE_KEY->loadFromEnv(), 13 | ], 14 | 15 | ]; 16 | -------------------------------------------------------------------------------- /config/autoload/mercure.global.php: -------------------------------------------------------------------------------- 1 | [ 15 | 'enabled' => EnvVars::MERCURE_ENABLED->loadFromEnv(), 16 | 'public_hub_url' => EnvVars::MERCURE_PUBLIC_HUB_URL->loadFromEnv(), 17 | 'internal_hub_url' => EnvVars::MERCURE_INTERNAL_HUB_URL->loadFromEnv(), 18 | 'jwt_secret' => EnvVars::MERCURE_JWT_SECRET->loadFromEnv(), 19 | 'jwt_issuer' => 'Shlink', 20 | ], 21 | 22 | 'dependencies' => [ 23 | 'delegators' => [ 24 | LcobucciJwtProvider::class => [ 25 | LazyServiceFactory::class, 26 | ], 27 | Hub::class => [ 28 | LazyServiceFactory::class, 29 | ], 30 | ], 31 | 'lazy_services' => [ 32 | 'class_map' => [ 33 | LcobucciJwtProvider::class => LcobucciJwtProvider::class, 34 | Hub::class => HubInterface::class, 35 | ], 36 | ], 37 | ], 38 | 39 | ]; 40 | -------------------------------------------------------------------------------- /config/autoload/rabbit.global.php: -------------------------------------------------------------------------------- 1 | [ 11 | 'enabled' => (bool) EnvVars::RABBITMQ_ENABLED->loadFromEnv(), 12 | 'host' => EnvVars::RABBITMQ_HOST->loadFromEnv(), 13 | 'use_ssl' => (bool) EnvVars::RABBITMQ_USE_SSL->loadFromEnv(), 14 | 'port' => (int) EnvVars::RABBITMQ_PORT->loadFromEnv(), 15 | 'user' => EnvVars::RABBITMQ_USER->loadFromEnv(), 16 | 'password' => EnvVars::RABBITMQ_PASSWORD->loadFromEnv(), 17 | 'vhost' => EnvVars::RABBITMQ_VHOST->loadFromEnv(), 18 | ], 19 | 20 | ]; 21 | -------------------------------------------------------------------------------- /config/autoload/router.global.php: -------------------------------------------------------------------------------- 1 | [ 11 | 'base_path' => EnvVars::BASE_PATH->loadFromEnv(), 12 | 13 | 'fastroute' => [ 14 | // Disabling config cache for cli, ensures it's never used for RoadRunner, and also that console 15 | // commands don't generate a cache file that's then used by php-fpm web executions 16 | FastRouteRouter::CONFIG_CACHE_ENABLED => EnvVars::isProdEnv() && PHP_SAPI !== 'cli', 17 | FastRouteRouter::CONFIG_CACHE_FILE => 'data/cache/fastroute_cached_routes.php', 18 | ], 19 | ], 20 | 21 | ]; 22 | -------------------------------------------------------------------------------- /config/cli-app.php: -------------------------------------------------------------------------------- 1 | get(CliApp::class); 12 | })(); 13 | -------------------------------------------------------------------------------- /config/cli-config.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'ShlinkMigrations' => 'module/Core/migrations', 15 | ], 16 | 'table_storage' => [ 17 | 'table_name' => 'migrations', 18 | ], 19 | 'custom_template' => 'data/migrations_template.txt', 20 | ]; 21 | $em = include __DIR__ . '/entity-manager.php'; 22 | 23 | return DependencyFactory::fromEntityManager( 24 | new ConfigurationArray($migrationsConfig), 25 | new ExistingEntityManager($em), 26 | ); 27 | })(); 28 | -------------------------------------------------------------------------------- /config/constants.php: -------------------------------------------------------------------------------- 1 | get(EntityManager::class); 13 | })(); 14 | -------------------------------------------------------------------------------- /config/roadrunner/.rr.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | rpc: 4 | listen: tcp://127.0.0.1:6001 5 | 6 | server: 7 | command: 'php ../../bin/roadrunner-worker.php' 8 | 9 | http: 10 | address: '0.0.0.0:8080' 11 | middleware: ['static'] 12 | static: 13 | dir: '../../public' 14 | forbid: ['.php', '.htaccess'] 15 | pool: 16 | num_workers: 1 17 | debug: true 18 | 19 | jobs: 20 | pool: 21 | num_workers: 1 22 | debug: true 23 | timeout: 300 24 | consume: ['shlink'] 25 | pipelines: 26 | shlink: 27 | driver: memory 28 | config: 29 | priority: 10 30 | prefetch: 10 31 | 32 | logs: 33 | mode: development 34 | channels: 35 | http: 36 | mode: 'off' # Disable logging as Shlink handles it internally 37 | server: 38 | level: info 39 | metrics: 40 | level: debug 41 | jobs: 42 | level: debug 43 | -------------------------------------------------------------------------------- /config/roadrunner/.rr.test.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | ############################################################################################ 4 | # Routes here need to be relative to the project root, as API tests are run with `-w .` # 5 | # See https://github.com/orgs/roadrunner-server/discussions/1440#discussioncomment-8486186 # 6 | ############################################################################################ 7 | 8 | rpc: 9 | listen: tcp://127.0.0.1:6001 10 | 11 | server: 12 | command: 'php ./bin/roadrunner-worker.php' 13 | 14 | http: 15 | address: '0.0.0.0:9999' 16 | middleware: ['static'] 17 | static: 18 | dir: './public' 19 | forbid: ['.php', '.htaccess'] 20 | pool: 21 | num_workers: 1 22 | debug: false 23 | 24 | jobs: 25 | pool: 26 | num_workers: 1 27 | debug: false 28 | timeout: 300 29 | consume: ['shlink'] 30 | pipelines: 31 | shlink: 32 | driver: memory 33 | config: 34 | priority: 10 35 | prefetch: 10 36 | 37 | logs: 38 | encoding: json 39 | mode: development 40 | channels: 41 | http: 42 | mode: 'off' # Disable logging as Shlink handles it internally 43 | server: 44 | encoding: json 45 | level: info 46 | metrics: 47 | level: panic 48 | jobs: 49 | level: panic 50 | -------------------------------------------------------------------------------- /config/roadrunner/.rr.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | rpc: 4 | listen: tcp://127.0.0.1:6001 5 | 6 | server: 7 | command: 'php -dopcache.enable_cli=1 -dopcache.validate_timestamps=0 ../../bin/roadrunner-worker.php' 8 | 9 | http: 10 | address: '${ADDRESS:-0.0.0.0}:${PORT:-8080}' 11 | middleware: ['static'] 12 | static: 13 | dir: '../../public' 14 | forbid: ['.php', '.htaccess'] 15 | pool: 16 | num_workers: ${WEB_WORKER_NUM:-0} 17 | 18 | jobs: 19 | timeout: 300 # 5 minutes 20 | pool: 21 | num_workers: ${TASK_WORKER_NUM:-0} 22 | consume: ['shlink'] 23 | pipelines: 24 | shlink: 25 | driver: memory 26 | config: 27 | priority: 10 28 | prefetch: 10 29 | 30 | logs: 31 | mode: production 32 | channels: 33 | http: 34 | mode: 'off' # Disable logging as Shlink handles it internally 35 | server: 36 | level: info 37 | jobs: 38 | level: debug 39 | -------------------------------------------------------------------------------- /config/run.php: -------------------------------------------------------------------------------- 1 | get(Application::class); 12 | 13 | $app->run(); 14 | }; 15 | -------------------------------------------------------------------------------- /data/migrations_template.txt: -------------------------------------------------------------------------------- 1 | ; 6 | 7 | use Doctrine\DBAL\Platforms\MySQLPlatform; 8 | use Doctrine\DBAL\Schema\Schema; 9 | use Doctrine\Migrations\AbstractMigration; 10 | 11 | final class extends AbstractMigration 12 | { 13 | public function up(Schema $schema): void 14 | { 15 | 16 | } 17 | 18 | public function down(Schema $schema): void 19 | { 20 | 21 | } 22 | 23 | public function isTransactional(): bool 24 | { 25 | return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /docker-compose.ci.yml: -------------------------------------------------------------------------------- 1 | services: 2 | shlink_db_mysql: 3 | user: '0' 4 | environment: 5 | MYSQL_DATABASE: shlink_test 6 | 7 | shlink_db_postgres: 8 | user: '0' 9 | environment: 10 | POSTGRES_DB: shlink_test 11 | 12 | shlink_db_maria: 13 | user: '0' 14 | environment: 15 | MYSQL_DATABASE: shlink_test 16 | -------------------------------------------------------------------------------- /docker/config/php.ini: -------------------------------------------------------------------------------- 1 | log_errors_max_len=0 2 | zend.assertions=1 3 | assert.exception=1 4 | -------------------------------------------------------------------------------- /docker/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -e 3 | 4 | cd /etc/shlink 5 | 6 | # Create data directories if they do not exist. This allows data dir to be mounted as an empty dir if needed 7 | mkdir -p data/cache data/locks data/log data/proxies 8 | 9 | flags="--no-interaction --clear-db-cache" 10 | 11 | # Read env vars through Shlink command, so that it applies the `_FILE` env var fallback logic 12 | geolite_license_key=$(bin/cli env-var:read GEOLITE_LICENSE_KEY) 13 | skip_initial_geolite_download=$(bin/cli env-var:read SKIP_INITIAL_GEOLITE_DOWNLOAD) 14 | initial_api_key=$(bin/cli env-var:read INITIAL_API_KEY) 15 | 16 | # Skip downloading GeoLite2 db file if the license key env var was not defined or skipping was explicitly set 17 | if [ -z "${geolite_license_key}" ] || [ "${skip_initial_geolite_download}" = "true" ]; then 18 | flags="${flags} --skip-download-geolite" 19 | fi 20 | 21 | # If INITIAL_API_KEY was provided, create an initial API key 22 | if [ -n "${initial_api_key}" ]; then 23 | flags="${flags} --initial-api-key=${initial_api_key}" 24 | fi 25 | 26 | php vendor/bin/shlink-installer init ${flags} 27 | 28 | if [ "$SHLINK_RUNTIME" = 'rr' ]; then 29 | # Run with `exec` so that signals are properly handled 30 | exec ./bin/rr serve -c config/roadrunner/.rr.yml 31 | fi 32 | -------------------------------------------------------------------------------- /module/CLI/src/ApiKey/RoleResolverInterface.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | public function determineRoles(InputInterface $input): iterable; 16 | } 17 | -------------------------------------------------------------------------------- /module/CLI/src/Command/Db/AbstractDatabaseCommand.php: -------------------------------------------------------------------------------- 1 | phpBinary = $phpFinder->find(false) ?: 'php'; 25 | } 26 | 27 | protected function runPhpCommand(OutputInterface $output, array $command): void 28 | { 29 | $command = [$this->phpBinary, ...$command, '--no-interaction']; 30 | $this->processRunner->run($output, $command); 31 | } 32 | 33 | protected function getLockConfig(): LockedCommandConfig 34 | { 35 | return LockedCommandConfig::blocking($this->getName() ?? static::class); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /module/CLI/src/Command/Db/MigrateDatabaseCommand.php: -------------------------------------------------------------------------------- 1 | setName(self::NAME) 21 | ->setHidden() 22 | ->setDescription('Runs database migrations, which will ensure the shlink database is up to date.'); 23 | } 24 | 25 | protected function lockedExecute(InputInterface $input, OutputInterface $output): int 26 | { 27 | $io = new SymfonyStyle($input, $output); 28 | 29 | $io->writeln('Migrating database...'); 30 | $this->runPhpCommand($output, [self::DOCTRINE_MIGRATIONS_SCRIPT, self::DOCTRINE_MIGRATE_COMMAND]); 31 | $io->success('Database properly migrated!'); 32 | 33 | return self::SUCCESS; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /module/CLI/src/Command/Util/LockedCommandConfig.php: -------------------------------------------------------------------------------- 1 | confirm($io)) { 18 | $io->info('Operation aborted'); 19 | return self::SUCCESS; 20 | } 21 | 22 | return $this->doExecute($input, $io); 23 | } 24 | 25 | private function confirm(SymfonyStyle $io): bool 26 | { 27 | $io->warning($this->getWarningMessage()); 28 | return $io->confirm('Continue deleting visits?', false); 29 | } 30 | 31 | abstract protected function doExecute(InputInterface $input, SymfonyStyle $io): int; 32 | 33 | abstract protected function getWarningMessage(): string; 34 | } 35 | -------------------------------------------------------------------------------- /module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php: -------------------------------------------------------------------------------- 1 | setName(self::NAME) 26 | ->setDescription('Deletes all orphan visits'); 27 | } 28 | 29 | protected function doExecute(InputInterface $input, SymfonyStyle $io): int 30 | { 31 | $result = $this->deleter->deleteOrphanVisits(); 32 | $io->success(sprintf('Successfully deleted %s visits', $result->affectedItems)); 33 | 34 | return self::SUCCESS; 35 | } 36 | 37 | protected function getWarningMessage(): string 38 | { 39 | return 'You are about to delete all orphan visits. This operation cannot be undone.'; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /module/CLI/src/ConfigProvider.php: -------------------------------------------------------------------------------- 1 | value, 20 | )); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /module/CLI/src/Factory/ApplicationFactory.php: -------------------------------------------------------------------------------- 1 | get('config')['cli']; 17 | $appOptions = $container->get(AppOptions::class); 18 | 19 | $commands = $config['commands'] ?? []; 20 | $app = new CliApp($appOptions->name, $appOptions->version); 21 | $app->setCommandLoader(new ContainerCommandLoader($container, $commands)); 22 | 23 | return $app; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /module/CLI/src/Input/EndDateOption.php: -------------------------------------------------------------------------------- 1 | dateOption = new DateOption($command, 'end-date', 'e', sprintf( 21 | 'Allows to filter %s, returning only those newer than provided date.', 22 | $descriptionHint, 23 | )); 24 | } 25 | 26 | public function get(InputInterface $input, OutputInterface $output): Chronos|null 27 | { 28 | return $this->dateOption->get($input, $output); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /module/CLI/src/Input/ShortUrlDataOption.php: -------------------------------------------------------------------------------- 1 | 't', 25 | self::VALID_SINCE => 's', 26 | self::VALID_UNTIL => 'u', 27 | self::MAX_VISITS => 'm', 28 | self::TITLE => null, 29 | self::CRAWLABLE => 'r', 30 | self::NO_FORWARD_QUERY => 'w', 31 | }; 32 | } 33 | 34 | public function wasProvided(InputInterface $input): bool 35 | { 36 | $option = sprintf('--%s', $this->value); 37 | $shortcut = $this->shortcut(); 38 | 39 | return $input->hasParameterOption($shortcut === null ? $option : [$option, sprintf('-%s', $shortcut)]); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /module/CLI/src/Input/ShortUrlIdentifierInput.php: -------------------------------------------------------------------------------- 1 | addArgument('shortCode', InputArgument::REQUIRED, $shortCodeDesc) 19 | ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, $domainDesc); 20 | } 21 | 22 | public function shortCode(InputInterface $input): string|null 23 | { 24 | return $input->getArgument('shortCode'); 25 | } 26 | 27 | public function toShortUrlIdentifier(InputInterface $input): ShortUrlIdentifier 28 | { 29 | $shortCode = $input->getArgument('shortCode'); 30 | $domain = $input->getOption('domain'); 31 | 32 | return ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /module/CLI/src/Input/StartDateOption.php: -------------------------------------------------------------------------------- 1 | dateOption = new DateOption($command, 'start-date', 's', sprintf( 21 | 'Allows to filter %s, returning only those older than provided date.', 22 | $descriptionHint, 23 | )); 24 | } 25 | 26 | public function get(InputInterface $input, OutputInterface $output): Chronos|null 27 | { 28 | return $this->dateOption->get($input, $output); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /module/CLI/src/RedirectRule/RedirectRuleHandlerAction.php: -------------------------------------------------------------------------------- 1 | exec( 23 | [CreateShortUrlCommand::NAME, 'https://example.com', '--domain', $defaultDomain, '--custom-slug', $slug], 24 | ); 25 | 26 | self::assertEquals(Command::SUCCESS, $exitCode); 27 | self::assertStringContainsString('Generated short URL: http://' . $defaultDomain . '/' . $slug, $output); 28 | 29 | [$listOutput] = $this->exec([ListShortUrlsCommand::NAME, '--show-domain', '--search-term', $slug]); 30 | self::assertStringContainsString(Domain::DEFAULT_AUTHORITY, $listOutput); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /module/CLI/test-cli/Command/GenerateApiKeyTest.php: -------------------------------------------------------------------------------- 1 | exec([GenerateKeyCommand::NAME]); 18 | 19 | self::assertStringContainsString('[OK] Generated API key', $output); 20 | self::assertEquals(Command::SUCCESS, $exitCode); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /module/CLI/test-cli/Command/InitialApiKeyTest.php: -------------------------------------------------------------------------------- 1 | exec([InitialApiKeyCommand::NAME, 'new_api_key', '-v']); 17 | 18 | self::assertEquals( 19 | <<exec([ManageRedirectRulesCommand::NAME, 'abc123'], [ 17 | '0', // Add new rule 18 | 'not-a-number', // Invalid priority 19 | '1', // Valid priority, to continue execution 20 | 'invalid-long-url', // Invalid long URL 21 | 'https://example.com', // Valid long URL, to continue execution 22 | '1', // Language condition type 23 | '', // Invalid required language 24 | 'es-ES', // Valid language, to continue execution 25 | 'no', // Do not add more conditions 26 | '4', // Discard changes 27 | ]); 28 | 29 | self::assertStringContainsString('The priority must be a numeric positive value', $output); 30 | self::assertStringContainsString('The input is not valid', $output); 31 | self::assertStringContainsString('The value is mandatory', $output); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Tag.Entity.Tag.php: -------------------------------------------------------------------------------- 1 | setTable(determineTableName('tags', $emConfig)) 15 | ->setCustomRepositoryClass(Tag\Repository\TagRepository::class); 16 | 17 | $builder->createField('id', Types::BIGINT) 18 | ->columnName('id') 19 | ->makePrimaryKey() 20 | ->generatedValue('IDENTITY') 21 | ->option('unsigned', true) 22 | ->build(); 23 | 24 | fieldWithUtf8Charset($builder->createField('name', Types::STRING), $emConfig) 25 | ->unique() 26 | ->build(); 27 | 28 | $builder->addInverseManyToMany('shortUrls', ShortUrl\Entity\ShortUrl::class, 'tags'); 29 | }; 30 | -------------------------------------------------------------------------------- /module/Core/config/entity-manager.config.php: -------------------------------------------------------------------------------- 1 | [ 8 | 'orm' => [ 9 | 'entities_mappings' => [ 10 | __DIR__ . '/../config/entities-mappings', 11 | ], 12 | ], 13 | ], 14 | 15 | ]; 16 | -------------------------------------------------------------------------------- /module/Core/migrations/Version20230211171904.php: -------------------------------------------------------------------------------- 1 | getTable('visits'); 18 | $this->skipIf($visits->hasIndex(self::INDEX_NAME)); 19 | 20 | $visits->addIndex(['short_url_id', 'potential_bot'], self::INDEX_NAME); 21 | } 22 | 23 | public function isTransactional(): bool 24 | { 25 | return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /module/Core/migrations/Version20230303164233.php: -------------------------------------------------------------------------------- 1 | getTable('visits'); 18 | $this->skipIf($visits->hasIndex(self::INDEX_NAME)); 19 | 20 | $visits->dropIndex('IDX_visits_potential_bot'); // Old index 21 | $visits->addIndex(['potential_bot'], self::INDEX_NAME); 22 | } 23 | 24 | public function isTransactional(): bool 25 | { 26 | return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /module/Core/migrations/Version20241105215309.php: -------------------------------------------------------------------------------- 1 | connection->quoteIdentifier('key'); 21 | 22 | $qb = $this->connection->createQueryBuilder(); 23 | $qb->select($keyColumnName) 24 | ->from('api_keys'); 25 | $result = $qb->executeQuery(); 26 | 27 | $updateQb = $this->connection->createQueryBuilder(); 28 | $updateQb 29 | ->update('api_keys') 30 | ->set($keyColumnName, ':encryptedKey') 31 | ->where($updateQb->expr()->eq($keyColumnName, ':plainTextKey')); 32 | 33 | while ($key = $result->fetchOne()) { 34 | $updateQb->setParameters([ 35 | 'encryptedKey' => hash('sha256', $key), 36 | 'plainTextKey' => $key, 37 | ])->executeStatement(); 38 | } 39 | } 40 | 41 | public function isTransactional(): bool 42 | { 43 | return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /module/Core/migrations/Version20241124112257.php: -------------------------------------------------------------------------------- 1 | getTable('visits'); 19 | $this->skipIf($visits->hasColumn(self::COLUMN_NAME)); 20 | 21 | $visits->addColumn(self::COLUMN_NAME, Types::STRING, [ 22 | 'length' => 2048, 23 | 'notnull' => false, 24 | 'default' => null, 25 | ]); 26 | } 27 | 28 | public function down(Schema $schema): void 29 | { 30 | $visits = $schema->getTable('visits'); 31 | $this->skipIf(! $visits->hasColumn(self::COLUMN_NAME)); 32 | $visits->dropColumn(self::COLUMN_NAME); 33 | } 34 | 35 | public function isTransactional(): bool 36 | { 37 | return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /module/Core/migrations/Version20241125213106.php: -------------------------------------------------------------------------------- 1 | skipIf(! $this->isMsSql()); 16 | 17 | // Recreate unique_short_code_plus_domain index in Microsoft SQL, as it accidentally has the columns defined in 18 | // the wrong order after Version20230130090946 migration 19 | $shortUrls = $schema->getTable('short_urls'); 20 | $shortUrls->dropIndex('unique_short_code_plus_domain'); 21 | $shortUrls->addUniqueIndex(['short_code', 'domain_id'], 'unique_short_code_plus_domain'); 22 | } 23 | 24 | private function isMsSql(): bool 25 | { 26 | return $this->connection->getDatabasePlatform() instanceof SQLServerPlatform; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /module/Core/src/Action/PixelAction.php: -------------------------------------------------------------------------------- 1 | redirectionBuilder->buildShortUrlRedirect($shortUrl, $request); 29 | return $this->redirectResponseHelper->buildRedirectResponse($longUrl); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /module/Core/src/Config/EmptyNotFoundRedirectConfig.php: -------------------------------------------------------------------------------- 1 | name, $this->version); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /module/Core/src/Config/Options/DeleteShortUrlsOptions.php: -------------------------------------------------------------------------------- 1 | loadFromEnv(); 22 | 23 | return new self( 24 | visitsThreshold: (int) ($threshold ?? DEFAULT_DELETE_SHORT_URL_THRESHOLD), 25 | checkVisitsThreshold: $threshold !== null, 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /module/Core/src/Config/Options/ExtraPathMode.php: -------------------------------------------------------------------------------- 1 | loadFromEnv()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /module/Core/src/Config/Options/RedirectOptions.php: -------------------------------------------------------------------------------- 1 | redirectStatusCode = RedirectStatus::tryFrom($redirectStatusCode) ?? DEFAULT_REDIRECT_STATUS_CODE; 24 | $this->redirectCacheLifetime = $redirectCacheLifetime > 0 25 | ? $redirectCacheLifetime 26 | : DEFAULT_REDIRECT_CACHE_LIFETIME; 27 | } 28 | 29 | public static function fromEnv(): self 30 | { 31 | return new self( 32 | redirectStatusCode: (int) EnvVars::REDIRECT_STATUS_CODE->loadFromEnv(), 33 | redirectCacheLifetime: (int) EnvVars::REDIRECT_CACHE_LIFETIME->loadFromEnv(), 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /module/Core/src/Config/Options/RobotsOptions.php: -------------------------------------------------------------------------------- 1 | loadFromEnv(), 25 | userAgents: splitByComma(EnvVars::ROBOTS_USER_AGENTS->loadFromEnv()), 26 | ); 27 | } 28 | 29 | public function hasUserAgents(): bool 30 | { 31 | return count($this->userAgents) > 0; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /module/Core/src/Config/PostProcessor/BasePathPrefixer.php: -------------------------------------------------------------------------------- 1 | prefixPathsWithBasePath($configKey, $config, $basePath); 19 | } 20 | 21 | return $config; 22 | } 23 | 24 | private function prefixPathsWithBasePath(string $configKey, array $config, string $basePath): array 25 | { 26 | return array_map(function (array $element) use ($basePath) { 27 | if (! isset($element['path'])) { 28 | return $element; 29 | } 30 | 31 | $element['path'] = $basePath . $element['path']; 32 | return $element; 33 | }, $config[$configKey] ?? []); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php: -------------------------------------------------------------------------------- 1 | loadFromEnv(); 20 | if (! $multiSegmentEnabled) { 21 | return $config; 22 | } 23 | 24 | $config['routes'] = array_map(static function (array $route): array { 25 | ['path' => $path] = $route; 26 | $route['path'] = str_replace(self::SINGLE_SEGMENT_PATTERN, self::MULTI_SEGMENT_PATTERN, $path); 27 | return $route; 28 | }, $config['routes'] ?? []); 29 | 30 | return $config; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /module/Core/src/ConfigProvider.php: -------------------------------------------------------------------------------- 1 | query)(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /module/Core/src/Crawling/CrawlingHelperInterface.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function listCrawlableShortCodes(): iterable; 13 | } 14 | -------------------------------------------------------------------------------- /module/Core/src/Domain/DomainServiceInterface.php: -------------------------------------------------------------------------------- 1 | authority, $domain, false); 24 | } 25 | 26 | public static function forDefaultDomain(string $defaultDomain, NotFoundRedirectConfigInterface $config): self 27 | { 28 | return new self($defaultDomain, $config, true); 29 | } 30 | 31 | public function jsonSerialize(): array 32 | { 33 | return [ 34 | 'domain' => $this->authority, 35 | 'isDefault' => $this->isDefault, 36 | 'redirects' => NotFoundRedirects::fromConfig($this->notFoundRedirectConfig), 37 | ]; 38 | } 39 | 40 | public function toString(): string 41 | { 42 | return $this->authority; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /module/Core/src/Domain/Repository/DomainRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | */ 13 | interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface 14 | { 15 | /** 16 | * @return Domain[] 17 | */ 18 | public function findDomains(ApiKey|null $apiKey = null): array; 19 | 20 | public function findOneByAuthority(string $authority, ApiKey|null $apiKey = null): Domain|null; 21 | 22 | public function domainExists(string $authority, ApiKey|null $apiKey = null): bool; 23 | } 24 | -------------------------------------------------------------------------------- /module/Core/src/Domain/Spec/IsDomain.php: -------------------------------------------------------------------------------- 1 | domainId); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php: -------------------------------------------------------------------------------- 1 | handle($request); 24 | $this->requestTracker->trackNotFoundIfApplicable($request->withAttribute( 25 | REDIRECT_URL_REQUEST_ATTRIBUTE, 26 | $response->hasHeader('Location') ? $response->getHeaderLine('Location') : null, 27 | )); 28 | 29 | return $response; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /module/Core/src/ErrorHandler/NotFoundTypeResolverMiddleware.php: -------------------------------------------------------------------------------- 1 | shlinkBasePath); 22 | return $handler->handle($request->withAttribute(NotFoundType::class, $notFoundType)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /module/Core/src/EventDispatcher/Async/AbstractAsyncListener.php: -------------------------------------------------------------------------------- 1 | wrapped = $wrapped; 17 | } 18 | 19 | public function __invoke(object $event): void 20 | { 21 | $this->em->open(); 22 | 23 | try { 24 | ($this->wrapped)($event); 25 | } finally { 26 | $this->em->getConnection()->close(); 27 | $this->em->close(); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /module/Core/src/EventDispatcher/CloseDbConnectionEventListenerDelegator.php: -------------------------------------------------------------------------------- 1 | get('em'); 21 | 22 | return new CloseDbConnectionEventListener($em, $wrapped); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /module/Core/src/EventDispatcher/Event/GeoLiteDbCreated.php: -------------------------------------------------------------------------------- 1 | $this->shortUrlId, 20 | ]; 21 | } 22 | 23 | public static function fromPayload(array $payload): self 24 | { 25 | return new self($payload['shortUrlId'] ?? ''); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /module/Core/src/EventDispatcher/Event/UrlVisited.php: -------------------------------------------------------------------------------- 1 | $this->visitId, 'originalIpAddress' => $this->originalIpAddress]; 21 | } 22 | 23 | public static function fromPayload(array $payload): self 24 | { 25 | return new self($payload['visitId'] ?? '', $payload['originalIpAddress'] ?? null); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /module/Core/src/EventDispatcher/Helper/RequestIdProvider.php: -------------------------------------------------------------------------------- 1 | requestIdMiddleware->currentRequestId(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /module/Core/src/EventDispatcher/Mercure/NotifyNewShortUrlToMercure.php: -------------------------------------------------------------------------------- 1 | options->enabled; 30 | } 31 | 32 | protected function getRemoteSystem(): RemoteSystem 33 | { 34 | return RemoteSystem::RABBIT_MQ; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php: -------------------------------------------------------------------------------- 1 | options->enabled; 30 | } 31 | 32 | protected function getRemoteSystem(): RemoteSystem 33 | { 34 | return RemoteSystem::RABBIT_MQ; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /module/Core/src/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedis.php: -------------------------------------------------------------------------------- 1 | enabled; 29 | } 30 | 31 | protected function getRemoteSystem(): RemoteSystem 32 | { 33 | return RemoteSystem::REDIS_PUB_SUB; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /module/Core/src/EventDispatcher/RedisPubSub/NotifyVisitToRedis.php: -------------------------------------------------------------------------------- 1 | enabled; 29 | } 30 | 31 | protected function getRemoteSystem(): RemoteSystem 32 | { 33 | return RemoteSystem::REDIS_PUB_SUB; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /module/Core/src/EventDispatcher/Topic.php: -------------------------------------------------------------------------------- 1 | value, $shortCode ?? ''); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /module/Core/src/Exception/DomainException.php: -------------------------------------------------------------------------------- 1 | detail = $message; 35 | $e->title = self::TITLE; 36 | $e->type = toProblemDetailsType(self::ERROR_CODE); 37 | $e->status = StatusCodeInterface::STATUS_FORBIDDEN; 38 | 39 | return $e; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /module/Core/src/Exception/GeolocationDbUpdateFailedException.php: -------------------------------------------------------------------------------- 1 | getCode(), $e); 34 | } 35 | 36 | /** 37 | * Tells if this belongs to an address that will never be possible to locate 38 | */ 39 | public function isNonLocatableAddress(): bool 40 | { 41 | return $this->type !== UnlocatableIpType::ERROR; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /module/Core/src/Exception/MalformedBodyException.php: -------------------------------------------------------------------------------- 1 | detail = $e->getMessage(); 23 | $e->title = 'Malformed request body'; 24 | $e->type = toProblemDetailsType('malformed-request-body'); 25 | $e->status = StatusCodeInterface::STATUS_BAD_REQUEST; 26 | 27 | return $e; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /module/Core/src/Exception/RuntimeException.php: -------------------------------------------------------------------------------- 1 | toString())); 25 | 26 | $e->detail = $e->getMessage(); 27 | $e->title = self::TITLE; 28 | $e->type = toProblemDetailsType(self::ERROR_CODE); 29 | $e->status = StatusCodeInterface::STATUS_CONFLICT; 30 | $e->additional = $renaming->toArray(); 31 | 32 | return $e; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /module/Core/src/Exception/TagNotFoundException.php: -------------------------------------------------------------------------------- 1 | detail = $e->getMessage(); 26 | $e->title = self::TITLE; 27 | $e->type = toProblemDetailsType(self::ERROR_CODE); 28 | $e->status = StatusCodeInterface::STATUS_NOT_FOUND; 29 | $e->additional = ['tag' => $tag]; 30 | 31 | return $e; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /module/Core/src/Geolocation/Entity/GeolocationDbUpdateStatus.php: -------------------------------------------------------------------------------- 1 | loadFromEnv(), 26 | baseUrl: EnvVars::MATOMO_BASE_URL->loadFromEnv(), 27 | siteId: EnvVars::MATOMO_SITE_ID->loadFromEnv(), 28 | apiToken: EnvVars::MATOMO_API_TOKEN->loadFromEnv(), 29 | ); 30 | } 31 | 32 | public function siteId(): int|null 33 | { 34 | if ($this->siteId === null) { 35 | return null; 36 | } 37 | 38 | // We enforce site ID to be hydrated as a numeric string or int, so it's safe to cast to int here 39 | return (int) $this->siteId; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /module/Core/src/Matomo/MatomoTrackerBuilderInterface.php: -------------------------------------------------------------------------------- 1 | $successfulVisits 13 | * @param int<0, max> $failedVisits 14 | */ 15 | public function __construct(public int $successfulVisits = 0, public int $failedVisits = 0) 16 | { 17 | } 18 | 19 | public function hasSuccesses(): bool 20 | { 21 | return $this->successfulVisits > 0; 22 | } 23 | 24 | public function hasFailures(): bool 25 | { 26 | return $this->failedVisits > 0; 27 | } 28 | 29 | public function count(): int 30 | { 31 | return $this->successfulVisits + $this->failedVisits; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /module/Core/src/Matomo/VisitSendingProgressTrackerInterface.php: -------------------------------------------------------------------------------- 1 | page = $this->determinePage($page); 19 | $this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage); 20 | } 21 | 22 | private function determinePage(int|null $page): int 23 | { 24 | return $page === null || $page <= 0 ? self::FIRST_PAGE : $page; 25 | } 26 | 27 | private function determineItemsPerPage(int|null $itemsPerPage): int 28 | { 29 | return $itemsPerPage === null || $itemsPerPage < 0 ? Paginator::ALL_ITEMS : $itemsPerPage; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /module/Core/src/Model/BulkDeleteResult.php: -------------------------------------------------------------------------------- 1 | $this->affectedItems]; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /module/Core/src/Model/DeviceType.php: -------------------------------------------------------------------------------- 1 | parse($userAgent); 18 | 19 | return match ($ua->platform()) { 20 | Platforms::IPHONE, Platforms::IPAD => self::IOS, // Detects both iPhone and iPad (except iPadOS 13+) 21 | Platforms::ANDROID => self::ANDROID, // Detects both android phones and android tablets 22 | Platforms::LINUX, Platforms::WINDOWS, Platforms::MACINTOSH, Platforms::CHROME_OS => self::DESKTOP, 23 | default => null, 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /module/Core/src/Model/Ordering.php: -------------------------------------------------------------------------------- 1 | 'oldName is required', 27 | 'newName' => 'newName is required', 28 | ]); 29 | } 30 | 31 | return self::fromNames($payload['oldName'], $payload['newName']); 32 | } 33 | 34 | public function nameChanged(): bool 35 | { 36 | return $this->oldName !== $this->newName; 37 | } 38 | 39 | public function toString(): string 40 | { 41 | return sprintf('%s to %s', $this->oldName, $this->newName); 42 | } 43 | 44 | public function toArray(): array 45 | { 46 | return [ 47 | 'oldName' => $this->oldName, 48 | 'newName' => $this->newName, 49 | ]; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /module/Core/src/Paginator/Adapter/AbstractCacheableCountPaginatorAdapter.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | abstract class AbstractCacheableCountPaginatorAdapter implements AdapterInterface 14 | { 15 | private int|null $count = null; 16 | 17 | final public function getNbResults(): int 18 | { 19 | // Since a new adapter instance is created every time visits are fetched, it is reasonably safe to internally 20 | // cache the count value. 21 | // The reason it is cached is because the Paginator is actually calling the method twice. 22 | // An inconsistent value could be returned if between the first call and the second one, a new visit is created. 23 | // However, it's almost instant, and then the adapter instance is discarded immediately after. 24 | 25 | if ($this->count !== null) { 26 | return $this->count; 27 | } 28 | 29 | return $this->count = $this->doCount(); 30 | } 31 | 32 | abstract protected function doCount(): int; 33 | } 34 | -------------------------------------------------------------------------------- /module/Core/src/RedirectRule/Model/RedirectRulesData.php: -------------------------------------------------------------------------------- 1 | isValid()) { 24 | throw ValidationException::fromInputFilter($inputFilter); 25 | } 26 | 27 | return new self(array_values($inputFilter->getValue(RedirectRulesInputFilter::REDIRECT_RULES))); 28 | } catch (InvalidArgumentException) { 29 | throw ValidationException::fromArray( 30 | [RedirectRulesInputFilter::REDIRECT_RULES => RedirectRulesInputFilter::REDIRECT_RULES], 31 | ); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php: -------------------------------------------------------------------------------- 1 | ruleService->rulesForShortUrl($shortUrl); 17 | foreach ($rules as $rule) { 18 | // Return the long URL for the first rule found that matches 19 | if ($rule->matchesRequest($request)) { 20 | return $rule->longUrl; 21 | } 22 | } 23 | 24 | return $shortUrl->getLongUrl(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /module/Core/src/RedirectRule/ShortUrlRedirectionResolverInterface.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | interface EntityRepositoryInterface extends ObjectRepository 14 | { 15 | /** 16 | * @todo This should be part of ObjectRepository, so adding here until that interface defines it. 17 | * EntityRepository already implements the method, so classes extending it won't have to add anything. 18 | */ 19 | public function count(array $criteria = []): int; 20 | } 21 | -------------------------------------------------------------------------------- /module/Core/src/ShortUrl/DeleteShortUrlServiceInterface.php: -------------------------------------------------------------------------------- 1 | repo->shortCodeIsInUseWithLock($identifier); 22 | 23 | if (! $otherShortUrlsExist) { 24 | return true; 25 | } 26 | 27 | if ($hasCustomSlug) { 28 | return false; 29 | } 30 | 31 | $shortUrlToBeCreated->regenerateShortCode($this->options->mode); 32 | return $this->ensureShortCodeUniqueness($shortUrlToBeCreated, $hasCustomSlug); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelperInterface.php: -------------------------------------------------------------------------------- 1 | pastValidUntil || $this->maxVisitsReached; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /module/Core/src/ShortUrl/Model/OrderableField.php: -------------------------------------------------------------------------------- 1 | errorOnEventDispatching !== null) { 24 | $handler($this->errorOnEventDispatching); 25 | } 26 | } 27 | 28 | public static function withoutErrorOnEventDispatching(ShortUrl $shortUrl): self 29 | { 30 | return new self($shortUrl, null); 31 | } 32 | 33 | public static function withErrorOnEventDispatching(ShortUrl $shortUrl, Throwable $errorOnEventDispatching): self 34 | { 35 | return new self($shortUrl, $errorOnEventDispatching); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php: -------------------------------------------------------------------------------- 1 | options->isLooseMode() ? strtolower($value) : $value; 28 | return $this->options->multiSegmentSlugsEnabled 29 | ? trim(str_replace(' ', '-', $value), '/') 30 | : str_replace([' ', '/'], '-', $value); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /module/Core/src/ShortUrl/Repository/CrawlableShortCodesQueryInterface.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function __invoke(): iterable; 13 | } 14 | -------------------------------------------------------------------------------- /module/Core/src/ShortUrl/Repository/ExpiredShortUrlsRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public function resolveTags(array $tags): Collection; 20 | } 21 | -------------------------------------------------------------------------------- /module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | public function resolveTags(array $tags): Collections\Collection 25 | { 26 | return new Collections\ArrayCollection(array_map(fn (string $tag) => new Tag($tag), $tags)); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /module/Core/src/ShortUrl/ShortUrlListService.php: -------------------------------------------------------------------------------- 1 | urlShortenerOptions->defaultDomain; 28 | $paginator = new Paginator(new ShortUrlRepositoryAdapter($this->repo, $params, $apiKey, $defaultDomain)); 29 | $paginator->setMaxPerPage($params->itemsPerPage) 30 | ->setCurrentPage($params->page); 31 | 32 | return $paginator; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /module/Core/src/ShortUrl/ShortUrlListServiceInterface.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | public function listShortUrls(ShortUrlsParams $params, ApiKey|null $apiKey = null): Paginator; 18 | } 19 | -------------------------------------------------------------------------------- /module/Core/src/ShortUrl/ShortUrlServiceInterface.php: -------------------------------------------------------------------------------- 1 | resolver->resolveShortUrl($identifier, $apiKey); 27 | return new BulkDeleteResult($this->repository->deleteShortUrlVisits($shortUrl)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php: -------------------------------------------------------------------------------- 1 | apiKey); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php: -------------------------------------------------------------------------------- 1 | getEntityManager()->getConnection(); 21 | return $qb->expr()->eq('s.authorApiKey', $conn->quote($this->apiKey->getId()))->__toString(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /module/Core/src/ShortUrl/Spec/BelongsToDomain.php: -------------------------------------------------------------------------------- 1 | domainId, $this->dqlAlias); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php: -------------------------------------------------------------------------------- 1 | getEntityManager()->getConnection(); 20 | return $qb->expr()->eq('s.domain', $conn->quote($this->domainId))->__toString(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php: -------------------------------------------------------------------------------- 1 | toIdentifier(); 23 | return [ 24 | 'shortUrl' => $this->stringifier->stringify($shortUrlIdentifier), 25 | ...$shortUrl->toArray(), 26 | ]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformerInterface.php: -------------------------------------------------------------------------------- 1 | dateRange?->startDate !== null) { 24 | $criteria[] = Spec::gte($this->field, $this->dateRange->startDate->toDateTimeString()); 25 | } 26 | 27 | if ($this->dateRange?->endDate !== null) { 28 | $criteria[] = Spec::lte($this->field, $this->dateRange->endDate->toDateTimeString()); 29 | } 30 | 31 | return Spec::andX(...$criteria); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /module/Core/src/Tag/Entity/Tag.php: -------------------------------------------------------------------------------- 1 | */ 15 | private Collections\Collection $shortUrls; 16 | 17 | public function __construct(private string $name) 18 | { 19 | $this->shortUrls = new Collections\ArrayCollection(); 20 | } 21 | 22 | public function rename(string $name): void 23 | { 24 | $this->name = $name; 25 | } 26 | 27 | public function jsonSerialize(): string 28 | { 29 | return $this->name; 30 | } 31 | 32 | public function __toString(): string 33 | { 34 | return $this->name; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /module/Core/src/Tag/Model/OrderableField.php: -------------------------------------------------------------------------------- 1 | visitsSummary = VisitsSummary::fromTotalAndNonBots($visitsCount, $nonBotVisitsCount ?? $visitsCount); 21 | } 22 | 23 | public static function fromRawData(array $data): self 24 | { 25 | return new self( 26 | $data['tag'], 27 | (int) $data['shortUrlsCount'], 28 | (int) $data['visits'], 29 | isset($data['nonBotVisits']) ? (int) $data['nonBotVisits'] : null, 30 | ); 31 | } 32 | 33 | public function jsonSerialize(): array 34 | { 35 | return [ 36 | 'tag' => $this->tag, 37 | 'shortUrlsCount' => $this->shortUrlsCount, 38 | 'visitsSummary' => $this->visitsSummary, 39 | ]; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /module/Core/src/Tag/Model/TagsListFiltering.php: -------------------------------------------------------------------------------- 1 | searchTerm, $params->orderBy, $apiKey); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /module/Core/src/Tag/Model/TagsParams.php: -------------------------------------------------------------------------------- 1 | */ 11 | class TagsInfoPaginatorAdapter extends AbstractTagsPaginatorAdapter 12 | { 13 | public function getSlice(int $offset, int $length): iterable 14 | { 15 | return $this->repo->findTagsWithInfo( 16 | TagsListFiltering::fromRangeAndParams($length, $offset, $this->params, $this->apiKey), 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php: -------------------------------------------------------------------------------- 1 | */ 12 | class TagsPaginatorAdapter extends AbstractTagsPaginatorAdapter 13 | { 14 | public function getSlice(int $offset, int $length): iterable 15 | { 16 | $conditions = [ 17 | new WithApiKeySpecsEnsuringJoin($this->apiKey), 18 | Spec::orderBy( 19 | 'name', // Ordering by other fields makes no sense here 20 | $this->params->orderBy->direction, 21 | ), 22 | Spec::limit($length), 23 | Spec::offset($offset), 24 | ]; 25 | 26 | $searchTerm = $this->params->searchTerm; 27 | if ($searchTerm !== null) { 28 | $conditions[] = Spec::like('name', $searchTerm); 29 | } 30 | 31 | return $this->repo->match(Spec::andX(...$conditions)); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /module/Core/src/Tag/Repository/TagRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | */ 15 | interface TagRepositoryInterface extends EntityRepositoryInterface, EntitySpecificationRepositoryInterface 16 | { 17 | public function deleteByName(array $names): int; 18 | 19 | /** 20 | * @return TagInfo[] 21 | */ 22 | public function findTagsWithInfo(TagsListFiltering|null $filtering = null): array; 23 | 24 | public function tagExists(string $tag, ApiKey|null $apiKey = null): bool; 25 | } 26 | -------------------------------------------------------------------------------- /module/Core/src/Tag/Spec/CountTagsWithName.php: -------------------------------------------------------------------------------- 1 | tagName), 24 | ), 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /module/Core/src/Tag/TagServiceInterface.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | public function listTags(TagsParams $params, ApiKey|null $apiKey = null): Paginator; 23 | 24 | /** 25 | * @return Paginator 26 | */ 27 | public function tagsInfo(TagsParams $params, ApiKey|null $apiKey = null): Paginator; 28 | 29 | /** 30 | * @param string[] $tagNames 31 | * @throws ForbiddenTagOperationException 32 | */ 33 | public function deleteTags(array $tagNames, ApiKey|null $apiKey = null): void; 34 | 35 | /** 36 | * @throws TagNotFoundException 37 | * @throws TagConflictException 38 | * @throws ForbiddenTagOperationException 39 | */ 40 | public function renameTag(Renaming $renaming, ApiKey|null $apiKey = null): Tag; 41 | } 42 | -------------------------------------------------------------------------------- /module/Core/src/Util/DoctrineBatchHelperInterface.php: -------------------------------------------------------------------------------- 1 | $resultSet 12 | * @return iterable 13 | */ 14 | public function wrapIterable(iterable $resultSet, int $batchSize): iterable; 15 | } 16 | -------------------------------------------------------------------------------- /module/Core/src/Util/RedirectResponseHelper.php: -------------------------------------------------------------------------------- 1 | options->redirectStatusCode; 22 | $headers = ! $statusCode->allowsCache() ? [] : [ 23 | 'Cache-Control' => sprintf('private,max-age=%s', $this->options->redirectCacheLifetime), 24 | ]; 25 | 26 | return new RedirectResponse($location, $statusCode->value, $headers); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /module/Core/src/Util/RedirectResponseHelperInterface.php: -------------------------------------------------------------------------------- 1 | |Route::HTTP_METHOD_ANY 24 | */ 25 | public function allowedHttpMethods(): array|null 26 | { 27 | return contains($this, [self::STATUS_301, self::STATUS_302]) 28 | ? [RequestMethodInterface::METHOD_GET] 29 | : Route::HTTP_METHOD_ANY; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /module/Core/src/Visit/Entity/OrphanVisitsCount.php: -------------------------------------------------------------------------------- 1 | hasRemoteAddr()) { 26 | throw IpCannotBeLocatedException::forEmptyAddress(); 27 | } 28 | 29 | $ipAddr = $visit->remoteAddr ?? ''; 30 | if ($ipAddr === IpAddress::LOCALHOST) { 31 | throw IpCannotBeLocatedException::forLocalhost(); 32 | } 33 | 34 | try { 35 | return $this->ipLocationResolver->resolveIpLocation($ipAddr); 36 | } catch (WrongIpException $e) { 37 | throw IpCannotBeLocatedException::forError($e); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /module/Core/src/Visit/Geolocation/VisitToLocationHelperInterface.php: -------------------------------------------------------------------------------- 1 | value; 12 | case BASE_URL = OrphanVisitType::BASE_URL->value; 13 | case REGULAR_404 = OrphanVisitType::REGULAR_404->value; 14 | } 15 | -------------------------------------------------------------------------------- /module/Core/src/Visit/Model/VisitsParams.php: -------------------------------------------------------------------------------- 1 | dateRange = $dateRange ?? DateRange::allTime(); 24 | } 25 | 26 | public static function fromRawData(array $query): self 27 | { 28 | return new self( 29 | parseDateRangeFromQuery($query, 'startDate', 'endDate'), 30 | isset($query['page']) ? (int) $query['page'] : null, 31 | isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null, 32 | isset($query['excludeBots']), 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /module/Core/src/Visit/Model/VisitsStats.php: -------------------------------------------------------------------------------- 1 | nonOrphanVisitsSummary = VisitsSummary::fromTotalAndNonBots( 21 | $nonOrphanVisitsTotal, 22 | $nonOrphanVisitsNonBots ?? $nonOrphanVisitsTotal, 23 | ); 24 | $this->orphanVisitsSummary = VisitsSummary::fromTotalAndNonBots( 25 | $orphanVisitsTotal, 26 | $orphanVisitsNonBots ?? $orphanVisitsTotal, 27 | ); 28 | } 29 | 30 | public function jsonSerialize(): array 31 | { 32 | return [ 33 | 'nonOrphanVisits' => $this->nonOrphanVisitsSummary, 34 | 'orphanVisits' => $this->orphanVisitsSummary, 35 | ]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /module/Core/src/Visit/Model/VisitsSummary.php: -------------------------------------------------------------------------------- 1 | $this->total, 24 | 'nonBots' => $this->nonBots, 25 | 'bots' => $this->total - $this->nonBots, 26 | ]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /module/Core/src/Visit/Persistence/OrphanVisitsCountFiltering.php: -------------------------------------------------------------------------------- 1 | */ 13 | class OrphanVisitsCountRepository extends EntitySpecificationRepository implements OrphanVisitsCountRepositoryInterface 14 | { 15 | public function countOrphanVisits(VisitsCountFiltering $filtering): int 16 | { 17 | if ($filtering->apiKey?->hasRole(Role::NO_ORPHAN_VISITS)) { 18 | return 0; 19 | } 20 | 21 | $qb = $this->getEntityManager()->createQueryBuilder(); 22 | $qb->select('COALESCE(SUM(vc.count), 0)') 23 | ->from(OrphanVisitsCount::class, 'vc'); 24 | 25 | if ($filtering->excludeBots) { 26 | $qb->andWhere($qb->expr()->eq('vc.potentialBot', ':potentialBot')) 27 | ->setParameter('potentialBot', false); 28 | } 29 | 30 | return (int) $qb->getQuery()->getSingleScalarResult(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /module/Core/src/Visit/Repository/OrphanVisitsCountRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | */ 13 | class ShortUrlVisitsCountRepository extends EntitySpecificationRepository implements 14 | ShortUrlVisitsCountRepositoryInterface 15 | { 16 | public function countNonOrphanVisits(VisitsCountFiltering $filtering): int 17 | { 18 | $qb = $this->getEntityManager()->createQueryBuilder(); 19 | $qb->select('COALESCE(SUM(vc.count), 0)') 20 | ->from(ShortUrlVisitsCount::class, 'vc') 21 | ->join('vc.shortUrl', 's'); 22 | 23 | 24 | if ($filtering->excludeBots) { 25 | $qb->andWhere($qb->expr()->eq('vc.potentialBot', ':potentialBot')) 26 | ->setParameter('potentialBot', false); 27 | } 28 | 29 | $this->applySpecification($qb, $filtering->apiKey?->spec(), 's'); 30 | 31 | return (int) $qb->getQuery()->getSingleScalarResult(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /module/Core/src/Visit/Repository/ShortUrlVisitsCountRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | public function findUnlocatedVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable; 18 | 19 | /** 20 | * @return iterable 21 | */ 22 | public function findVisitsWithEmptyLocation(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable; 23 | 24 | /** 25 | * @return iterable 26 | */ 27 | public function findAllVisits( 28 | DateRange|null $dateRange = null, 29 | int $blockSize = self::DEFAULT_BLOCK_SIZE, 30 | ): iterable; 31 | } 32 | -------------------------------------------------------------------------------- /module/Core/src/Visit/RequestTrackerInterface.php: -------------------------------------------------------------------------------- 1 | filtering->dateRange), 26 | ]; 27 | 28 | if ($this->filtering->excludeBots) { 29 | $conditions[] = Spec::eq('potentialBot', false); 30 | } 31 | 32 | $apiKey = $this->filtering->apiKey; 33 | if ($apiKey !== null) { 34 | $conditions[] = new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrl'); 35 | } 36 | 37 | return Spec::countOf(Spec::andX(...$conditions)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /module/Core/src/Visit/Spec/CountOfOrphanVisits.php: -------------------------------------------------------------------------------- 1 | filtering->dateRange), 25 | ]; 26 | 27 | if ($this->filtering->excludeBots) { 28 | $conditions[] = Spec::eq('potentialBot', false); 29 | } 30 | 31 | if ($this->filtering->type) { 32 | $conditions[] = Spec::eq('type', $this->filtering->type->value); 33 | } 34 | 35 | return Spec::countOf(Spec::andX(...$conditions)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /module/Core/src/Visit/VisitsDeleter.php: -------------------------------------------------------------------------------- 1 | hasRole(Role::NO_ORPHAN_VISITS) ? 0 : $this->repository->deleteOrphanVisits(); 21 | return new BulkDeleteResult($affectedItems); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /module/Core/src/Visit/VisitsDeleterInterface.php: -------------------------------------------------------------------------------- 1 | callShortUrl('custom/qr-code'); 18 | self::assertEquals(200, $response->getStatusCode()); 19 | 20 | // This short URL allow max 2 visits 21 | $this->callShortUrl('custom'); 22 | $this->callShortUrl('custom'); 23 | 24 | // After 2 visits, the short URL returns a 404, but the QR code should still work 25 | self::assertEquals(404, $this->callShortUrl('custom')->getStatusCode()); 26 | self::assertEquals(200, $this->callShortUrl('custom/qr-code')->getStatusCode()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /module/Core/test-api/Action/RobotsTest.php: -------------------------------------------------------------------------------- 1 | callShortUrl('robots.txt'); 16 | $body = $resp->getBody()->__toString(); 17 | 18 | self::assertEquals(200, $resp->getStatusCode()); 19 | self::assertEquals( 20 | << [ 14 | 'ignored_path_prefixes' => [ 15 | Action\HealthAction::ROUTE_PATH, 16 | ], 17 | ], 18 | 19 | // This config needs to go in this file in order to override the value defined in shlink-common 20 | ConfigAbstractFactory::class => [ 21 | // Use MergeReplaceKey to overwrite what was defined in shlink-common, instead of merging it 22 | AccessLogMiddleware::class => new MergeReplaceKey( 23 | [AccessLogMiddleware::LOGGER_SERVICE_NAME, 'config.access_logs.ignored_path_prefixes'], 24 | ), 25 | ], 26 | 27 | ]; 28 | -------------------------------------------------------------------------------- /module/Rest/config/auth.config.php: -------------------------------------------------------------------------------- 1 | [ 12 | 'routes_without_api_key' => [ 13 | Action\HealthAction::class, 14 | ConfigProvider::UNVERSIONED_HEALTH_ENDPOINT_NAME, 15 | ], 16 | 17 | 'routes_with_query_api_key' => [ 18 | Action\ShortUrl\SingleStepCreateShortUrlAction::class, 19 | ], 20 | ], 21 | 22 | 'dependencies' => [ 23 | 'factories' => [ 24 | Middleware\AuthenticationMiddleware::class => ConfigAbstractFactory::class, 25 | ], 26 | ], 27 | 28 | ConfigAbstractFactory::class => [ 29 | Middleware\AuthenticationMiddleware::class => [ 30 | Service\ApiKeyService::class, 31 | 'config.auth.routes_without_api_key', 32 | 'config.auth.routes_with_query_api_key', 33 | ], 34 | ], 35 | 36 | ]; 37 | -------------------------------------------------------------------------------- /module/Rest/config/entity-manager.config.php: -------------------------------------------------------------------------------- 1 | [ 8 | 'orm' => [ 9 | 'entities_mappings' => [ 10 | __DIR__ . '/../config/entities-mappings', 11 | ], 12 | ], 13 | ], 14 | 15 | ]; 16 | -------------------------------------------------------------------------------- /module/Rest/src/Action/AbstractRestAction.php: -------------------------------------------------------------------------------- 1 | static::class, 20 | 'middleware' => [...$prevMiddleware, static::class, ...$postMiddleware], 21 | 'path' => static::ROUTE_PATH, 22 | 'allowed_methods' => static::ROUTE_ALLOWED_METHODS, 23 | ]; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php: -------------------------------------------------------------------------------- 1 | getParsedBody(); 24 | $payload[ShortUrlInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request); 25 | 26 | return ShortUrlCreation::fromRawData($payload, $this->urlShortenerOptions); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php: -------------------------------------------------------------------------------- 1 | deleteShortUrlService->deleteByShortCode($identifier, false, $apiKey); 30 | 31 | return new EmptyResponse(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /module/Rest/src/Action/ShortUrl/DeleteShortUrlVisitsAction.php: -------------------------------------------------------------------------------- 1 | deleter->deleteShortUrlVisits($identifier, $apiKey); 30 | 31 | return new JsonResponse($result->toArray('deletedVisits')); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php: -------------------------------------------------------------------------------- 1 | getQueryParams(); 20 | $longUrl = $query['longUrl'] ?? null; 21 | $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); 22 | 23 | return ShortUrlCreation::fromRawData([ 24 | ShortUrlInputFilter::LONG_URL => $longUrl, 25 | ShortUrlInputFilter::API_KEY => $apiKey, 26 | // This will usually be null, unless this API key enforces one specific domain 27 | ShortUrlInputFilter::DOMAIN => $request->getAttribute(ShortUrlInputFilter::DOMAIN), 28 | ], $this->urlShortenerOptions); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /module/Rest/src/Action/Tag/DeleteTagsAction.php: -------------------------------------------------------------------------------- 1 | getQueryParams(); 26 | $tags = $query['tags'] ?? []; 27 | $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); 28 | 29 | $this->tagService->deleteTags($tags, $apiKey); 30 | return new EmptyResponse(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /module/Rest/src/Action/Tag/ListTagsAction.php: -------------------------------------------------------------------------------- 1 | getQueryParams()); 28 | $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); 29 | 30 | return new JsonResponse([ 31 | 'tags' => PagerfantaUtils::serializePaginator($this->tagService->listTags($params, $apiKey)), 32 | ]); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /module/Rest/src/Action/Tag/TagsStatsAction.php: -------------------------------------------------------------------------------- 1 | getQueryParams()); 28 | $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); 29 | $tagsInfo = $this->tagService->tagsInfo($params, $apiKey); 30 | 31 | return new JsonResponse(['tags' => PagerfantaUtils::serializePaginator($tagsInfo)]); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /module/Rest/src/Action/Tag/UpdateTagAction.php: -------------------------------------------------------------------------------- 1 | getParsedBody(); 28 | $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); 29 | 30 | $this->tagService->renameTag(Renaming::fromArray($body), $apiKey); 31 | return new EmptyResponse(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /module/Rest/src/Action/Visit/DeleteOrphanVisitsAction.php: -------------------------------------------------------------------------------- 1 | visitsDeleter->deleteOrphanVisits($apiKey); 27 | 28 | return new JsonResponse($result->toArray('deletedVisits')); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /module/Rest/src/Action/Visit/GlobalVisitsAction.php: -------------------------------------------------------------------------------- 1 | $this->statsHelper->getVisitsStats($apiKey), 29 | ]); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /module/Rest/src/Action/Visit/NonOrphanVisitsAction.php: -------------------------------------------------------------------------------- 1 | visitsHelper->nonOrphanVisits($params, $apiKey); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /module/Rest/src/Action/Visit/OrphanVisitsAction.php: -------------------------------------------------------------------------------- 1 | getQueryParams()); 23 | return $this->visitsHelper->orphanVisits($orphanParams, $apiKey); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /module/Rest/src/Action/Visit/ShortUrlVisitsAction.php: -------------------------------------------------------------------------------- 1 | visitsHelper->visitsForShortUrl($identifier, $params, $apiKey); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /module/Rest/src/Action/Visit/TagVisitsAction.php: -------------------------------------------------------------------------------- 1 | getAttribute('tag', ''); 19 | return $this->visitsHelper->visitsForTag($tag, $params, $apiKey); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /module/Rest/src/ApiKey/Model/RoleDefinition.php: -------------------------------------------------------------------------------- 1 | $domain->getId(), 'authority' => $domain->authority], 26 | ); 27 | } 28 | 29 | public static function forNoOrphanVisits(): self 30 | { 31 | return new self(Role::NO_ORPHAN_VISITS, []); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | interface ApiKeyRepositoryInterface extends EntityRepositoryInterface, EntitySpecificationRepositoryInterface 15 | { 16 | /** 17 | * Will create provided API key with admin permissions, only if no other API keys exist yet 18 | */ 19 | public function createInitialApiKey(string $apiKey): ApiKey|null; 20 | 21 | /** 22 | * Checks whether an API key with provided name exists or not 23 | */ 24 | public function nameExists(string $name): bool; 25 | } 26 | -------------------------------------------------------------------------------- /module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php: -------------------------------------------------------------------------------- 1 | apiKey === null || ! ApiKey::isShortUrlRestricted($this->apiKey) ? Spec::andX() : Spec::andX( 24 | Spec::join($this->fieldToJoin, 's'), 25 | $this->apiKey->spec($this->fieldToJoin), 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /module/Rest/src/Entity/ApiKeyRole.php: -------------------------------------------------------------------------------- 1 | meta; 19 | } 20 | 21 | public function updateMeta(array $newMeta): void 22 | { 23 | $this->meta = $newMeta; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /module/Rest/src/Exception/ApiKeyConflictException.php: -------------------------------------------------------------------------------- 1 | detail = $e->getMessage(); 25 | $e->title = self::TITLE; 26 | $e->type = toProblemDetailsType(self::ERROR_CODE); 27 | $e->status = StatusCodeInterface::STATUS_NOT_IMPLEMENTED; 28 | 29 | return $e; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /module/Rest/src/Exception/RuntimeException.php: -------------------------------------------------------------------------------- 1 | detail = $e->getMessage(); 24 | $e->title = 'Invalid API key'; 25 | $e->type = toProblemDetailsType(self::ERROR_CODE); 26 | $e->status = StatusCodeInterface::STATUS_UNAUTHORIZED; 27 | 28 | return $e; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /module/Rest/src/Middleware/EmptyResponseImplicitOptionsMiddlewareFactory.php: -------------------------------------------------------------------------------- 1 | getParsedBody(); 24 | if (! isset($body[ShortUrlInputFilter::SHORT_CODE_LENGTH])) { 25 | $body[ShortUrlInputFilter::SHORT_CODE_LENGTH] = $this->urlShortenerOptions->defaultShortCodesLength; 26 | } 27 | 28 | return $handler->handle($request->withParsedBody($body)); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /module/Rest/src/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddleware.php: -------------------------------------------------------------------------------- 1 | getParsedBody() ?? []; 23 | $request = $request->withQueryParams($this->sanitizeDomainFromPayload($request->getQueryParams())) 24 | ->withParsedBody($this->sanitizeDomainFromPayload($body)); 25 | 26 | return $handler->handle($request); 27 | } 28 | 29 | private function sanitizeDomainFromPayload(array $payload): array 30 | { 31 | if (isset($payload['domain']) && $payload['domain'] === $this->urlShortenerOptions->defaultDomain) { 32 | unset($payload['domain']); 33 | } 34 | 35 | return $payload; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /module/Rest/src/Service/ApiKeyCheckResult.php: -------------------------------------------------------------------------------- 1 | apiKey !== null && $this->apiKey->isValid(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/phpstan/phpstan-doctrine/extension.neon 3 | - vendor/phpstan/phpstan-symfony/extension.neon 4 | - vendor/phpstan/phpstan-phpunit/extension.neon 5 | - vendor/phpstan/phpstan-phpunit/rules.neon 6 | parameters: 7 | level: 8 8 | paths: 9 | - module 10 | - config 11 | - docker/config 12 | symfony: 13 | consoleApplicationLoader: 'config/cli-app.php' 14 | doctrine: 15 | repositoryClass: Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository 16 | objectManagerLoader: 'config/entity-manager.php' 17 | ignoreErrors: 18 | - '#should return int<0, max> but returns int#' 19 | - '#expects -1\|int<1, max>, int given#' 20 | - identifier: missingType.iterableValue 21 | -------------------------------------------------------------------------------- /phpunit-cli.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./module/*/test-cli 14 | 15 | 16 | 17 | 18 | 19 | ./module/CLI/src 20 | ./module/Core/src 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | RewriteEngine On 2 | # The following rule tells Apache that if the requested filename 3 | # exists, simply serve it. 4 | RewriteCond %{REQUEST_FILENAME} -f [OR] 5 | RewriteCond %{REQUEST_FILENAME} -l [OR] 6 | RewriteCond %{REQUEST_FILENAME} -d 7 | RewriteRule ^ - [NC,L] 8 | 9 | # The following rewrites all other queries to index.php. The 10 | # condition ensures that if you are using Apache aliases to do 11 | # mass virtual hosting, the base path will be prepended to 12 | # allow proper resolution of the index.php file; it will work 13 | # in non-aliased environments as well, providing a safe, one-size 14 | # fits all solution. 15 | RewriteCond $0::%{REQUEST_URI} ^([^:]*+(?::[^:]*+)*?)::(/.+?)\1$ 16 | RewriteRule .+ - [E=BASE:%2] 17 | RewriteRule .* %{ENV:BASE}index.php [NC,L] 18 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shlinkio/shlink/2cad5dd435d47dfa42a044ab149f7961a49b2c35/public/favicon.ico -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 |