Compare commits

..

250 Commits
v4.4.4 ... main

Author SHA1 Message Date
Alejandro Celaya
b8f3a02a22 Remove duplicated PHP versions in publish-release workflow 2026-01-09 17:29:08 +01:00
Alejandro Celaya
5b4e0b1d84 Merge pull request #2570 from shlinkio/develop
Release 5.0.0
2026-01-09 17:23:46 +01:00
Alejandro Celaya
1cb93f6154 Add v5.0.0 to changelog 2026-01-09 17:14:11 +01:00
Alejandro Celaya
690b5c89b2 Merge pull request #2569 from acelaya-forks/charset-error
Fix error when trying to persist non-utf-8 title
2026-01-09 17:12:37 +01:00
Alejandro Celaya
8a82361d0e Fix error when trying to persist non-utf-8 title 2026-01-09 17:05:51 +01:00
Alejandro Celaya
d746018f52 Merge pull request #2568 from acelaya-forks/5.0-upgrade-docs
Document how to upgrade to Shlink 5.0
2026-01-09 14:12:33 +01:00
Alejandro Celaya
97e7d4a7fe Document how to upgrade to Shlink 5.0 2026-01-09 14:10:46 +01:00
Alejandro Celaya
98b23a11a9 Merge pull request #2566 from acelaya-forks/remove-ext-json
Remove dependency in ext-json
2026-01-07 19:53:05 +01:00
Alejandro Celaya
9641c704e2 Remove dependency in ext-json 2026-01-07 19:48:57 +01:00
Alejandro Celaya
8e10e9138c Merge pull request #2563 from acelaya-forks/extended-visits-cli-output
Extend and normalize output from visits console commands
2026-01-03 11:59:50 +01:00
Alejandro Celaya
900de9e800 Extend and normalize output from visits console commands 2026-01-03 11:53:09 +01:00
Alejandro Celaya
0d964f0fde Merge pull request #2562 from acelaya-forks/visits-export
Allow exporting visits in CSV format
2025-12-29 11:07:03 +01:00
Alejandro Celaya
248e8032e3 Update changelog 2025-12-29 11:04:10 +01:00
Alejandro Celaya
a6286c247a Allow visits to be generated in CSV format 2025-12-29 10:43:37 +01:00
Alejandro Celaya
c0edcd3cfd Support paginating the output of visits commands to avoid out of memory errors 2025-12-29 10:22:50 +01:00
Alejandro Celaya
faed7ae60b Generalize VisitsDateRangeInput to VisitsListInput to add more common params 2025-12-28 11:59:37 +01:00
Alejandro Celaya
a65c5c3b56 Merge pull request #2560 from acelaya-forks/pie
Install compatible extensions in dev docker images with pie
2025-12-22 12:54:38 +01:00
Alejandro Celaya
6eb94194a3 Install zip extension with pie 2025-12-22 12:41:41 +01:00
Alejandro Celaya
18ac39ad9c Install APCU via pie in php dev docker image 2025-12-22 12:27:32 +01:00
Alejandro Celaya
05b833b399 Install xdebug with pie in dev docker images 2025-12-22 12:20:06 +01:00
Alejandro Celaya
cb8ba5d970 Merge pull request #2558 from acelaya-forks/redis-socket
Add support for redis connections via unix socket
2025-12-20 13:33:35 +01:00
Alejandro Celaya
983a7f444c Document removal of redis database index as path 2025-12-20 13:28:48 +01:00
Alejandro Celaya
ce9cbe2add Add support for redis connections via unix socket 2025-12-20 13:26:39 +01:00
Alejandro Celaya
fec9d0375d Merge pull request #2559 from acelaya-forks/fix-doctrine-error
Fix error when setting max results in a delete query
2025-12-20 13:26:12 +01:00
Alejandro Celaya
0ad777b6fa Fix error when setting max results in a delete query 2025-12-20 13:23:20 +01:00
Alejandro Celaya
7712f790e5 Merge pull request #2557 from acelaya-forks/after-date-rule
Support dynamic redirects based on an after-date condition
2025-12-18 09:47:09 +01:00
Alejandro Celaya
9ae2dce261 Support dynamic redirects based on an after-date condition 2025-12-18 09:41:07 +01:00
Alejandro Celaya
39585ed87d Merge pull request #2556 from acelaya-forks/before-date-rule
Support for redirects with a before-date condition
2025-12-18 09:29:53 +01:00
Alejandro Celaya
ca183d6e21 Some changes in before-date rule wording 2025-12-18 09:27:11 +01:00
Alejandro Celaya
54dc82cb90 Type date in RedirectCondition::forBeforeDate as Chronos 2025-12-18 09:21:13 +01:00
Andrei Vasilev
dae52fedf4 Support for redirects with a condition before date 2025-12-18 09:04:57 +01:00
Alejandro Celaya
77898d1edc Merge pull request #2553 from acelaya-forks/visits-invokable-commands
Convert all visits list commands into invokable commands
2025-12-17 15:51:20 +01:00
Alejandro Celaya
a774778822 Remove unecessary method from GetShortUrlVisitsCommand 2025-12-17 15:48:05 +01:00
Alejandro Celaya
d0ee6e549b Update changelog 2025-12-17 15:46:15 +01:00
Alejandro Celaya
c6b83a6437 Convert GetShortUrlVisitsCommand into invokable command 2025-12-17 15:43:14 +01:00
Alejandro Celaya
e265e55917 Convert GetDomainVisitsCommand into invokable command 2025-12-17 15:35:27 +01:00
Alejandro Celaya
ce7f334326 Convert GetTagVisitsCommand into invokable command 2025-12-17 15:32:21 +01:00
Alejandro Celaya
aecc36a463 Convert GetOrphanVisitsCommand into invokable command 2025-12-17 15:27:39 +01:00
Alejandro Celaya
66d35968f4 Convert GetNonOrphanVisitsCommand to invokable command 2025-12-17 15:22:56 +01:00
Alejandro Celaya
f9b1f0ebf4 Merge pull request #2552 from acelaya-forks/db-invokable-commands
Convert database commands into invokable commands
2025-12-16 09:19:26 +01:00
Alejandro Celaya
5b80ee73bb Convert database console commands into invokable commands 2025-12-16 09:14:39 +01:00
Alejandro Celaya
49daf9fbb6 Remove AbstractDatabaseCommand 2025-12-16 09:11:53 +01:00
Alejandro Celaya
83e373e96a Decouple database commands from AbstractDatabaseCommand 2025-12-16 09:07:17 +01:00
Alejandro Celaya
97c81fc1c8 Merge pull request #2551 from acelaya-forks/symfony-cli-improvements
Convert LocateVisitsCommand into invokable command
2025-12-15 15:27:44 +01:00
Alejandro Celaya
fff070ea87 Convert LocateVisitsCommand into invokable command 2025-12-15 15:24:01 +01:00
Alejandro Celaya
0c0a4ad940 Merge pull request #2550 from acelaya-forks/remove-abstract-locked-command
Remove AsbtractLockedCommand
2025-12-15 15:07:01 +01:00
Alejandro Celaya
e261bd16e4 Decouple AbstractDatabaseCommand from AbstractLockedCommand 2025-12-15 15:01:00 +01:00
Alejandro Celaya
96d122bcbf Decouple LocateVisitsCommand from AbstractLockedCommand 2025-12-15 14:55:06 +01:00
Alejandro Celaya
0f3f9d53c9 Merge pull request #2549 from acelaya-forks/mock-notices
Fix notices reported by latest PHPUnit version
2025-12-15 14:21:47 +01:00
Alejandro Celaya
da53c5a206 Fix notices reported by latest PHPUnit version 2025-12-15 14:17:36 +01:00
Alejandro Celaya
2c8bc6aca0 Merge pull request #2548 from acelaya-forks/symfony-cli-improvements
Symfony console improvements
2025-12-15 10:20:23 +01:00
Alejandro Celaya
9e4ea80139 Convert ResolveUrlCommand into invokable command 2025-12-15 10:16:09 +01:00
Alejandro Celaya
c30ec261c9 Convert DeleteOrphanVisitsCommand into invokable command 2025-12-15 10:08:09 +01:00
Alejandro Celaya
d481c06f09 Convert DeleteShortUrlVisitsCommand into invokable command 2025-12-15 10:04:48 +01:00
Alejandro Celaya
36cb760a88 Convert DeleteShortUrlCommand into invokable command 2025-12-15 09:47:16 +01:00
Alejandro Celaya
cbfcdd42c8 Merge pull request #2546 from acelaya-forks/symfony-cli-improvements
Symfony cli improvements
2025-12-15 09:39:02 +01:00
Alejandro Celaya
635e968bb2 Convert EditShortUrlCommand into invokable command 2025-12-15 09:35:38 +01:00
Alejandro Celaya
965d191ce1 Convert CreateShortUrlCommand into invokable command 2025-12-15 09:27:52 +01:00
Alejandro Celaya
b7ae228a95 Make tag and exclude-tag trully optional in ShortUrlsParamsInput 2025-12-15 08:57:54 +01:00
Alejandro Celaya
7cdefcb4b6 Convert ManageRedirectRulesCommand into invokable command 2025-12-15 08:39:05 +01:00
Alejandro Celaya
c496b7ac69 Require symfony/console 8.0 2025-12-15 08:36:20 +01:00
Alejandro Celaya
f0e12b1d06 Merge pull request #2545 from acelaya-forks/symfony-cli-improvements
Symfony cli improvements
2025-12-13 17:07:39 +01:00
Alejandro Celaya
88efe7d962 Fix PHPStan error 2025-12-13 17:05:02 +01:00
Alejandro Celaya
8fb8aea5f8 Replace interact method with Interact attribute in ReadEnvVarCommand 2025-12-13 16:59:16 +01:00
Alejandro Celaya
5a390894ea Use Ask attribute to simplify RenameApiKeyCommand 2025-12-13 16:55:31 +01:00
Alejandro Celaya
5df3abbce9 Migrate GenerateKeyCommand to symfony/console attributes 2025-12-13 16:49:52 +01:00
Alejandro Celaya
c53f538c79 Merge pull request #2544 from acelaya-forks/symfony-cli-improvements
Migrate ListShortUrlsCommand to symfony/console attributes
2025-12-13 11:13:09 +01:00
Alejandro Celaya
309ef8dc95 Fix pagination in short-url:list command 2025-12-13 11:10:42 +01:00
Alejandro Celaya
5600c1cc4f Update changelog 2025-12-13 11:07:25 +01:00
Alejandro Celaya
a75ee138e1 Migrate ListShortUrlsCommand to symfony/console attributes 2025-12-13 11:05:20 +01:00
Alejandro Celaya
89419e278c Merge pull request #2539 from acelaya-forks/symfony-8.0
Update to Symfony 8.0
2025-12-03 07:58:39 +01:00
Alejandro Celaya
1996745f64 Update to Symfony 8.0 2025-12-02 12:20:52 +01:00
Alejandro Celaya
cfab13bc47 Merge pull request #2533 from acelaya-forks/improve-coverage-2
Add more code coverage improvements
2025-11-18 09:33:20 +01:00
Alejandro Celaya
9432a5ba78 Add tests for events 2025-11-18 09:30:30 +01:00
Alejandro Celaya
7812a85b39 Remove unused AppOptions::__toString method 2025-11-18 09:20:52 +01:00
Alejandro Celaya
1e0b6be67d Improved NorFoundRedirectResolver test 2025-11-18 09:06:11 +01:00
Alejandro Celaya
88e5bb5618 Add test for AbstractRestAction::getRouteDef() 2025-11-18 08:56:09 +01:00
Alejandro Celaya
db1411d3f8 Remove unused method in ApiKeyNotFoundException 2025-11-18 08:45:31 +01:00
Alejandro Celaya
933c54e884 Improve some console commands coverage 2025-11-18 08:44:15 +01:00
Alejandro Celaya
f3ff059d48 Improve RoleResolver coverage 2025-11-17 12:33:08 +01:00
Alejandro Celaya
039a58bb44 Merge pull request #2532 from acelaya-forks/improve-coverage
Remove dead code that is affecting code coverage
2025-11-17 12:23:55 +01:00
Alejandro Celaya
0604237b94 Remove dead code that is affecting code coverage 2025-11-17 12:12:06 +01:00
Alejandro Celaya
c8537e4f71 Merge pull request #2529 from acelaya-forks/php-8.4-goodies
Simplify NotFoundRedirectConfigInterface with property hooks and asymetric visibility
2025-11-08 22:58:34 +01:00
Alejandro Celaya
c42fb67efc Simplify NotFoundRedirectConfigInterface with property hooks and asymetric visibility 2025-11-08 22:47:24 +01:00
Alejandro Celaya
ad15ae1922 Merge pull request #2526 from acelaya-forks/remove-trusted-proxies-workaround
Remove workaround to detect trusted proxies automatically
2025-11-08 10:44:09 +01:00
Alejandro Celaya
a731e01bd4 Remove test covering trusted proxies workaround 2025-11-08 10:41:03 +01:00
Alejandro Celaya
63bea36c05 Remove workaround to detect trusted proxies automatically 2025-11-08 10:33:06 +01:00
Alejandro Celaya
8a33c6968a Merge pull request #2525 from acelaya-forks/remove-tags-option
Remove deprecated --tags option in console commands
2025-11-08 10:29:26 +01:00
Alejandro Celaya
359129f586 Remove deprecated --tags option in console commands 2025-11-08 10:22:02 +01:00
Alejandro Celaya
fdcc9933a3 Merge pull request #2524 from acelaya-forks/list-urls-deprecations
Remove deprecated options from short-url:list command
2025-11-08 10:13:15 +01:00
Alejandro Celaya
94adba95eb Fix codecov/codecov-action arguments for v5 2025-11-08 10:09:20 +01:00
Alejandro Celaya
8bafd82e1d Remove deprecated options from short-url:list command 2025-11-08 10:07:51 +01:00
Alejandro Celaya
d2bc9f7c2b Merge pull request #2523 from acelaya-forks/no-disable-by-api-key
Do not allow API keys to be disabled by plain-text key
2025-11-08 09:34:34 +01:00
Alejandro Celaya
9f564b9785 Do not allow API keys to be disabled by plain-text key 2025-11-08 09:16:15 +01:00
Alejandro Celaya
1b6929acf6 Merge pull request #2518 from acelaya-forks/remove-extra-path
Remove REDIRECT_APPEND_EXTRA_PATH env var
2025-11-08 08:53:33 +01:00
Alejandro Celaya
91fd5809ff Remove REDIRECT_APPEND_EXTRA_PATH env var 2025-11-08 08:28:52 +01:00
Alejandro Celaya
c7fd6b3cba Merge pull request #2516 from acelaya-forks/drop-php-8.3
Drop support for PHP 8.3
2025-11-08 08:17:18 +01:00
Alejandro Celaya
1eb1f5344c Drop support for PHP 8.3 2025-11-07 17:21:54 +01:00
Alejandro Celaya
f9ec4cea62 Merge pull request #2515 from acelaya-forks/remove-qr-codes
Drop support for QR code generation
2025-11-07 17:21:04 +01:00
Alejandro Celaya
c3961b139a Remove image extensions from dev docker containers 2025-11-07 17:10:51 +01:00
Alejandro Celaya
c2aae9640d Remove requirement on ext-gd 2025-11-07 17:07:34 +01:00
Alejandro Celaya
b4043be7fa Drop support for QR code generation 2025-11-07 16:58:19 +01:00
Alejandro Celaya
49c67abf0a Add missing entry in 4.6.0 changelog 2025-11-01 12:53:14 +01:00
Alejandro Celaya
0d833904e8 Merge pull request #2510 from shlinkio/develop
Release 4.6.0
2025-11-01 12:48:03 +01:00
Alejandro Celaya
c6f718eb11 Add proper version contraints for shlinkio packages 2025-11-01 12:43:22 +01:00
Alejandro Celaya
d3e8e9a735 Add v4.6.0 to changelog 2025-11-01 12:38:00 +01:00
Alejandro Celaya
8f1542c7aa Merge pull request #2509 from acelaya-forks/invokable-commands
Invokable commands
2025-11-01 12:37:04 +01:00
Alejandro Celaya
058c0ebfaf Update changelog 2025-11-01 12:31:46 +01:00
Alejandro Celaya
b69db91378 Make DownloadGeoLiteDbCommand invokable 2025-11-01 12:30:15 +01:00
Alejandro Celaya
6113c28768 Make RenameTagCommand invokable 2025-11-01 12:28:04 +01:00
Alejandro Celaya
506ed47531 Make ListTagsCommand invokable 2025-11-01 12:25:52 +01:00
Alejandro Celaya
10173d2ab8 Make DeleteTagsCommand invokable 2025-11-01 12:24:18 +01:00
Alejandro Celaya
9ee709f0f3 Make DeleteExpiredShortUrlsCommand invokable 2025-11-01 12:18:29 +01:00
Alejandro Celaya
0fe28a5eb5 Make MatomoSendVisitsCommand invokable 2025-11-01 11:56:40 +01:00
Alejandro Celaya
2142afae89 Make ListDomainsCommand invokable 2025-11-01 11:50:43 +01:00
Alejandro Celaya
e7f4b84c65 Make DomainRedirectsCommand invokable 2025-11-01 11:45:27 +01:00
Alejandro Celaya
2d83b8d046 Make InitialApiKeyCommand invokable 2025-11-01 11:41:50 +01:00
Alejandro Celaya
dfef735c89 Make ReadEnvVarCommand invokable 2025-11-01 11:38:10 +01:00
Alejandro Celaya
c34c4e0eea Merge pull request #2508 from acelaya-forks/php-8.5-support
Add support for PHP 8.5
2025-11-01 11:04:08 +01:00
Alejandro Celaya
f024fd414e Add support for PHP 8.5 2025-11-01 10:13:00 +01:00
Alejandro Celaya
12d81c3213 Update changelog 2025-11-01 10:03:03 +01:00
Alejandro Celaya
628fb9ebb5 Merge pull request #2503 from acelaya-forks/domain-visits-filter
Allow tags, orphan and non-orphan visits lists to be filtered by domain
2025-11-01 10:02:32 +01:00
Alejandro Celaya
e21cea1971 Add API tests for visits domain filtering 2025-11-01 09:56:15 +01:00
Alejandro Celaya
37088b1a4b Allow filtering orphan visits by domain via DEFAULT keyword 2025-10-31 08:53:31 +01:00
Alejandro Celaya
b5f8e8a4cd Document domain param for visits endpoints 2025-10-30 10:23:00 +01:00
Alejandro Celaya
a236f19dc4 Allow filtering by domain in VisitRepository::findNonOrphanVisits 2025-10-30 10:08:46 +01:00
Alejandro Celaya
94426c7bf4 Allow filtering by domain in VisitRepository::findOrphanVisits 2025-10-30 09:04:51 +01:00
Alejandro Celaya
9dcc51abde Allow filtering by domain in VisitRepository::findVisitsByTag 2025-10-29 12:04:36 +01:00
Alejandro Celaya
70e376d569 Allow domain to be passed to tag:visits, visit:orphan and visit:non-orphan commands 2025-10-29 08:43:01 +01:00
Alejandro Celaya
14a7e3bb05 Allow tags, orphan and non-orphan visits to be provided a domain filter param 2025-10-28 11:08:33 +01:00
Alejandro Celaya
10dab5be20 Merge pull request #2504 from acelaya-forks/remove-set-accessible
Remove all calls to ReflectionProperty::setAccessible
2025-10-28 11:08:03 +01:00
Alejandro Celaya
532700028a Remove all calls to ReflectionProperty::setAccessible 2025-10-28 11:03:41 +01:00
Alejandro Celaya
fc54a25c32 Merge pull request #2501 from acelaya-forks/redis-sentinels-acl
Add support for redis credentials when using sentinels
2025-10-27 10:47:36 +01:00
Alejandro Celaya
ba16ba45f2 Add support for redis credentials when using sentinels 2025-10-27 10:16:57 +01:00
Alejandro Celaya
51c732a013 Document support for frankenphp in changelog 2025-10-24 08:45:52 +02:00
Alejandro Celaya
0f17990821 Merge pull request #2499 from acelaya-forks/api-key-filter
Allow filtering short URLs by API key name
2025-10-22 08:40:33 +02:00
Alejandro Celaya
02500143c1 Update changelog 2025-10-22 08:31:04 +02:00
Alejandro Celaya
9c22c7fc9c Add more tests for apiKeyName short URLs filtering 2025-10-22 08:28:45 +02:00
Alejandro Celaya
7860225c25 Add api-key-name option to short-url:list command 2025-10-22 08:04:29 +02:00
Alejandro Celaya
506ed6207f Allow filtering short URLs by API key name 2025-10-21 12:25:06 +02:00
Alejandro Celaya
30ed1d7c6b Merge pull request #2497 from acelaya-forks/delete-api-key
Add new command to delete API keys
2025-10-20 15:06:50 +02:00
Alejandro Celaya
b5a9353b85 Add new command to delete API keys 2025-10-20 10:34:53 +02:00
Alejandro Celaya
cae18ccfb3 Merge pull request #2495 from acelaya-forks/tags-option
Extract tags option to its own Option class
2025-10-20 09:02:15 +02:00
Alejandro Celaya
f876769b67 Extract tags option to its own Option class 2025-10-20 08:58:07 +02:00
Alejandro Celaya
2b06f56a9a Merge pull request #2492 from acelaya-forks/feature/exclude-tags
Allow listing short URLs which DO NOT include certain tags
2025-10-17 09:33:51 +02:00
Alejandro Celaya
1c38ab1217 Add exclude-tags CLI tests 2025-10-17 09:26:18 +02:00
Alejandro Celaya
fb9e8cd79f Update changelog 2025-10-17 08:56:26 +02:00
Alejandro Celaya
eb199a61da Add exclude-tags API tests 2025-10-17 08:52:25 +02:00
Alejandro Celaya
25de0263c5 Deprecate --tags and add --tag for short-url:list command 2025-10-17 08:35:41 +02:00
Alejandro Celaya
41c03a66e4 Fix static analysis 2025-10-16 19:16:11 +02:00
Alejandro Celaya
13c1b12d84 Update logic in ShortUrlListRepository to take excluded tags into consideration 2025-10-16 19:16:11 +02:00
Alejandro Celaya
fe10aaf245 Make --tags option allow multiple values in list short URLs command 2025-10-16 19:16:11 +02:00
Alejandro Celaya
464e3d7f8e Support excludeTags and excludeTagsMode in list short URLs command 2025-10-16 19:16:11 +02:00
Alejandro Celaya
ac40a7021b Document excludeTags and excludeTagsMode params for short URLs list 2025-10-16 19:16:11 +02:00
Alejandro Celaya
c60a5e750b Reference Jetbrains in README 2025-10-16 15:08:29 +02:00
Alejandro Celaya
785f728afc Merge pull request #2493 from acelaya-forks/fix-phpstan
Fix issue reported by phpstan in CrossDomainMiddleware
2025-10-16 10:16:33 +02:00
Alejandro Celaya
648696f778 Fix issue reported by phpstan in CrossDomainMiddleware 2025-10-16 10:08:24 +02:00
Alejandro Celaya
cd124789c4 Merge pull request #2491 from shlinkio/develop
Release 4.5.3
2025-10-10 10:39:17 +02:00
Alejandro Celaya
774a579a94 Add v4.5.3 to changelog 2025-10-10 10:29:06 +02:00
Alejandro Celaya
98bbb01165 Update coding standard 2025-10-06 08:46:34 +02:00
Alejandro Celaya
0bcb9e0438 Update changelog 2025-10-03 10:24:38 +02:00
Alejandro Celaya
edb8b57a48 Merge pull request #2489 from acelaya-forks/feature/cors-credentials-fix
Make sure Access-Control-Allow-Credentials is always set if configured
2025-10-03 10:23:02 +02:00
Alejandro Celaya
b01f271f72 Make sure Access-Control-Allow-Credentials is always set if configured 2025-10-03 10:15:27 +02:00
Alejandro Celaya
98b504a2de Merge pull request #2484 from acelaya-forks/feature/memory-efficient-geolite
Make GeoLite db download memory efficient
2025-09-11 09:39:32 +02:00
Alejandro Celaya
075e6347b6 Make GeoLite db download memory efficient 2025-09-11 09:28:44 +02:00
Alejandro Celaya
92a70b8c11 Merge pull request #2477 from acelaya-forks/feature/frankenphp
Add a development FrankenPHP server
2025-08-28 09:01:41 +02:00
Alejandro Celaya
52d257dd42 Merge pull request #2481 from shlinkio/develop
Release 4.5.2
2025-08-27 09:45:58 +02:00
Alejandro Celaya
613c7b7368 Merge pull request #2480 from acelaya-forks/feature/garbage-collection
Garbage collect after every request
2025-08-27 09:41:38 +02:00
Alejandro Celaya
232f6e37c6 Ensure pipeline is not marked as failed if only v8.5 fails 2025-08-27 09:30:41 +02:00
Alejandro Celaya
c818d5603d Garbage collect after every request 2025-08-27 09:24:28 +02:00
Alejandro Celaya
766b227e47 Add a development FrankenPHP server 2025-08-26 08:52:22 +02:00
Alejandro Celaya
ff77d8b149 Merge pull request #2479 from shlinkio/develop
Release 4.5.1
2025-08-24 11:28:44 +02:00
Alejandro Celaya
95be5a93fc Merge pull request #2478 from acelaya-forks/feature/memory-leak-mitigation
Try to mitigate memory leaks when using RoadRunner
2025-08-24 11:23:57 +02:00
Alejandro Celaya
20c41690da Try to mitigate memory leaks when using RoadRunner 2025-08-24 11:18:40 +02:00
Alejandro Celaya
22b5fa5a83 Merge pull request #2474 from acelaya-forks/feature/symfony-lock
Update to symfony/lock ^7.3.2
2025-08-01 08:28:05 +02:00
Alejandro Celaya
0c4d1b6d2f Update to symfony/lock ^7.3.2 2025-08-01 08:21:37 +02:00
Alejandro Celaya
4520ba50bf Merge pull request #2471 from shlinkio/develop
Release 4.5.0
2025-07-24 20:00:42 +02:00
Alejandro Celaya
d2514b7555 Merge pull request #2470 from acelaya-forks/feature/release-4.5.0
Add v4.5.0 to changelog
2025-07-24 12:11:03 +02:00
Alejandro Celaya
2d5734fc8b Add v4.5.0 to changelog 2025-07-24 12:07:11 +02:00
Alejandro Celaya
478ac344ff Merge pull request #2469 from acelaya-forks/feature/logs-encoding
Allow logs format to be configured as console or JSON
2025-07-24 10:01:36 +02:00
Alejandro Celaya
e40b82618a Allow logs format to be configured as console or JSON 2025-07-24 09:57:34 +02:00
Alejandro Celaya
51dd671174 Merge pull request #2467 from acelaya-forks/feature/nullable-match-value
Make RedirectCondition->matchValue nullable
2025-07-22 08:32:25 +02:00
Alejandro Celaya
5b5d0aae49 Make RedirectCondition->matchValue nullable 2025-07-22 08:28:09 +02:00
Alejandro Celaya
56df880a93 Merge pull request #2466 from acelaya-forks/feature/php-8.5
Run tests under PHP 8.5 in CI
2025-07-21 10:38:02 +02:00
Alejandro Celaya
afa509613a Run tests under PHP 8.5 in CI 2025-07-21 10:30:35 +02:00
Alejandro Celaya
3be49a25a0 Merge pull request #2465 from acelaya-forks/feature/redirect-cache-visibility
Allow redirect cache visibility to be configured
2025-07-21 10:21:36 +02:00
Alejandro Celaya
8b259b364d Allow redirect cache visibility to be configured 2025-07-21 10:13:17 +02:00
Alejandro Celaya
13d9b7b0a7 Merge pull request #2464 from acelaya-forks/feature/desktop-devices
Add support for more device types in device-specific redirects
2025-07-20 12:02:11 +02:00
Alejandro Celaya
2b33095392 Add support for more device types in device-specific redirects 2025-07-20 11:56:33 +02:00
Alejandro Celaya
3a1ce40a49 Merge pull request #2461 from acelaya-forks/feature/trusted-proxies
Allow trusted proxies to be provided via TRUSTED_PROXIES env var or config option
2025-07-18 08:32:48 +02:00
Alejandro Celaya
a68300f19a Fix phpstan report 2025-07-18 08:29:16 +02:00
Alejandro Celaya
3318987d63 Allow providing hop count via TRUSTED_PROXIES 2025-07-18 08:24:57 +02:00
Alejandro Celaya
1f825797f6 Allow trusted proxies to be provided via TRUSTED_PROXIES env var 2025-07-17 09:57:34 +02:00
Alejandro Celaya
650fafb7c4 Register ReverseForwardedAddressesMiddlewareDecorator via ServiceManager delegator 2025-07-17 09:47:02 +02:00
Alejandro Celaya
978e24d6fa Merge pull request #2460 from acelaya-forks/feature/enhanced-query-param-rules
Add support for any-value and valueless query param redirect rules
2025-07-17 08:57:30 +02:00
Alejandro Celaya
c3d3cc6288 Test RedirectConditionType::isValid() in isolation 2025-07-17 08:51:59 +02:00
Alejandro Celaya
223901324f Enhance RedirectRuleHandlerTest with new query-param-related conditions 2025-07-17 08:44:19 +02:00
Alejandro Celaya
47293be85c Enhance RedirectConditionTest with new query-param-related conditions 2025-07-17 08:39:37 +02:00
Alejandro Celaya
18c4c39fee Add support for any-value and valueless query param redirect rules 2025-07-17 08:31:29 +02:00
Alejandro Celaya
e762d28b67 Merge pull request #2455 from acelaya-forks/feature/cors-customization
Add new CORS configuration options
2025-07-16 08:41:42 +02:00
Alejandro Celaya
f5c6bc8204 Update changelog 2025-07-16 08:39:12 +02:00
Alejandro Celaya
3369afe22c Add CorsOptions test 2025-07-16 08:29:57 +02:00
Alejandro Celaya
1d96cc0279 Update CrossDomainMiddleware test 2025-07-08 13:17:46 +02:00
Alejandro Celaya
cd4fcc9b0a Update shlink-installer 2025-07-08 13:07:04 +02:00
Alejandro Celaya
834bc4ae20 Allow credentials to be enabled in CORS 2025-07-08 10:36:12 +02:00
Alejandro Celaya
92d7a44cee Add new CORS configuration options 2025-07-05 10:34:50 +02:00
Alejandro Celaya
c8e3b3df0a Update changelog 2025-07-04 18:31:20 +02:00
Alejandro Celaya
77244b52c9 Merge pull request #2454 from acelaya-forks/feature/real-time-updates-options
Allow individual real-time updates topics to be enabled
2025-07-04 18:29:12 +02:00
Alejandro Celaya
9e93e34e12 Add test to cover when visit updates topics are disabled 2025-07-04 18:25:45 +02:00
Alejandro Celaya
733b2e5647 Add test to cover when short URL updates topic is disabled 2025-07-04 18:04:27 +02:00
Alejandro Celaya
26fef87f3b Add RealTimeUpdatesOptions test 2025-07-04 10:07:40 +02:00
Alejandro Celaya
f4aaf02d55 Reduce duplicated code between enumValues and enumNames 2025-07-04 09:52:35 +02:00
Alejandro Celaya
314a99862d Update to latest shlink-installer with real-time updates support 2025-07-03 18:35:14 +02:00
Alejandro Celaya
240d9df177 Validate topic names in RealTimeUpdateOptions 2025-07-03 14:34:27 +02:00
Alejandro Celaya
fb995f2bea Allow individual real-time updates topics to be enabled 2025-07-03 10:10:06 +02:00
Alejandro Celaya
436be1985c Merge pull request #2452 from acelaya-forks/feature/invokable-command-poc
Use invokable commands approach on some API console commands
2025-06-26 08:46:20 +02:00
Alejandro Celaya
850e8574e9 Use invokable commands approach on some API console commands 2025-06-26 08:41:18 +02:00
Alejandro Celaya
c2743cb488 Merge pull request #2453 from acelaya-forks/feature/phpunit-warnings
Adjust tests to fix warnings
2025-06-26 08:40:10 +02:00
Alejandro Celaya
f1157aa177 Adjust tests to fix warnings 2025-06-24 19:47:18 +02:00
Alejandro Celaya
497429e685 Forward questions to the global discussions repo 2025-06-23 10:14:18 +02:00
Alejandro Celaya
2cad5dd435 Update to roadrunner 2025.1 2025-05-27 14:23:49 +02:00
Alejandro Celaya
f38f1ae5da Merge pull request #2439 from acelaya-forks/feature/mercure-enabled
Add new MERCURE_ENABLED env var
2025-05-22 08:29:23 +01:00
Alejandro Celaya
9c1db35d81 Add new MERCURE_ENABLED env var 2025-05-22 09:20:50 +02:00
Alejandro Celaya
11b8943919 Merge pull request #2432 from acelaya-forks/feature/docker-env-syntax
Update syntax used for env vars in Dockerfiles
2025-05-06 12:25:14 +02:00
Alejandro Celaya
27d24a4f15 Update syntax used for env vars in Dockerfiles 2025-05-06 11:56:49 +02:00
Alejandro Celaya
b2dbc4cf52 Fix typo in Dockerfile 2025-05-04 15:57:29 +02:00
Alejandro Celaya
1a7a745f2e Update Dockerfile marking image-related extensions as delegated 2025-05-04 15:56:44 +02:00
Alejandro Celaya
99bc1a21dd Merge pull request #2425 from acelaya-forks/feature/command-exit-codes
Replace ExitCode with standard symfony Command constants
2025-04-22 19:49:16 +02:00
Alejandro Celaya
cea8a982e2 Replace ExitCode with standard symfony Command constants 2025-04-22 12:07:41 +02:00
Alejandro Celaya
8bd1c6a79a Merge pull request #2423 from acelaya-forks/feature/remove-bootstrap
Remove references to bootstrap from error templates
2025-04-22 09:12:08 +02:00
Alejandro Celaya
71a3b993b1 Remove references to bootstrap from error templates 2025-04-22 09:09:52 +02:00
Alejandro Celaya
6e25e3c31d Merge pull request #2422 from acelaya-forks/feature/deprecate-qr-codes
Deprecate QR code generation endpoint
2025-04-22 08:50:34 +02:00
Alejandro Celaya
b15e832cf4 Deprecate QR code generation endpoint 2025-04-22 08:47:37 +02:00
Alejandro Celaya
851929ebef Merge pull request #2403 from acelaya-forks/feature/phpunit-phpstan-fixes
Fix compatibility with PHPUnit 12.0.9 and phpstan-phpunit
2025-03-24 19:36:44 +01:00
Alejandro Celaya
87d5f9bc75 Fix compatibility with PHPUnit 12.0.9 and phpstan-phpunit 2025-03-24 19:33:52 +01:00
Alejandro Celaya
c2649395f8 Merge pull request #2398 from shlinkio/develop
Release 4.4.6
2025-03-20 09:24:27 +01:00
Alejandro Celaya
b7d9ba8258 Merge pull request #2397 from acelaya-forks/feature/endroid-fix
Fix error intrduced by endroid/qr-code 6.0.4
2025-03-20 09:19:58 +01:00
Alejandro Celaya
6526cf8c44 Fix error intrduced by endroid/qr-code 6.0.4 2025-03-20 09:16:53 +01:00
Alejandro Celaya
a85afb2bee Merge pull request #2394 from acelaya-forks/feature/fix-artifact-removal
Update geekyeggo/delete-artifact action to v5
2025-03-14 18:00:47 +01:00
Alejandro Celaya
8b4067efbe Update geekyeggo/delete-artifact action to v5 2025-03-14 17:57:55 +01:00
Alejandro Celaya
c7c2272fab Update changelog 2025-03-14 17:53:23 +01:00
Alejandro Celaya
bc77750713 Merge pull request #2392 from wuuei/patch-1
Fix Matomo country logging by sending country code instead of country
2025-03-14 17:51:37 +01:00
Alejandro Celaya
1ceb38f50b Test actual arguments set to matomo tracker when sending visits 2025-03-14 17:40:37 +01:00
wuuei
d273b56144 Lock "endroid/qr-code" to 6.0.3 so that unit tests complete 2025-03-14 15:21:55 +00:00
wuuei
5cd7305666 Fix code style to resolve failing check 2025-03-14 15:20:49 +00:00
wuuei
3040a22c02 Fix Matomo country logging by sending country code instead of country name
Matomo expects the country code in lowercase for accurate logging and proper flag display
2025-03-13 15:33:00 +01:00
Alejandro Celaya
6991138812 Merge pull request #2379 from shlinkio/develop
Release 4.4.5
2025-03-01 09:41:16 +01:00
Alejandro Celaya
5eb1808217 Update CHANGELOG.md with V4.4.5 2025-03-01 09:14:37 +01:00
Alejandro Celaya
5eb14c5315 Merge pull request #2375 from acelaya-forks/feature/deprecation-error-reporting
Disable deprecation warnings when running in production envs
2025-02-21 21:18:44 +01:00
Alejandro Celaya
a18360a4d6 Disable deprecation warnings when running in production envs 2025-02-21 21:13:29 +01:00
309 changed files with 4568 additions and 4161 deletions

View File

@@ -1,49 +0,0 @@
title: 'Help wanted'
body:
- type: input
validations:
required: true
attributes:
label: Shlink version
placeholder: x.y.z
- type: input
validations:
required: true
attributes:
label: PHP version
placeholder: x.y.z
- type: dropdown
validations:
required: true
attributes:
label: How do you serve Shlink
options:
- Self-hosted Apache
- Self-hosted nginx
- Self-hosted RoadRunner
- Docker image
- Other (explain in summary)
- type: dropdown
validations:
required: true
attributes:
label: Database engine
options:
- MySQL
- MariaDB
- PostgreSQL
- MicrosoftSQL
- SQLite
- type: input
validations:
required: true
attributes:
label: Database version
placeholder: x.y.z
- type: textarea
validations:
required: true
attributes:
label: Summary
value: '<!-- Describe your issue, question or request here. -->'

View File

@@ -1,7 +0,0 @@
<!--
Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personally if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
-->

View File

@@ -2,4 +2,4 @@ blank_issues_enabled: true
contact_links:
- name: Question - Support
about: Do you need help setting up or using Shlink?
url: https://github.com/shlinkio/shlink/discussions/new?category=help-wanted
url: https://github.com/orgs/shlinkio/discussions/new?category=help-wanted

View File

@@ -13,11 +13,11 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.3', '8.4']
php-version: ['8.4', '8.5']
env:
LC_ALL: C
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install MSSQL ODBC
if: ${{ inputs.platform == 'ms' }}
run: sudo ./data/infra/ci/install-ms-odbc.sh
@@ -35,8 +35,8 @@ jobs:
- name: Run tests
run: composer test:db:${{ inputs.platform }}
- name: Upload code coverage
uses: actions/upload-artifact@v4
if: ${{ matrix.php-version == '8.3' && inputs.platform == 'sqlite:ci' }}
uses: actions/upload-artifact@v5
if: ${{ matrix.php-version == '8.4' && inputs.platform == 'sqlite:ci' }}
with:
name: coverage-db
path: |

View File

@@ -13,11 +13,11 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.3', '8.4']
php-version: ['8.4', '8.5']
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Start postgres database server
if: ${{ inputs.test-group == 'api' }}
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
@@ -32,8 +32,8 @@ jobs:
if: ${{ inputs.test-group == 'api' }}
run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr
- run: composer test:${{ inputs.test-group }}:ci
- uses: actions/upload-artifact@v4
if: ${{ matrix.php-version == '8.3' }}
- uses: actions/upload-artifact@v5
if: ${{ matrix.php-version == '8.4' }}
with:
name: coverage-${{ inputs.test-group }}
path: |

View File

@@ -27,10 +27,10 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.3']
php-version: ['8.4']
command: ['cs', 'stan', 'openapi:validate']
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
@@ -69,16 +69,15 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.3']
php-version: ['8.4']
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Use PHP
uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
extensions-cache-key: tests-extensions-${{ matrix.php-version }}
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v6
with:
path: build
- run: mv build/coverage-unit/coverage-unit.cov build/coverage-unit.cov
@@ -87,16 +86,16 @@ jobs:
- run: mv build/coverage-cli/coverage-cli.cov build/coverage-cli.cov
- run: vendor/bin/phpcov merge build --clover build/clover.xml
- name: Publish coverage
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
file: ./build/clover.xml
files: ./build/clover.xml
delete-artifacts:
needs:
- upload-coverage
runs-on: ubuntu-24.04
steps:
- uses: geekyeggo/delete-artifact@v2
- uses: geekyeggo/delete-artifact@v5
with:
name: |
coverage-*

View File

@@ -10,9 +10,9 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.3']
php-version: ['8.4']
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Determine version
id: determine_version
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT

View File

@@ -10,16 +10,16 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.3', '8.4']
php-version: ['8.4', '8.5']
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
install-deps: 'no'
- run: ./build.sh ${GITHUB_REF#refs/tags/v}
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v5
with:
name: dist-files-${{ matrix.php-version }}
path: build
@@ -28,8 +28,8 @@ jobs:
needs: ['build']
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- uses: actions/checkout@v5
- uses: actions/download-artifact@v6
with:
path: build
- name: Publish release with assets
@@ -45,6 +45,6 @@ jobs:
needs: ['publish']
runs-on: ubuntu-24.04
steps:
- uses: geekyeggo/delete-artifact@v2
- uses: geekyeggo/delete-artifact@v5
with:
name: dist-files-*

View File

@@ -4,6 +4,221 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [5.0.0] - 2026-01-09
### Added
* [#2431](https://github.com/shlinkio/shlink/issues/2431) Add new date-based conditions for the dynamic rules redirections system, that allow to perform redirections based on an ISO-8601 date value.
* `before-date`: matches when current date and time is earlier than the defined threshold.
* `after-date`: matches when current date and time is later than the defined threshold.
* [#2513](https://github.com/shlinkio/shlink/issues/2513) Add support for redis connections via unix socket (e.g. `REDIS_SERVERS=unix:/path/to/redis.sock`).
* Visits generated in the command line can now be formatted in CSV, via `--format=csv`.
### Changed
* [#2522](https://github.com/shlinkio/shlink/issues/2522) Shlink no longer tries to detect trusted proxies automatically, when resolving the visitor's IP address, as this is a potential security issue.
Instead, if you have more than 1 proxy in front of Shlink, you should provide `TRUSTED_PROXIES` env var, with either a comma-separated list of the IP addresses of your proxies, or a number indicating how many proxies are there in front of Shlink.
* [#2311](https://github.com/shlinkio/shlink/issues/2311) All visits-related commands now return more information, and columns are arranged slightly differently.
Among other things, they now always return the type of the visit, region, visited URL, redirected URL and whether the visit comes from a potential bot or not.
* [#2540](https://github.com/shlinkio/shlink/issues/2540) Update Symfony packages to 8.0.
* [#2512](https://github.com/shlinkio/shlink/issues/2512) Make all remaining console commands invokable.
### Deprecated
* *Nothing*
### Removed
* [#2507](https://github.com/shlinkio/shlink/issues/2507) Drop support for PHP 8.3.
* [#2514](https://github.com/shlinkio/shlink/issues/2514) Remove support to generate QR codes. This functionality is now handled by Shlink Web Client and Shlink Dashboard.
* [#2517](https://github.com/shlinkio/shlink/issues/2517) Remove `REDIRECT_APPEND_EXTRA_PATH` env var. Use `REDIRECT_EXTRA_PATH_MODE=append` instead.
* [#2519](https://github.com/shlinkio/shlink/issues/2519) Disabling API keys by their plain-text key is no longer supported. When calling `api-key:disable`, the first argument is now always assumed to be the name.
* [#2520](https://github.com/shlinkio/shlink/issues/2520) Remove deprecated `--including-all-tags` and `--show-api-key-name` options from `short-url:list` command. Use `--tags-all` and `--show-api-key` instead.
* [#2521](https://github.com/shlinkio/shlink/issues/2521) Remove deprecated `--tags` option in all commands using it. Use `--tag` multiple times instead, one per tag.
* [#2543](https://github.com/shlinkio/shlink/issues/2543) Remove support for `--order-by=field,dir` option `short-url:list` command. Use `--order-by=field-dir` instead.
* Remove support to provide redis database index via URI path. Use `?database=3` query instead.
* [#2565](https://github.com/shlinkio/shlink/issues/2565) Remove explicit dependency in ext-json, since it's part of PHP since v8.0
### Fixed
* [#2564](https://github.com/shlinkio/shlink/issues/2564) Fix error when trying to persist non-utf-8 title without being able to determine its original charset for parsing.
Now, when resolving a website's charset, two improvements have been introduced:
1. If the `Content-Type` header does not define the charset, we fall back to `<meta charset>` or `<meta http-equiv="Content-Type">`.
2. If it's still not possible to determine the charset, we ignore the auto-resolved title, to avoid other encoding errors further down the line.
## [4.6.0] - 2025-11-01
### Added
* [#2327](https://github.com/shlinkio/shlink/issues/2327) Allow filtering short URL lists by those not including certain tags.
Now, the `GET /short-urls` endpoint accepts two new params: `excludeTags`, which is an array of strings with the tags that should not be included, and `excludeTagsMode`, which accepts the values `any` and `all`, and determines if short URLs should be filtered out if they contain any of the excluded tags, or all the excluded tags.
Additionally, the `short-url:list` command also supports the same feature via `--exclude-tag` option, which requires a value and can be provided multiple times, and `--exclude-tags-all`, which does not expect a value and determines if the mode should be `all`, or `any`.
* [#2192](https://github.com/shlinkio/shlink/issues/2192) Allow filtering short URL lists by the API key that was used to create them.
Now, the `GET /short-urls` endpoint accepts a new `apiKeyName` param, which is ignored if the request is performed with a non-admin API key which name does not match the one provided here.
Additionally, the `short-url:list` command also supports the same feature via the `--api-key-name` option.
* [#2330](https://github.com/shlinkio/shlink/issues/2330) Add support to serve Shlink with FrankenPHP, by providing a worker script in `bin/frankenphp-worker.php`.
* [#2449](https://github.com/shlinkio/shlink/issues/2449) Add support to provide redis credentials separately when using redis sentinels, where provided servers are the sentinels and not the redis instances.
For this, Shlink supports two new env ras / config options, as `REDIS_SERVERS_USER` and `REDIS_SERVERS_PASSWORD`.
* [#2498](https://github.com/shlinkio/shlink/issues/2498) Allow orphan visits, non-orphan visits and tag visits lists to be filtered by domain.
This is done via the `domain` query parameter in API endpoints, and via the `--domain` option in console commands.
* [#2472](https://github.com/shlinkio/shlink/issues/2472) Add support for PHP 8.5
* [#2291](https://github.com/shlinkio/shlink/issues/2291) Add `api-key:delete` console command to delete API keys.
### Changed
* [#2424](https://github.com/shlinkio/shlink/issues/2424) Make simple console commands invokable.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [4.5.3] - 2025-10-10
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2488](https://github.com/shlinkio/shlink/issues/2488) Ensure `Access-Control-Allow-Credentials` is set in all cross-origin responses when `CORS_ALLOW_ORIGIN=true`.
## [4.5.2] - 2025-08-27
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2433](https://github.com/shlinkio/shlink/issues/2433) Try to mitigate memory leaks allowing RoadRunner to garbage collect memory after every request and every job, by setting `GC_COLLECT_CYCLES=true`.
## [4.5.1] - 2025-08-24
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2433](https://github.com/shlinkio/shlink/issues/2433) Try to mitigate memory leaks by restarting job and http workers every 250 executions when using RoadRunner.
## [4.5.0] - 2025-07-24
### Added
* [#2438](https://github.com/shlinkio/shlink/issues/2438) Add `MERCURE_ENABLED` env var and corresponding config option, to more easily allow the mercure integration to be toggled.
For BC, if this env var is not present, we'll still consider the integration enabled if the `MERCURE_PUBLIC_HUB_URL` env var has a value. This is considered deprecated though, and next major version will rely only on `MERCURE_ENABLED`, so if you are using Mercure, make sure to set `MERCURE_ENABLED=true` to be ready.
* [#2387](https://github.com/shlinkio/shlink/issues/2387) Add `REAL_TIME_UPDATES_TOPICS` env var and corresponding config option, to granularly decide which real-time updates topics should be enabled.
* [#2418](https://github.com/shlinkio/shlink/issues/2418) Add more granular control over how Shlink handles CORS. It is now possible to customize the `Access-Control-Allow-Origin`, `Access-Control-Max-Age` and `Access-Control-Allow-Credentials` headers via env vars or config options.
* [#2386](https://github.com/shlinkio/shlink/issues/2386) Add new `any-value-query-param` and `valueless-query-param` redirect rule conditions.
These new rules expand the existing `query-param`, which requires both a specific non-empty value in order to match the condition.
The new conditions match as soon as a query param exists with any or no value (in the case of `any-value-query-param`), or if a query param exists with no value at all (in the case of `valueless-query-param`).
* [#2360](https://github.com/shlinkio/shlink/issues/2360) Add `TRUSTED_PROXIES` env var and corresponding config option, to configure a comma-separated list of all the proxies in front of Shlink, or simply the amount of trusted proxies in front of Shlink.
This is important to properly detect visitor's IP addresses instead of incorrectly matching one of the proxy's IP address, and if provided, it disables a workaround introduced in https://github.com/shlinkio/shlink/pull/2359.
* [#2274](https://github.com/shlinkio/shlink/issues/2274) Add more supported device types for the `device` redirect condition:
* `linux`: Will match desktop devices with Linux.
* `windows`: Will match desktop devices with Windows.
* `macos`: Will match desktop devices with MacOS.
* `chromeos`: Will match desktop devices with ChromeOS.
* `mobile`: Will match any mobile devices with either Android or iOS.
* [#2093](https://github.com/shlinkio/shlink/issues/2093) Add `REDIRECT_CACHE_LIFETIME` env var and corresponding config option, so that it is possible to set the `Cache-Control` visibility directive (`public` or `private`) when the `REDIRECT_STATUS_CODE` has been set to `301` or `308`.
* [#2323](https://github.com/shlinkio/shlink/issues/2323) Add `LOGS_FORMAT` env var and corresponding config option, to allow the logs generated by Shlink to be in console or JSON formats.
### Changed
* [#2406](https://github.com/shlinkio/shlink/issues/2406) Remove references to bootstrap from error templates, and instead inline the very minimum required styles.
### Deprecated
* [#2408](https://github.com/shlinkio/shlink/issues/2408) Generating QR codes via `/{short-code}/qr-code` is now deprecated and will be removed in Shlink 5.0. Use the equivalent capability from web clients instead.
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [4.4.6] - 2025-03-20
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2391](https://github.com/shlinkio/shlink/issues/2391) When sending visits to Matomo, send the country code, not the country name.
* Fix error with new option introduced by `endroid/qr-code` 6.0.4.
## [4.4.5] - 2025-03-01
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2373](https://github.com/shlinkio/shlink/issues/2373) Ensure deprecation warnings do not end up escalated to `ErrorException`s by `ProblemDetailsMiddleware`.
In order to do this, Shlink will entirely ignore deprecation warnings when running in production, as those do not mean something is not working, but only that something will break in future versions.
## [4.4.4] - 2025-02-19
### Added
* *Nothing*

View File

@@ -1,26 +1,26 @@
FROM php:8.4-alpine3.21 AS base
ARG SHLINK_VERSION=latest
ENV SHLINK_VERSION ${SHLINK_VERSION}
ENV SHLINK_VERSION=${SHLINK_VERSION}
ARG SHLINK_RUNTIME=rr
ENV SHLINK_RUNTIME ${SHLINK_RUNTIME}
ENV SHLINK_RUNTIME=${SHLINK_RUNTIME}
ENV USER_ID '1001'
ENV PDO_SQLSRV_VERSION 5.12.0
ENV MS_ODBC_DOWNLOAD '7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
ENV MS_ODBC_SQL_VERSION 18_18.4.1.1
ENV LC_ALL 'C'
ENV USER_ID='1001'
ENV PDO_SQLSRV_VERSION='5.12.0'
ENV MS_ODBC_DOWNLOAD='7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
ENV MS_ODBC_SQL_VERSION='18_18.4.1.1'
ENV LC_ALL='C'
WORKDIR /etc/shlink
# Install required PHP extensions
RUN \
# Temp install dev dependencies needed to compile the extensions
apk add --no-cache --virtual .dev-deps sqlite-dev postgresql-dev icu-dev libzip-dev zlib-dev libpng-dev linux-headers && \
docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql intl calendar sockets bcmath zip gd && \
# Temp install dev dependencies needed to compile the extensions \
apk add --no-cache --virtual .dev-deps sqlite-dev postgresql-dev icu-dev libzip-dev zlib-dev linux-headers && \
docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql intl calendar sockets bcmath zip && \
apk add --no-cache sqlite-libs && \
docker-php-ext-install -j"$(nproc)" pdo_sqlite && \
# Remove temp dev extensions, and install prod equivalents that are required at runtime
# Remove temp dev extensions, and install prod equivalents that are required at runtime \
apk del .dev-deps && \
apk add --no-cache postgresql icu libzip libpng

View File

@@ -36,10 +36,9 @@ The idea is that you can just generate a container using the image and provide t
First, make sure the host where you are going to run shlink fulfills these requirements:
* PHP 8.3 or 8.4
* PHP 8.4 or 8.5
* The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath.
* apcu extension is recommended if you don't plan to use RoadRunner.
* xml extension is required if you want to generate QR codes in svg format.
* sockets and bcmath extensions are required if you want to integrate with a RabbitMQ instance.
* MySQL, MariaDB, PostgreSQL, MicrosoftSQL or SQLite.
* You will also need the corresponding pdo variation for the database you are planning to use: `pdo_mysql`, `pdo_pgsql`, `pdo_sqlsrv` or `pdo_sqlite`.
@@ -99,6 +98,12 @@ Both the API and CLI allow you to do mostly the same operations, except for API
If you are trying to find out how to run the project in development mode or how to provide contributions, read the [CONTRIBUTING](CONTRIBUTING.md) doc.
## Powered by
Thanks to [JetBrains](https://www.jetbrains.com/) for their continuous support to this project in the form of IDE licenses.
![JetBrains logo](https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg)
---
> This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com)

View File

@@ -1,5 +1,26 @@
# Upgrading
## From v4.x to v5.x
### General
* Generating QR codes by appending `/qr-code` to a short URL is no longer possible. Use external services to generate QR codes from a short URL, or the logic embedded in Shlink Web Client and Shlink Dashboard.
* Shlink no longer tries to detect trusted proxies automatically, when resolving the visitor's IP address.
Instead, if you have more than 1 proxy in front of Shlink, you should provide `TRUSTED_PROXIES` env var, with either a comma-separated list of the IP addresses of your proxies, or a number indicating how many proxies are there in front of Shlink.
* PHP 8.3 is no longer supported. Only 8.4 and 8.5 are officially supported as of Shlink 5.0.0.
### Changes in CLI
* Disabling API keys by their plain-text key is no longer supported. When calling `api-key:disable`, the first argument is now always assumed to be the name.
* All visits-related commands (`short-url:visits`, `tag:visits`, `domain:visits`, `visit:orphan` and `visit:non-orphan`) now return more information, and columns are arranged slightly differently.
* The `short-url:list` command no longer accepts `--including-all-tags` and `--show-api-key-name` options. Use `--tags-all` and `--show-api-key` instead.
* The `short-url:list` command no longer allows ordering using the `--order-by=field,dir` format. Use `--order-by=field-dir` instead.
* All commands which used to accept the `--tags` flag, no longer accept it. Pass `--tag` multiple times instead, one per tag.
### Changes in env vars
* The `REDIRECT_APPEND_EXTRA_PATH` env var is no longer supported. Use `REDIRECT_EXTRA_PATH_MODE=append` to enable the same behavior.
## From v3.x to v4.x
### General

36
bin/frankenphp-worker.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Laminas\Diactoros\ServerRequestFactory;
use Laminas\HttpHandlerRunner\Emitter\EmitterInterface;
use Mezzio\Application;
use Psr\Container\ContainerInterface;
use function frankenphp_handle_request;
use function gc_collect_cycles;
(static function (): void {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/../config/container.php';
$app = $container->get(Application::class);
$responseEmitter = $container->get(EmitterInterface::class);
$handler = static function () use ($app, $responseEmitter): void {
$response = $app->handle(ServerRequestFactory::fromGlobals());
$responseEmitter->emit($response);
};
$maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 0);
for ($nbRequests = 0; !$maxRequests || $nbRequests < $maxRequests; ++$nbRequests) {
$keepRunning = frankenphp_handle_request($handler);
// Call the garbage collector to reduce the chances of it being triggered in the middle of a page generation
gc_collect_cycles();
if (! $keepRunning) {
break;
}
}
})();

View File

@@ -2,18 +2,22 @@
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Mezzio\Application;
use Psr\Container\ContainerInterface;
use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware;
use Shlinkio\Shlink\EventDispatcher\RoadRunner\RoadRunnerTaskConsumerToListener;
use Spiral\RoadRunner\Http\PSR7Worker;
use function gc_collect_cycles;
use function Shlinkio\Shlink\Config\env;
(static function (): void {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/../config/container.php';
$rrMode = env('RR_MODE');
$gcCollectCycles = env('GC_COLLECT_CYCLES', default: false);
if ($rrMode === 'http') {
// This was spin-up as a web worker
@@ -25,6 +29,10 @@ use function Shlinkio\Shlink\Config\env;
$worker->respond($app->handle($req));
} catch (Throwable $e) {
$worker->getWorker()->error((string) $e);
} finally {
if ($gcCollectCycles) {
gc_collect_cycles();
}
}
}
} else {

View File

@@ -12,19 +12,16 @@
}
],
"require": {
"php": "^8.3",
"php": "^8.4",
"ext-curl": "*",
"ext-gd": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.6",
"cakephp/chronos": "^3.1",
"doctrine/dbal": "^4.2",
"doctrine/migrations": "^3.8",
"doctrine/orm": "^3.3",
"donatj/phpuseragentparser": "^1.10",
"endroid/qr-code": "^6.0",
"doctrine/dbal": "^4.4",
"doctrine/migrations": "^3.9",
"doctrine/orm": "^3.6",
"donatj/phpuseragentparser": "^1.11",
"friendsofphp/proxy-manager-lts": "^1.0",
"geoip2/geoip2": "^3.1",
"guzzlehttp/guzzle": "^7.9",
@@ -35,6 +32,7 @@
"laminas/laminas-inputfilter": "^2.31",
"laminas/laminas-servicemanager": "^3.23",
"laminas/laminas-stdlib": "^3.20",
"league/csv": "^9.28",
"matomo/matomo-php-tracker": "^3.3",
"mezzio/mezzio": "^3.20",
"mezzio/mezzio-fastroute": "^3.12",
@@ -43,37 +41,36 @@
"pagerfanta/core": "^3.8",
"ramsey/uuid": "^4.7",
"shlinkio/doctrine-specification": "^2.2",
"shlinkio/shlink-common": "^7.0",
"shlinkio/shlink-config": "^4.0",
"shlinkio/shlink-event-dispatcher": "^4.2",
"shlinkio/shlink-importer": "^5.6",
"shlinkio/shlink-installer": "^9.5",
"shlinkio/shlink-ip-geolocation": "^4.3",
"shlinkio/shlink-json": "^1.2",
"spiral/roadrunner": "^2024.3",
"spiral/roadrunner-cli": "^2.6",
"spiral/roadrunner-http": "^3.5",
"spiral/roadrunner-jobs": "^4.6",
"symfony/console": "^7.2",
"symfony/filesystem": "^7.2",
"symfony/lock": "7.1.6",
"symfony/process": "^7.2",
"symfony/string": "^7.2"
"shlinkio/shlink-common": "dev-main#d4ae052 as 8.0.0",
"shlinkio/shlink-config": "dev-main#fb186e4 as 4.1.0",
"shlinkio/shlink-event-dispatcher": "dev-main#54d4701 as 4.4.0",
"shlinkio/shlink-importer": "dev-main#63753cf as 5.7.0",
"shlinkio/shlink-installer": "dev-develop#a225b16 as 10.0.0",
"shlinkio/shlink-ip-geolocation": "dev-main#e0c45b2 as 5.0.0",
"shlinkio/shlink-json": "^1.3",
"spiral/roadrunner": "^2025.1",
"spiral/roadrunner-cli": "^2.7",
"spiral/roadrunner-http": "^3.6",
"spiral/roadrunner-jobs": "^4.7",
"symfony/console": "^8.0",
"symfony/filesystem": "^8.0",
"symfony/lock": "^8.0",
"symfony/process": "^8.0",
"symfony/string": "^8.0"
},
"require-dev": {
"devizzent/cebe-php-openapi": "^1.1.2",
"devster/ubench": "^2.1",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-phpunit": "^2.0.5",
"phpstan/phpstan-symfony": "^2.0",
"phpunit/php-code-coverage": "^12.0",
"phpunit/phpcov": "^11.0",
"phpunit/phpunit": "^12.0",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.4.0",
"shlinkio/shlink-test-utils": "^4.3.1",
"symfony/var-dumper": "^7.2",
"phpunit/phpunit": "^12.0.10",
"shlinkio/php-coding-standard": "~2.5.0",
"shlinkio/shlink-test-utils": "^4.4",
"symfony/var-dumper": "^8.0",
"veewee/composer-run-parallel": "^1.4"
},
"conflict": {
@@ -147,12 +144,12 @@
"test:cli:ci": [
"@putenv GENERATE_COVERAGE=yes",
"@test:cli",
"vendor/bin/phpcov merge build/coverage-cli --php build/coverage-cli.cov && rm build/coverage-cli/*.cov"
"@php -d memory_limit=-1 vendor/bin/phpcov merge build/coverage-cli --php build/coverage-cli.cov && rm build/coverage-cli/*.cov"
],
"test:cli:pretty": [
"@putenv GENERATE_COVERAGE=yes",
"@test:cli",
"phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov"
"@php -d memory_limit=-1 phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov"
],
"openapi:validate": "php-openapi validate docs/swagger/swagger.json",
"openapi:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/openapi-inlined.json",

View File

@@ -11,6 +11,8 @@ return (static function (): array {
'redis' => [
'servers' => $redisServers,
'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(),
'username' => EnvVars::REDIS_SERVERS_USER->loadFromEnv(),
'password' => EnvVars::REDIS_SERVERS_PASSWORD->loadFromEnv(),
],
];

View File

@@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
return [
'cors' => [
'max_age' => 3600,
],
];

View File

@@ -8,7 +8,7 @@ return [
'geolite2' => [
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
'temp_dir' => __DIR__ . '/../../data',
'temp_dir' => __DIR__ . '/../../data/temp-geolite',
'license_key' => EnvVars::GEOLITE_LICENSE_KEY->loadFromEnv(),
],

View File

@@ -13,6 +13,7 @@ return [
'enabled_options' => [
Option\Server\RuntimeConfigOption::class,
Option\Server\MemoryLimitConfigOption::class,
Option\Server\LogsFormatConfigOption::class,
Option\Database\DatabaseDriverConfigOption::class,
Option\Database\DatabaseNameConfigOption::class,
Option\Database\DatabaseHostConfigOption::class,
@@ -32,6 +33,8 @@ return [
Option\Cache\CacheNamespaceConfigOption::class,
Option\Redis\RedisServersConfigOption::class,
Option\Redis\RedisSentinelServiceConfigOption::class,
Option\Redis\RedisServersUserConfigOption::class,
Option\Redis\RedisServersPasswordConfigOption::class,
Option\Redis\RedisPubSubConfigOption::class,
Option\UrlShortener\ShortCodeLengthOption::class,
Option\Mercure\EnableMercureConfigOption::class,
@@ -41,6 +44,7 @@ return [
Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class,
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
Option\UrlShortener\RedirectCacheVisibilityConfigOption::class,
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
Option\UrlShortener\ExtraPathModeConfigOption::class,
Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class,
@@ -56,15 +60,6 @@ return [
Option\Tracking\DisableIpTrackingConfigOption::class,
Option\Tracking\DisableReferrerTrackingConfigOption::class,
Option\Tracking\DisableUaTrackingConfigOption::class,
Option\QrCode\DefaultSizeConfigOption::class,
Option\QrCode\DefaultMarginConfigOption::class,
Option\QrCode\DefaultFormatConfigOption::class,
Option\QrCode\DefaultErrorCorrectionConfigOption::class,
Option\QrCode\DefaultRoundBlockSizeConfigOption::class,
Option\QrCode\DefaultColorConfigOption::class,
Option\QrCode\DefaultBgColorConfigOption::class,
Option\QrCode\DefaultLogoUrlConfigOption::class,
Option\QrCode\EnabledForDisabledShortUrlsConfigOption::class,
Option\RabbitMq\RabbitMqEnabledConfigOption::class,
Option\RabbitMq\RabbitMqHostConfigOption::class,
Option\RabbitMq\RabbitMqUseSslConfigOption::class,
@@ -76,6 +71,11 @@ return [
Option\Matomo\MatomoBaseUrlConfigOption::class,
Option\Matomo\MatomoSiteIdConfigOption::class,
Option\Matomo\MatomoApiTokenConfigOption::class,
Option\RealTimeUpdates\RealTimeUpdatesTopicsConfigOption::class,
Option\Cors\CorsAllowOriginConfigOption::class,
Option\Cors\CorsAllowCredentialsConfigOption::class,
Option\Cors\CorsMaxAgeConfigOption::class,
Option\TrustedProxiesConfigOption::class,
],
'installation_commands' => [

View File

@@ -2,49 +2,47 @@
declare(strict_types=1);
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use RKA\Middleware\IpAddress;
use RKA\Middleware\Mezzio\IpAddressFactory;
use Shlinkio\Shlink\Core\Middleware\ReverseForwardedAddressesMiddlewareDecorator;
use Shlinkio\Shlink\Core\Config\EnvVars;
use function Shlinkio\Shlink\Core\splitByComma;
use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE;
return [
return (static function (): array {
$trustedProxies = EnvVars::TRUSTED_PROXIES->loadFromEnv();
$proxiesIsHopCount = is_numeric($trustedProxies);
// Configuration for RKA\Middleware\IpAddress
'rka' => [
'ip_address' => [
'attribute_name' => IP_ADDRESS_REQUEST_ATTRIBUTE,
'check_proxy_headers' => true,
'trusted_proxies' => [],
'headers_to_inspect' => [
'CF-Connecting-IP',
'X-Forwarded-For',
'X-Forwarded',
'Forwarded',
'True-Client-IP',
'X-Real-IP',
'X-Cluster-Client-Ip',
'Client-Ip',
return [
// Configuration for RKA\Middleware\IpAddress
'rka' => [
'ip_address' => [
'attribute_name' => IP_ADDRESS_REQUEST_ATTRIBUTE,
'check_proxy_headers' => true,
// List of trusted proxies
'trusted_proxies' => $proxiesIsHopCount ? [] : splitByComma($trustedProxies),
// Amount of addresses to skip from the right, before finding the visitor IP address
'hop_count' => $proxiesIsHopCount ? (int) $trustedProxies : 0,
'headers_to_inspect' => [
'CF-Connecting-IP',
'X-Forwarded-For',
'X-Forwarded',
'Forwarded',
'True-Client-IP',
'X-Real-IP',
'X-Cluster-Client-Ip',
'Client-Ip',
],
],
],
],
'dependencies' => [
'factories' => [
// IpAddress::class => IpAddressFactory::class,
'actual_ip_address_middleware' => IpAddressFactory::class,
ReverseForwardedAddressesMiddlewareDecorator::class => ConfigAbstractFactory::class,
'dependencies' => [
'factories' => [
IpAddress::class => IpAddressFactory::class,
],
],
'aliases' => [
// Make sure the decorated middleware is resolved when getting IpAddress::class, to make this decoration
// transparent for other parts of the code
IpAddress::class => ReverseForwardedAddressesMiddlewareDecorator::class,
],
],
ConfigAbstractFactory::class => [
ReverseForwardedAddressesMiddlewareDecorator::class => ['actual_ip_address_middleware'],
],
];
];
})();

View File

@@ -23,11 +23,16 @@ use function Shlinkio\Shlink\Config\runningInRoadRunner;
return (static function (): array {
$isDev = EnvVars::isDevEnv();
$common = [
$format = EnvVars::LOGS_FORMAT->loadFromEnv();
$buildCommonConfig = static fn (bool $addNewLine = false) => [
'level' => $isDev ? Level::Debug->value : Level::Info->value,
'processors' => [RequestIdMiddleware::class],
'line_format' =>
'[%datetime%] [%extra.' . RequestIdMiddleware::ATTRIBUTE . '%] %channel%.%level_name% - %message%',
'formatter' => [
'type' => $format,
'add_new_line' => $addNewLine,
'line_format' =>
'[%datetime%] [%extra.' . RequestIdMiddleware::ATTRIBUTE . '%] %channel%.%level_name% - %message%',
],
];
// In dev env or the docker container, stream Shlink logs to stderr, otherwise send them to a file
@@ -39,16 +44,15 @@ return (static function (): array {
'Shlink' => $useStreamForShlinkLogger ? [
'type' => LoggerType::STREAM->value,
'destination' => 'php://stderr',
...$common,
...$buildCommonConfig(),
] : [
'type' => LoggerType::FILE->value,
...$common,
...$buildCommonConfig(),
],
'Access' => [
'type' => LoggerType::STREAM->value,
'destination' => 'php://stderr',
'add_new_line' => ! runningInRoadRunner(),
...$common,
...$buildCommonConfig(! runningInRoadRunner()),
],
],

View File

@@ -12,6 +12,7 @@ return [
// This config is used by shlink-common. Do not delete
'mercure' => [
'enabled' => EnvVars::MERCURE_ENABLED->loadFromEnv(),
'public_hub_url' => EnvVars::MERCURE_PUBLIC_HUB_URL->loadFromEnv(),
'internal_hub_url' => EnvVars::MERCURE_INTERNAL_HUB_URL->loadFromEnv(),
'jwt_secret' => EnvVars::MERCURE_JWT_SECRET->loadFromEnv(),

View File

@@ -94,14 +94,6 @@ return (static function (): array {
],
'allowed_methods' => [RequestMethodInterface::METHOD_GET],
],
[
'name' => CoreAction\QrCodeAction::class,
'path' => '/{shortCode}/qr-code',
'middleware' => [
CoreAction\QrCodeAction::class,
],
'allowed_methods' => [RequestMethodInterface::METHOD_GET],
],
[
'name' => CoreAction\RedirectAction::class,
'path' => sprintf('/{shortCode}%s', $shortUrlRouteSuffix),

View File

@@ -10,7 +10,7 @@ use Mezzio;
use Mezzio\ProblemDetails;
use Shlinkio\Shlink\Core\Config\EnvVars;
return (new ConfigAggregator\ConfigAggregator(
return new ConfigAggregator\ConfigAggregator(
providers: [
Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class,
@@ -39,4 +39,4 @@ return (new ConfigAggregator\ConfigAggregator(
Core\Config\PostProcessor\MultiSegmentSlugProcessor::class,
Core\Config\PostProcessor\ShortUrlMethodsProcessor::class,
],
))->getMergedConfig();
)->getMergedConfig();

View File

@@ -11,15 +11,30 @@ const DEFAULT_SHORT_CODES_LENGTH = 5;
const MIN_SHORT_CODES_LENGTH = 4;
const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302;
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
const DEFAULT_REDIRECT_CACHE_VISIBILITY = 'private';
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
const LOOSE_URI_MATCHER = '/(.+)\:(.+)/i'; // Matches anything starting with a schema.
const DEFAULT_QR_CODE_SIZE = 300;
const DEFAULT_QR_CODE_MARGIN = 0;
const DEFAULT_QR_CODE_FORMAT = 'png';
const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true;
const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true;
const DEFAULT_QR_CODE_COLOR = '#000000'; // Black
const DEFAULT_QR_CODE_BG_COLOR = '#ffffff'; // White
const IP_ADDRESS_REQUEST_ATTRIBUTE = 'remote_address';
const REDIRECT_URL_REQUEST_ATTRIBUTE = 'redirect_url';
/**
* List of ISO 3166-1 alpha-2 two-letter country codes https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
*/
const ISO_COUNTRY_CODES = [
'AF', 'AX', 'AL', 'DZ', 'AS', 'AD', 'AO', 'AI', 'AQ', 'AG', 'AR', 'AM', 'AW', 'AU', 'AT', 'AZ',
'BS', 'BH', 'BD', 'BB', 'BY', 'BE', 'BZ', 'BJ', 'BM', 'BT', 'BO', 'BQ', 'BA', 'BW', 'BV', 'BR',
'IO', 'BN', 'BG', 'BF', 'BI', 'CV', 'KH', 'CM', 'CA', 'KY', 'CF', 'TD', 'CL', 'CN', 'CX', 'CC',
'CO', 'KM', 'CG', 'CD', 'CK', 'CR', 'CI', 'HR', 'CU', 'CW', 'CY', 'CZ', 'DK', 'DJ', 'DM', 'DO',
'EC', 'EG', 'SV', 'GQ', 'ER', 'EE', 'SZ', 'ET', 'FK', 'FO', 'FJ', 'FI', 'FR', 'GF', 'PF', 'TF',
'GA', 'GM', 'GE', 'DE', 'GH', 'GI', 'GR', 'GL', 'GD', 'GP', 'GU', 'GT', 'GG', 'GN', 'GW', 'GY',
'HT', 'HM', 'VA', 'HN', 'HK', 'HU', 'IS', 'IN', 'ID', 'IR', 'IQ', 'IE', 'IM', 'IL', 'IT', 'JM',
'JP', 'JE', 'JO', 'KZ', 'KE', 'KI', 'KP', 'KR', 'KW', 'KG', 'LA', 'LV', 'LB', 'LS', 'LR', 'LY',
'LI', 'LT', 'LU', 'MO', 'MG', 'MW', 'MY', 'MV', 'ML', 'MT', 'MH', 'MQ', 'MR', 'MU', 'YT', 'MX',
'FM', 'MD', 'MC', 'MN', 'ME', 'MS', 'MA', 'MZ', 'MM', 'NA', 'NR', 'NP', 'NL', 'NC', 'NZ', 'NI',
'NE', 'NG', 'NU', 'NF', 'MK', 'MP', 'NO', 'OM', 'PK', 'PW', 'PS', 'PA', 'PG', 'PY', 'PE', 'PH',
'PN', 'PL', 'PT', 'PR', 'QA', 'RE', 'RO', 'RU', 'RW', 'BL', 'SH', 'KN', 'LC', 'MF', 'PM', 'VC',
'WS', 'SM', 'ST', 'SA', 'SN', 'RS', 'SC', 'SL', 'SG', 'SX', 'SK', 'SI', 'SB', 'SO', 'ZA', 'GS',
'SS', 'ES', 'LK', 'SD', 'SR', 'SJ', 'SE', 'CH', 'SY', 'TW', 'TJ', 'TZ', 'TH', 'TL', 'TG', 'TK',
'TO', 'TT', 'TN', 'TR', 'TM', 'TC', 'TV', 'UG', 'UA', 'AE', 'GB', 'US', 'UM', 'UY', 'UZ', 'VU',
'VE', 'VN', 'VG', 'VI', 'WF', 'EH', 'YE', 'ZM', 'ZW',
];

View File

@@ -11,6 +11,7 @@ use function Shlinkio\Shlink\Core\enumValues;
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
// Set current directory to the project's root directory
chdir(dirname(__DIR__));
require 'vendor/autoload.php';
@@ -21,7 +22,11 @@ loadEnvVarsFromConfig(
enumValues(EnvVars::class),
);
// This is one of the first files loaded. Configure the timezone and memory limit here
// This is one of the first files loaded. Set global configuration here
error_reporting(
// Set a less strict error reporting for prod, where deprecation warnings should be ignored
EnvVars::isProdEnv() ? E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED : E_ALL,
);
ini_set('memory_limit', EnvVars::MEMORY_LIMIT->loadFromEnv());
date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv());

View File

@@ -4,13 +4,15 @@ declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
use function Shlinkio\Shlink\Config\runningInRoadRunner;
return [
EnvVars::APP_ENV->value => 'dev',
// EnvVars::GEOLITE_LICENSE_KEY->value => '',
// URL shortener
EnvVars::DEFAULT_DOMAIN->value => 'localhost:8800',
EnvVars::DEFAULT_DOMAIN->value => runningInRoadRunner() ? 'localhost:8800' : 'localhost:8008',
EnvVars::IS_HTTPS_ENABLED->value => false,
// Database - MySQL
@@ -58,6 +60,7 @@ return [
// EnvVars::MATOMO_API_TOKEN->value => ,
// Mercure
EnvVars::MERCURE_ENABLED->value => true,
EnvVars::MERCURE_PUBLIC_HUB_URL->value => 'http://localhost:8002',
EnvVars::MERCURE_INTERNAL_HUB_URL->value => 'http://shlink_mercure_proxy',
EnvVars::MERCURE_JWT_SECRET->value => 'mercure_jwt_key_long_enough_to_avoid_error',

View File

@@ -30,13 +30,17 @@ jobs:
prefetch: 10
logs:
encoding: console
mode: development
channels:
http:
mode: 'off' # Disable logging as Shlink handles it internally
server:
encoding: console
level: info
metrics:
encoding: console
level: debug
jobs:
encoding: console
level: debug

View File

@@ -35,15 +35,16 @@ jobs:
prefetch: 10
logs:
encoding: json
encoding: console
mode: development
channels:
http:
mode: 'off' # Disable logging as Shlink handles it internally
server:
encoding: json
encoding: console
level: info
metrics:
level: panic
jobs:
encoding: console
level: panic

View File

@@ -14,11 +14,13 @@ http:
forbid: ['.php', '.htaccess']
pool:
num_workers: ${WEB_WORKER_NUM:-0}
max_jobs: 250 # Restart worker after processing this amount of requests to mitigate memory leaks
jobs:
timeout: 300 # 5 minutes
pool:
num_workers: ${TASK_WORKER_NUM:-0}
max_jobs: 250 # Restart worker after processing this amount of jobs to mitigate memory leaks
consume: ['shlink']
pipelines:
shlink:
@@ -28,11 +30,14 @@ jobs:
prefetch: 10
logs:
encoding: ${LOGS_FORMAT:-console}
mode: production
channels:
http:
mode: 'off' # Disable logging as Shlink handles it internally
server:
encoding: ${LOGS_FORMAT:-console}
level: info
jobs:
encoding: ${LOGS_FORMAT:-console}
level: debug

View File

@@ -11,5 +11,11 @@ const ANDROID_USER_AGENT = 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (
. 'Chrome/109.0.5414.86 Mobile Safari/537.36';
const IOS_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 '
. '(KHTML, like Gecko) FxiOS/109.0 Mobile/15E148 Safari/605.1.15';
const DESKTOP_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like '
. 'Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/109.0.1518.61';
const WINDOWS_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'Chrome/138.0.0.0 Safari/537.36 Edg/138.0.3351.95';
const LINUX_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'HeadlessChrome/81.0.4044.113 Safari/537.36';
const MACOS_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 15_5) AppleWebKit/605.1.15 (KHTML, like Gecko) '
. 'Version/18.4 Safari/605.1.15';
const CHROMEOS_USER_AGENT = 'Mozilla/5.0 (X11; CrOS x86_64 16181.61.0) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'Chrome/134.0.6998.198 Safari/537.36';

View File

@@ -11,7 +11,7 @@ server {
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
}

View File

@@ -0,0 +1,53 @@
FROM dunglas/frankenphp:1-php8.4-alpine
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV PDO_SQLSRV_VERSION='5.12.0'
ENV MS_ODBC_DOWNLOAD='7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
ENV MS_ODBC_SQL_VERSION='18_18.4.1.1'
RUN apk update
# Install common php extensions
RUN docker-php-ext-install pdo_mysql
RUN docker-php-ext-install calendar
RUN apk add --no-cache oniguruma-dev
RUN docker-php-ext-install mbstring
RUN apk add --no-cache sqlite-libs
RUN apk add --no-cache sqlite-dev
RUN docker-php-ext-install pdo_sqlite
RUN apk add --no-cache icu-dev
RUN docker-php-ext-install intl
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
COPY --from=ghcr.io/php/pie:bin /pie /usr/bin/pie
RUN apk add --no-cache libzip-dev zlib-dev && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
docker-php-ext-install sockets && \
pie install xdebug/xdebug && \
pie install pecl/zip && \
apk del .phpize-deps
RUN docker-php-ext-install bcmath
# Install sqlsrv driver
RUN apk add --update linux-headers && \
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \
docker-php-ext-enable pdo_sqlsrv && \
apk del .phpize-deps && \
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk
# Install composer
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
# Make home directory writable by anyone
RUN chmod 777 /home
VOLUME /home/shlink
WORKDIR /home/shlink

View File

@@ -0,0 +1,2 @@
*
!.gitignore

2
data/infra/frankenphp_caddy_data/.gitignore vendored Executable file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -1,10 +1,10 @@
FROM php:8.4-fpm-alpine3.21
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.24
ENV PDO_SQLSRV_VERSION 5.12.0
ENV MS_ODBC_DOWNLOAD '7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
ENV MS_ODBC_SQL_VERSION 18_18.4.1.1
ENV APCU_VERSION='5.1.24'
ENV PDO_SQLSRV_VERSION='5.12.0'
ENV MS_ODBC_DOWNLOAD='7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
ENV MS_ODBC_SQL_VERSION='18_18.4.1.1'
RUN apk update
@@ -22,37 +22,26 @@ RUN docker-php-ext-install pdo_sqlite
RUN apk add --no-cache icu-dev
RUN docker-php-ext-install intl
RUN apk add --no-cache libzip-dev zlib-dev
RUN docker-php-ext-install zip
RUN apk add --no-cache libpng-dev
RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
COPY --from=ghcr.io/php/pie:bin /pie /usr/bin/pie
RUN apk add --no-cache libzip-dev zlib-dev && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
docker-php-ext-install sockets && \
pie install xdebug/xdebug && \
pie install pecl/zip && \
pie install apcu/apcu && \
apk del .phpize-deps
RUN docker-php-ext-install bcmath
# Install APCu extension
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu \
&& tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \
&& docker-php-ext-configure apcu \
&& docker-php-ext-install apcu \
&& rm /tmp/apcu.tar.gz \
&& rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \
&& echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
# Install xdebug and sqlsrv driver
# Install sqlsrv driver
RUN apk add --update linux-headers && \
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} xdebug && \
docker-php-ext-enable pdo_sqlsrv xdebug && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \
docker-php-ext-enable pdo_sqlsrv && \
apk del .phpize-deps && \
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk

View File

@@ -1,5 +1,4 @@
display_errors=On
error_reporting=-1
log_errors_max_len=0
zend.assertions=1
assert.exception=1

View File

@@ -1,9 +1,9 @@
FROM php:8.4-alpine3.21
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV PDO_SQLSRV_VERSION 5.12.0
ENV MS_ODBC_DOWNLOAD '7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
ENV MS_ODBC_SQL_VERSION 18_18.4.1.1
ENV PDO_SQLSRV_VERSION='5.12.0'
ENV MS_ODBC_DOWNLOAD='7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8'
ENV MS_ODBC_SQL_VERSION='18_18.4.1.1'
RUN apk update
@@ -21,27 +21,25 @@ RUN docker-php-ext-install pdo_sqlite
RUN apk add --no-cache icu-dev
RUN docker-php-ext-install intl
RUN apk add --no-cache libzip-dev zlib-dev
RUN docker-php-ext-install zip
RUN apk add --no-cache libpng-dev
RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
COPY --from=ghcr.io/php/pie:bin /pie /usr/bin/pie
RUN apk add --no-cache libzip-dev zlib-dev && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
docker-php-ext-install sockets && \
pie install xdebug/xdebug && \
pie install pecl/zip && \
apk del .phpize-deps
RUN docker-php-ext-install bcmath
# Install xdebug and sqlsrv driver
# Install sqlsrv driver
RUN apk add --update linux-headers && \
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} xdebug && \
docker-php-ext-enable pdo_sqlsrv xdebug && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \
docker-php-ext-enable pdo_sqlsrv && \
apk del .phpize-deps && \
rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk

2
data/temp-geolite/.gitignore vendored Executable file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -66,6 +66,37 @@ services:
extra_hosts:
- 'host.docker.internal:host-gateway'
shlink_frankenphp:
container_name: shlink_frankenphp
user: 1000:1000
build:
context: .
dockerfile: ./data/infra/frankenphp.Dockerfile
ports:
- "8008:8008"
volumes:
- ./:/home/shlink
- ./data/infra/php.ini:/usr/local/etc/php/php.ini
- ./data/infra/frankenphp_caddy_data:/data
- ./data/infra/frankenphp_caddy_config:/config
links:
- shlink_db_mysql
- shlink_db_postgres
- shlink_db_maria
- shlink_db_ms
- shlink_redis
- shlink_redis_acl
- shlink_mercure
- shlink_mercure_proxy
- shlink_rabbitmq
- shlink_matomo
environment:
FRANKENPHP_CONFIG: 'worker /home/shlink/bin/frankenphp-worker.php'
SERVER_NAME: ':8008 https:8009'
extra_hosts:
- 'host.docker.internal:host-gateway'
tty: true
shlink_db_mysql:
container_name: shlink_db_mysql
user: 1000:1000

View File

@@ -4,7 +4,7 @@ set -e
cd /etc/shlink
# Create data directories if they do not exist. This allows data dir to be mounted as an empty dir if needed
mkdir -p data/cache data/locks data/log data/proxies
mkdir -p data/cache data/locks data/log data/proxies data/temp-geolite
flags="--no-interaction --clear-db-cache"

View File

@@ -19,9 +19,13 @@
"device",
"language",
"query-param",
"any-value-query-param",
"valueless-query-param",
"ip-address",
"geolocation-country-code",
"geolocation-city-name"
"geolocation-city-name",
"before-date",
"after-date"
],
"description": "The type of the condition, which will determine the logic used to match it"
},
@@ -29,7 +33,7 @@
"type": ["string", "null"]
},
"matchValue": {
"type": "string"
"type": ["string", "null"]
}
}
}

View File

@@ -31,7 +31,7 @@
{
"name": "searchTerm",
"in": "query",
"description": "A query used to filter results by searching for it on the longUrl and shortCode fields. (Since v1.3.0)",
"description": "A query used to filter results by searching for it on the longUrl and shortCode fields.",
"required": false,
"schema": {
"type": "string"
@@ -40,7 +40,7 @@
{
"name": "tags[]",
"in": "query",
"description": "A list of tags used to filter the result set. Only short URLs tagged with at least one of the provided tags will be returned. (Since v1.3.0)",
"description": "A list of tags used to filter the result set. Only short URLs **with** these tags will be returned.",
"required": false,
"schema": {
"type": "array",
@@ -52,7 +52,29 @@
{
"name": "tagsMode",
"in": "query",
"description": "Tells how the filtering by tags should work, returning short URLs containing \"any\" of the tags, or \"all\" the tags. It's ignored if no tags are provided, and defaults to \"any\" if not provided.",
"description": "Tells how the filtering by `tags` should work, returning short URLs containing \"any\" of the tags, or \"all\" the tags. Defaults to \"any\".<br />It's ignored if `tags` is not provided.",
"required": false,
"schema": {
"type": "string",
"enum": ["any", "all"]
}
},
{
"name": "excludeTags[]",
"in": "query",
"description": "A list of tags used to filter the result set. Only short URLs **without** these tags will be returned.",
"required": false,
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "excludeTagsMode",
"in": "query",
"description": "Tells how the filtering by `excludeTags` should work, returning short URLs not containing \"any\" of the tags, or not containing \"all\" the tags. Defaults to \"any\".<br />It's ignored if `excludeTags` is not provided.",
"required": false,
"schema": {
"type": "string",
@@ -134,6 +156,15 @@
"schema": {
"type": "string"
}
},
{
"name": "apiKeyName",
"in": "query",
"description": "Only get short URLs created with this API key.<br />This value is **ignored** if the request is performed with a non-admin API key that does not match this name.",
"required": false,
"schema": {
"type": "string"
}
}
],
"security": [

View File

@@ -64,6 +64,10 @@
"type": "string",
"enum": ["true"]
}
},
{
"$ref": "../parameters/domain.json",
"description": "Return visits for short URLs that belong to this domain. Use **DEFAULT** keyword to return visits from default domain."
}
],
"security": [

View File

@@ -55,6 +55,10 @@
"type": "string",
"enum": ["true"]
}
},
{
"$ref": "../parameters/domain.json",
"description": "Return visits for short URLs that belong to this domain. Use **DEFAULT** keyword to return visits from default domain."
}
],
"security": [

View File

@@ -65,6 +65,10 @@
"type": "string",
"enum": ["invalid_short_url", "base_url", "regular_404"]
}
},
{
"$ref": "../parameters/domain.json",
"description": "Return only visits for this domain. Use **DEFAULT** keyword to return visits from default domain."
}
],
"security": [

View File

@@ -1,120 +0,0 @@
{
"get": {
"operationId": "shortUrlQrCode",
"tags": [
"URL Shortener"
],
"summary": "Short URL QR code",
"description": "Generates a QR code image pointing to a short URL.<br />Since this is not an API endpoint but an image one, when an invalid value is provided for any of the query params, they will fall to their default values instead of throwing an error.",
"parameters": [
{
"$ref": "../parameters/shortCode.json"
},
{
"name": "size",
"in": "query",
"description": "The size of the image to be returned.",
"required": false,
"schema": {
"type": "integer",
"minimum": 50,
"maximum": 1000,
"default": 300
}
},
{
"name": "format",
"in": "query",
"description": "The format for the QR code image, being valid values png and svg. Not providing the param or providing any other value will fall back to png.",
"required": false,
"schema": {
"type": "string",
"enum": ["png", "svg"],
"default": "png"
}
},
{
"name": "margin",
"in": "query",
"description": "The margin around the QR code image.",
"required": false,
"schema": {
"type": "integer",
"minimum": 0,
"default": 0
}
},
{
"name": "errorCorrection",
"in": "query",
"description": "The error correction level to apply to the QR code: **[L]**ow, **[M]**edium, **[Q]**uartile or **[H]**igh. See [docs](https://www.qrcode.com/en/about/error_correction.html).",
"required": false,
"schema": {
"type": "string",
"enum": ["L", "M", "Q", "H"],
"default": "L"
}
},
{
"name": "roundBlockSize",
"in": "query",
"description": "Allows to disable block size rounding, which might reduce the readability of the QR code, but ensures no extra margin is added.",
"required": false,
"schema": {
"type": "string",
"enum": ["true", "false"],
"default": "false"
}
},
{
"name": "color",
"in": "query",
"description": "The QR code foreground color. It should be an hex representation of a color, in 3 or 6 characters, optionally preceded by the \"#\" character.",
"required": false,
"schema": {
"type": "string",
"default": "#000000"
}
},
{
"name": "bgColor",
"in": "query",
"description": "The QR code background color. It should be an hex representation of a color, in 3 or 6 characters, optionally preceded by the \"#\" character.",
"required": false,
"schema": {
"type": "string",
"default": "#ffffff"
}
},
{
"name": "logo",
"in": "query",
"description": "Currently used to disable the logo that was set via configuration options. It may be used in future to dynamically choose from multiple logos.",
"required": false,
"schema": {
"type": "string",
"enum": ["disable"]
}
}
],
"responses": {
"200": {
"description": "QR code in PNG format",
"content": {
"image/png": {
"schema": {
"type": "string",
"format": "binary"
}
},
"image/svg+xml": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
}

View File

@@ -133,9 +133,6 @@
},
"/{shortCode}/track": {
"$ref": "paths/{shortCode}_track.json"
},
"/{shortCode}/qr-code": {
"$ref": "paths/{shortCode}_qr-code.json"
}
}
}

View File

@@ -26,6 +26,7 @@ return [
Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class,
Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,
Command\Api\DeleteKeyCommand::NAME => Command\Api\DeleteKeyCommand::class,
Command\Api\ListKeysCommand::NAME => Command\Api\ListKeysCommand::class,
Command\Api\InitialApiKeyCommand::NAME => Command\Api\InitialApiKeyCommand::class,
Command\Api\RenameApiKeyCommand::NAME => Command\Api\RenameApiKeyCommand::class,

View File

@@ -32,6 +32,7 @@ return [
RedirectRule\RedirectRuleHandler::class => InvokableFactory::class,
Util\ProcessRunner::class => ConfigAbstractFactory::class,
Util\PhpProcessRunner::class => ConfigAbstractFactory::class,
ApiKey\RoleResolver::class => ConfigAbstractFactory::class,
@@ -52,6 +53,7 @@ return [
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\DeleteKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class,
Command\Api\InitialApiKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\RenameApiKeyCommand::class => ConfigAbstractFactory::class,
@@ -78,6 +80,7 @@ return [
ConfigAbstractFactory::class => [
Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class],
Util\PhpProcessRunner::class => [Util\ProcessRunner::class, PhpExecutableFinder::class],
ApiKey\RoleResolver::class => [DomainService::class, UrlShortenerOptions::class],
Command\ShortUrl\CreateShortUrlCommand::class => [
@@ -104,10 +107,11 @@ return [
],
Command\Visit\GetOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\Visit\DeleteOrphanVisitsCommand::class => [Visit\VisitsDeleter::class],
Command\Visit\GetNonOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
Command\Visit\GetNonOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
Command\Api\DeleteKeyCommand::class => [ApiKeyService::class],
Command\Api\ListKeysCommand::class => [ApiKeyService::class],
Command\Api\InitialApiKeyCommand::class => [ApiKeyService::class],
Command\Api\RenameApiKeyCommand::class => [ApiKeyService::class],
@@ -115,11 +119,11 @@ return [
Command\Tag\ListTagsCommand::class => [TagService::class],
Command\Tag\RenameTagCommand::class => [TagService::class],
Command\Tag\DeleteTagsCommand::class => [TagService::class],
Command\Tag\GetTagVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
Command\Tag\GetTagVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\Domain\ListDomainsCommand::class => [DomainService::class],
Command\Domain\DomainRedirectsCommand::class => [DomainService::class],
Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\RedirectRule\ManageRedirectRulesCommand::class => [
ShortUrl\ShortUrlResolver::class,
@@ -134,16 +138,11 @@ return [
Command\Db\CreateDatabaseCommand::class => [
LockFactory::class,
Util\ProcessRunner::class,
PhpExecutableFinder::class,
Util\PhpProcessRunner::class,
'em',
NoDbNameConnectionFactory::SERVICE_NAME,
],
Command\Db\MigrateDatabaseCommand::class => [
LockFactory::class,
Util\ProcessRunner::class,
PhpExecutableFinder::class,
],
Command\Db\MigrateDatabaseCommand::class => [LockFactory::class, Util\PhpProcessRunner::class],
],
];

View File

@@ -4,15 +4,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\ApiKey;
use Shlinkio\Shlink\CLI\Command\Api\Input\ApiKeyInput;
use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Symfony\Component\Console\Input\InputInterface;
use function is_string;
/** @deprecated API key roles are deprecated */
readonly class RoleResolver implements RoleResolverInterface
{
public function __construct(
@@ -21,16 +19,16 @@ readonly class RoleResolver implements RoleResolverInterface
) {
}
public function determineRoles(InputInterface $input): iterable
public function determineRoles(ApiKeyInput $input): iterable
{
$domainAuthority = $input->getOption(Role::DOMAIN_SPECIFIC->paramName());
$author = $input->getOption(Role::AUTHORED_SHORT_URLS->paramName());
$noOrphanVisits = $input->getOption(Role::NO_ORPHAN_VISITS->paramName());
$domainAuthority = $input->domain;
$author = $input->authorOnly;
$noOrphanVisits = $input->noOrphanVisits;
if ($author) {
yield RoleDefinition::forAuthoredShortUrls();
}
if (is_string($domainAuthority)) {
if ($domainAuthority !== null) {
yield $this->resolveRoleForAuthority($domainAuthority);
}
if ($noOrphanVisits) {

View File

@@ -4,13 +4,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\ApiKey;
use Shlinkio\Shlink\CLI\Command\Api\Input\ApiKeyInput;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Symfony\Component\Console\Input\InputInterface;
/** @deprecated API key roles are deprecated */
interface RoleResolverInterface
{
/**
* @return iterable<RoleDefinition>
*/
public function determineRoles(InputInterface $input): iterable;
public function determineRoles(ApiKeyInput $input): iterable;
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Exception\ApiKeyNotFoundException;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function Shlinkio\Shlink\Core\ArrayUtils\map;
use function sprintf;
#[AsCommand(
name: DeleteKeyCommand::NAME,
description: 'Deletes an API key by name',
help: <<<HELP
The <info>%command.name%</info> command allows you to delete an existing API key via its name.
If no arguments are provided, you will be prompted to select one of the existing API keys.
<info>%command.full_name%</info>
You can optionally pass the API key name to be disabled:
<info>%command.full_name% the_key_name</info>
HELP,
)]
class DeleteKeyCommand extends Command
{
public const string NAME = 'api-key:delete';
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
{
parent::__construct();
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$apiKeyName = $input->getArgument('name');
if ($apiKeyName === null) {
$apiKeys = $this->apiKeyService->listKeys();
$name = new SymfonyStyle($input, $output)->choice(
'What API key do you want to delete?',
map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
);
$input->setArgument('name', $name);
}
}
public function __invoke(
SymfonyStyle $io,
InputInterface $input,
#[Argument(description: 'The API key to delete.')]
string|null $name = null,
): int {
if ($name === null) {
$io->warning('An API key name was not provided.');
return Command::INVALID;
}
if (! $this->shouldProceed($io, $input)) {
return Command::INVALID;
}
try {
$this->apiKeyService->deleteByName($name);
$io->success(sprintf('API key "%s" properly deleted', $name));
return Command::SUCCESS;
} catch (ApiKeyNotFoundException $e) {
$io->error($e->getMessage());
return Command::FAILURE;
}
}
private function shouldProceed(SymfonyStyle $io, InputInterface $input): bool
{
if (! $input->isInteractive()) {
return true;
}
$io->warning('You are about to delete an API key. This action cannot be undone.');
return $io->confirm('Are you sure you want to delete the API key?');
}
}

View File

@@ -4,20 +4,35 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function Shlinkio\Shlink\Core\ArrayUtils\map;
use function sprintf;
#[AsCommand(
name: DisableKeyCommand::NAME,
description: 'Disables an API key by name',
help: <<<HELP
The <info>%command.name%</info> command allows you to disable an existing API key.
If no arguments are provided, you will be prompted to select one of the existing non-disabled API keys.
<info>%command.full_name%</info>
You can optionally pass the API key name to be disabled:
<info>%command.full_name% the_key_name</info>
HELP,
)]
class DisableKeyCommand extends Command
{
public const string NAME = 'api-key:disable';
@@ -27,82 +42,37 @@ class DisableKeyCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$help = <<<HELP
The <info>%command.name%</info> command allows you to disable an existing API key, via its name or the
plain-text key.
If no arguments are provided, you will be prompted to select one of the existing non-disabled API keys.
<info>%command.full_name%</info>
You can optionally pass the API key name to be disabled. In that case <comment>--by-name</comment> is also
required, to indicate the first argument is the API key name and not the plain-text key:
<info>%command.full_name% the_key_name --by-name</info>
You can pass the plain-text key to be disabled, but that is <options=bold>DEPRECATED</>. In next major version,
the argument will always be assumed to be the name:
<info>%command.full_name% d6b6c60e-edcd-4e43-96ad-fa6b7014c143</info>
HELP;
$this
->setName(self::NAME)
->setDescription('Disables an API key by name or plain-text key (providing a plain-text key is DEPRECATED)')
->addArgument(
'keyOrName',
InputArgument::OPTIONAL,
'The API key to disable. Pass `--by-name` to indicate this value is the name and not the key.',
)
->addOption(
'by-name',
mode: InputOption::VALUE_NONE,
description: 'Indicates the first argument is the API key name, not the plain-text key.',
)
->setHelp($help);
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$keyOrName = $input->getArgument('keyOrName');
$name = $input->getArgument('name');
if ($keyOrName === null) {
if ($name === null) {
$apiKeys = $this->apiKeyService->listKeys(enabledOnly: true);
$name = (new SymfonyStyle($input, $output))->choice(
$name = new SymfonyStyle($input, $output)->choice(
'What API key do you want to disable?',
map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
);
$input->setArgument('keyOrName', $name);
$input->setOption('by-name', true);
$input->setArgument('name', $name);
}
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$keyOrName = $input->getArgument('keyOrName');
$byName = $input->getOption('by-name');
$io = new SymfonyStyle($input, $output);
if (! $keyOrName) {
public function __invoke(
SymfonyStyle $io,
#[Argument('The name of the API key to disable.')] string|null $name = null,
): int {
if ($name === null) {
$io->warning('An API key name was not provided.');
return ExitCode::EXIT_WARNING;
return Command::INVALID;
}
try {
if ($byName) {
$this->apiKeyService->disableByName($keyOrName);
} else {
$this->apiKeyService->disableByKey($keyOrName);
}
$io->success(sprintf('API key "%s" properly disabled', $keyOrName));
return ExitCode::EXIT_SUCCESS;
$this->apiKeyService->disableByName($name);
$io->success(sprintf('API key "%s" properly disabled', $name));
return Command::SUCCESS;
} catch (InvalidArgumentException $e) {
$io->error($e->getMessage());
return ExitCode::EXIT_FAILURE;
return Command::FAILURE;
}
}
}

View File

@@ -4,41 +4,27 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Command\Api\Input\ApiKeyInput;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function Shlinkio\Shlink\Core\arrayToString;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
use function sprintf;
class GenerateKeyCommand extends Command
{
public const string NAME = 'api-key:generate';
public function __construct(
private readonly ApiKeyServiceInterface $apiKeyService,
private readonly RoleResolverInterface $roleResolver,
) {
parent::__construct();
}
protected function configure(): void
{
$authorOnly = Role::AUTHORED_SHORT_URLS->paramName();
$domainOnly = Role::DOMAIN_SPECIFIC->paramName();
$noOrphanVisits = Role::NO_ORPHAN_VISITS->paramName();
$help = <<<HELP
#[AsCommand(
name: GenerateKeyCommand::NAME,
description: 'Generate a new valid API key',
help: <<<HELP
The <info>%command.name%</info> generates a new valid API key.
<info>%command.full_name%</info>
@@ -50,62 +36,26 @@ class GenerateKeyCommand extends Command
You can optionally set its expiration date with <comment>--expiration-date</comment> or <comment>-e</comment>:
<info>%command.full_name% --expiration-date 2020-01-01</info>
HELP,
)]
class GenerateKeyCommand extends Command
{
public const string NAME = 'api-key:generate';
You can also set roles to the API key:
* Can interact with short URLs created with this API key: <info>%command.full_name% --{$authorOnly}</info>
* Can interact with short URLs for one domain: <info>%command.full_name% --{$domainOnly}=example.com</info>
* Cannot see orphan visits: <info>%command.full_name% --{$noOrphanVisits}</info>
* All: <info>%command.full_name% --{$authorOnly} --{$domainOnly}=example.com --{$noOrphanVisits}</info>
HELP;
$this
->setName(self::NAME)
->setDescription('Generate a new valid API key.')
->addOption(
'name',
'm',
InputOption::VALUE_REQUIRED,
'The name by which this API key will be known.',
)
->addOption(
'expiration-date',
'e',
InputOption::VALUE_REQUIRED,
'The date in which the API key should expire. Use any valid PHP format.',
)
->addOption(
$authorOnly,
'a',
InputOption::VALUE_NONE,
sprintf('Adds the "%s" role to the new API key.', Role::AUTHORED_SHORT_URLS->value),
)
->addOption(
$domainOnly,
'd',
InputOption::VALUE_REQUIRED,
sprintf(
'Adds the "%s" role to the new API key, with the domain provided.',
Role::DOMAIN_SPECIFIC->value,
),
)
->addOption(
$noOrphanVisits,
'o',
InputOption::VALUE_NONE,
sprintf('Adds the "%s" role to the new API key.', Role::NO_ORPHAN_VISITS->value),
)
->setHelp($help);
public function __construct(
private readonly ApiKeyServiceInterface $apiKeyService,
private readonly RoleResolverInterface $roleResolver,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
public function __invoke(SymfonyStyle $io, InputInterface $input, #[MapInput] ApiKeyInput $inputData): int
{
$io = new SymfonyStyle($input, $output);
$expirationDate = $input->getOption('expiration-date');
$expirationDate = $inputData->expirationDate;
$apiKeyMeta = ApiKeyMeta::fromParams(
name: $input->getOption('name'),
expirationDate: isset($expirationDate) ? Chronos::parse($expirationDate) : null,
roleDefinitions: $this->roleResolver->determineRoles($input),
name: $inputData->name,
expirationDate: isset($expirationDate) ? normalizeOptionalDate($expirationDate) : null,
roleDefinitions: $this->roleResolver->determineRoles($inputData),
);
$apiKey = $this->apiKeyService->create($apiKeyMeta);
@@ -123,6 +73,6 @@ class GenerateKeyCommand extends Command
);
}
return ExitCode::EXIT_SUCCESS;
return Command::SUCCESS;
}
}

View File

@@ -4,13 +4,16 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: InitialApiKeyCommand::NAME,
description: 'Tries to create initial API key',
)]
class InitialApiKeyCommand extends Command
{
public const string NAME = 'api-key:initial';
@@ -20,24 +23,16 @@ class InitialApiKeyCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this
->setHidden()
->setName(self::NAME)
->setDescription('Tries to create initial API key')
->addArgument('apiKey', InputArgument::REQUIRED, 'The initial API to create');
}
public function __invoke(
SymfonyStyle $io,
#[Argument('The initial API to create')] string $apiKey,
): int {
$result = $this->apiKeyService->createInitial($apiKey);
protected function execute(InputInterface $input, OutputInterface $output): int
{
$key = $input->getArgument('apiKey');
$result = $this->apiKeyService->createInitial($key);
if ($result === null && $output->isVerbose()) {
$output->writeln('<comment>Other API keys already exist. Initial API key creation skipped.</comment>');
if ($result === null && $io->isVerbose()) {
$io->writeln('<comment>Other API keys already exist. Initial API key creation skipped.</comment>');
}
return ExitCode::EXIT_SUCCESS;
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api\Input;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Symfony\Component\Console\Attribute\Option;
final class ApiKeyInput
{
#[Option('The unique name by which this API key will be known', shortcut: 'm')]
public string|null $name = null;
#[Option('The date in which the API key should expire. Use any valid PHP format', shortcut: 'e')]
public string|null $expirationDate = null;
/** @deprecated */
#[Option('Adds the "' . Role::AUTHORED_SHORT_URLS->value . '" role to the new API key', shortcut: 'a')]
public bool $authorOnly = false;
/** @deprecated */
#[Option(
'Adds the "' . Role::DOMAIN_SPECIFIC->value . '" role to the new API key, with provided domain',
name: 'domain-only',
shortcut: 'd',
)]
public string|null $domain = null;
/** @deprecated */
#[Option('Adds the "' . Role::NO_ORPHAN_VISITS->value . '" role to the new API key', shortcut: 'o')]
public bool $noOrphanVisits = false;
}

View File

@@ -4,21 +4,24 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_filter;
use function array_map;
use function implode;
use function sprintf;
#[AsCommand(
name: ListKeysCommand::NAME,
description: 'Lists all the available API keys.',
)]
class ListKeysCommand extends Command
{
private const string ERROR_STRING_PATTERN = '<fg=red>%s</>';
@@ -32,23 +35,14 @@ class ListKeysCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Lists all the available API keys.')
->addOption(
'enabled-only',
'e',
InputOption::VALUE_NONE,
'Tells if only enabled API keys should be returned.',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$enabledOnly = $input->getOption('enabled-only');
public function __invoke(
SymfonyStyle $io,
#[Option(
description: 'Tells if only enabled API keys should be returned.',
shortcut: 'e',
)]
bool $enabledOnly = false,
): int {
$rows = array_map(function (ApiKey $apiKey) use ($enabledOnly) {
$expiration = $apiKey->expirationDate;
$messagePattern = $this->determineMessagePattern($apiKey);
@@ -66,14 +60,14 @@ class ListKeysCommand extends Command
return $rowData;
}, $this->apiKeyService->listKeys($enabledOnly));
ShlinkTable::withRowSeparators($output)->render(array_filter([
ShlinkTable::withRowSeparators($io)->render(array_filter([
'Name',
! $enabledOnly ? 'Is enabled' : null,
'Expiration date',
'Roles',
]), $rows);
return ExitCode::EXIT_SUCCESS;
return Command::SUCCESS;
}
private function determineMessagePattern(ApiKey $apiKey): string

View File

@@ -4,19 +4,18 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Model\Renaming;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Ask;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function Shlinkio\Shlink\Core\ArrayUtils\map;
#[AsCommand(
name: RenameApiKeyCommand::NAME,
description: 'Renames an API key by name',
)]
class RenameApiKeyCommand extends Command
{
public const string NAME = 'api-key:rename';
@@ -26,52 +25,16 @@ class RenameApiKeyCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Renames an API key by name')
->addArgument('oldName', InputArgument::REQUIRED, 'Current name of the API key to rename')
->addArgument('newName', InputArgument::REQUIRED, 'New name to set to the API key');
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$oldName = $input->getArgument('oldName');
$newName = $input->getArgument('newName');
if ($oldName === null) {
$apiKeys = $this->apiKeyService->listKeys();
$requestedOldName = $io->choice(
'What API key do you want to rename?',
map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
);
$input->setArgument('oldName', $requestedOldName);
}
if ($newName === null) {
$requestedNewName = $io->ask(
'What is the new name you want to set?',
validator: static fn (string|null $value): string => $value !== null
? $value
: throw new InvalidArgumentException('The new name cannot be empty'),
);
$input->setArgument('newName', $requestedNewName);
}
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$oldName = $input->getArgument('oldName');
$newName = $input->getArgument('newName');
public function __invoke(
SymfonyStyle $io,
#[Argument(description: 'Current name of the API key to rename'), Ask('What API key do you want to rename?')]
string $oldName,
#[Argument(description: 'New name to set to the API key'), Ask('What is the new name you want to set?')]
string $newName,
): int {
$this->apiKeyService->renameApiKey(Renaming::fromNames($oldName, $newName));
$io->success('API key properly renamed');
return ExitCode::EXIT_SUCCESS;
return Command::SUCCESS;
}
}

View File

@@ -5,13 +5,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Config;
use Closure;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Interact;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function Shlinkio\Shlink\Config\formatEnvVarValue;
@@ -19,6 +19,11 @@ use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function Shlinkio\Shlink\Core\enumValues;
use function sprintf;
#[AsCommand(
name: ReadEnvVarCommand::NAME,
description: 'Display current value for an env var',
hidden: true,
)]
class ReadEnvVarCommand extends Command
{
public const string NAME = 'env-var:read';
@@ -32,19 +37,11 @@ class ReadEnvVarCommand extends Command
parent::__construct();
}
protected function configure(): void
#[Interact]
public function askMissing(InputInterface $input, SymfonyStyle $io): void
{
$this
->setName(self::NAME)
->setHidden()
->setDescription('Display current value for an env var')
->addArgument('envVar', InputArgument::REQUIRED, 'The env var to read');
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$envVar = $input->getArgument('envVar');
/** @var string|null $envVar */
$envVar = $input->getArgument('env-var');
$validEnvVars = enumValues(EnvVars::class);
if ($envVar === null) {
@@ -55,14 +52,14 @@ class ReadEnvVarCommand extends Command
throw new InvalidArgumentException(sprintf('%s is not a valid Shlink environment variable', $envVar));
}
$input->setArgument('envVar', $envVar);
$input->setArgument('env-var', $envVar);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$envVar = $input->getArgument('envVar');
$output->writeln(formatEnvVarValue(($this->loadEnvVar)($envVar)));
return ExitCode::EXIT_SUCCESS;
public function __invoke(
SymfonyStyle $io,
#[Argument(description: 'The env var to read')] string $envVar,
): int {
$io->writeln(formatEnvVarValue(($this->loadEnvVar)($envVar)));
return Command::SUCCESS;
}
}

View File

@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Db;
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Process\PhpExecutableFinder;
abstract class AbstractDatabaseCommand extends AbstractLockedCommand
{
private string $phpBinary;
public function __construct(
LockFactory $locker,
private readonly ProcessRunnerInterface $processRunner,
PhpExecutableFinder $phpFinder,
) {
parent::__construct($locker);
$this->phpBinary = $phpFinder->find(false) ?: 'php';
}
protected function runPhpCommand(OutputInterface $output, array $command): void
{
$command = [$this->phpBinary, ...$command, '--no-interaction'];
$this->processRunner->run($output, $command);
}
protected function getLockConfig(): LockedCommandConfig
{
return LockedCommandConfig::blocking($this->getName() ?? static::class);
}
}

View File

@@ -7,63 +7,65 @@ namespace Shlinkio\Shlink\CLI\Command\Db;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Command\Util\CommandUtils;
use Shlinkio\Shlink\CLI\Command\Util\LockConfig;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Process\PhpExecutableFinder;
use Throwable;
use function array_map;
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function Shlinkio\Shlink\Core\ArrayUtils\some;
class CreateDatabaseCommand extends AbstractDatabaseCommand
#[AsCommand(
name: CreateDatabaseCommand::NAME,
description: 'Creates the database needed for shlink to work. It will do nothing if the database already exists',
hidden: true,
)]
class CreateDatabaseCommand extends Command
{
private readonly Connection $regularConn;
public const string NAME = 'db:create';
public const string DOCTRINE_SCRIPT = 'bin/doctrine';
public const string DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create';
public const string SCRIPT = 'bin/doctrine';
public const string COMMAND = 'orm:schema-tool:create';
public function __construct(
LockFactory $locker,
ProcessRunnerInterface $processRunner,
PhpExecutableFinder $phpFinder,
private readonly LockFactory $locker,
private readonly ProcessRunnerInterface $processRunner,
private readonly EntityManagerInterface $em,
private readonly Connection $noDbNameConn,
) {
$this->regularConn = $this->em->getConnection();
parent::__construct($locker, $processRunner, $phpFinder);
parent::__construct();
}
protected function configure(): void
public function __invoke(SymfonyStyle $io): int
{
$this
->setName(self::NAME)
->setHidden()
->setDescription(
'Creates the database needed for shlink to work. It will do nothing if the database already exists',
);
return CommandUtils::executeWithLock(
$this->locker,
LockConfig::blocking(self::NAME),
$io,
fn () => $this->executeCommand($io),
);
}
protected function lockedExecute(InputInterface $input, OutputInterface $output): int
private function executeCommand(SymfonyStyle $io): int
{
$io = new SymfonyStyle($input, $output);
if ($this->databaseTablesExist()) {
$io->success('Database already exists. Run "db:migrate" command to make sure it is up to date.');
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
// Create database
$io->writeln('<fg=blue>Creating database tables...</>');
$this->runPhpCommand($output, [self::DOCTRINE_SCRIPT, self::DOCTRINE_CREATE_SCHEMA_COMMAND]);
$this->processRunner->run($io, [self::SCRIPT, self::COMMAND, '--no-interaction']);
$io->success('Database properly created!');
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
private function databaseTablesExist(): bool

View File

@@ -4,33 +4,48 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Db;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Shlinkio\Shlink\CLI\Command\Util\CommandUtils;
use Shlinkio\Shlink\CLI\Command\Util\LockConfig;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Lock\LockFactory;
class MigrateDatabaseCommand extends AbstractDatabaseCommand
#[AsCommand(
name: MigrateDatabaseCommand::NAME,
description: 'Runs database migrations, which will ensure the shlink database is up to date',
hidden: true,
)]
class MigrateDatabaseCommand extends Command
{
public const string NAME = 'db:migrate';
public const string DOCTRINE_MIGRATIONS_SCRIPT = 'vendor/doctrine/migrations/bin/doctrine-migrations.php';
public const string DOCTRINE_MIGRATE_COMMAND = 'migrations:migrate';
public const string SCRIPT = 'vendor/doctrine/migrations/bin/doctrine-migrations.php';
public const string COMMAND = 'migrations:migrate';
protected function configure(): void
{
$this
->setName(self::NAME)
->setHidden()
->setDescription('Runs database migrations, which will ensure the shlink database is up to date.');
public function __construct(
private readonly LockFactory $locker,
private readonly ProcessRunnerInterface $processRunner,
) {
parent::__construct();
}
protected function lockedExecute(InputInterface $input, OutputInterface $output): int
public function __invoke(SymfonyStyle $io): int
{
$io = new SymfonyStyle($input, $output);
return CommandUtils::executeWithLock(
$this->locker,
LockConfig::blocking(self::NAME),
$io,
fn () => $this->executeCommand($io),
);
}
private function executeCommand(SymfonyStyle $io): int
{
$io->writeln('<fg=blue>Migrating database...</>');
$this->runPhpCommand($output, [self::DOCTRINE_MIGRATIONS_SCRIPT, self::DOCTRINE_MIGRATE_COMMAND]);
$this->processRunner->run($io, [self::SCRIPT, self::COMMAND, '--no-interaction']);
$io->success('Database properly migrated!');
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
}

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Interact;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_filter;
@@ -19,6 +19,10 @@ use function array_map;
use function sprintf;
use function str_contains;
#[AsCommand(
name: DomainRedirectsCommand::NAME,
description: 'Set specific "not found" redirects for individual domains.',
)]
class DomainRedirectsCommand extends Command
{
public const string NAME = 'domain:redirects';
@@ -28,19 +32,8 @@ class DomainRedirectsCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Set specific "not found" redirects for individual domains.')
->addArgument(
'domain',
InputArgument::REQUIRED,
'The domain authority to which you want to set the specific redirects',
);
}
protected function interact(InputInterface $input, OutputInterface $output): void
#[Interact]
public function askDomain(InputInterface $input, SymfonyStyle $io): void
{
/** @var string|null $domain */
$domain = $input->getArgument('domain');
@@ -48,7 +41,6 @@ class DomainRedirectsCommand extends Command
return;
}
$io = new SymfonyStyle($input, $output);
$askNewDomain = static fn () => $io->ask('Domain authority for which you want to set specific redirects');
/** @var string[] $availableDomains */
@@ -68,10 +60,11 @@ class DomainRedirectsCommand extends Command
$input->setArgument('domain', str_contains($selectedOption, 'New domain') ? $askNewDomain() : $selectedOption);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$domainAuthority = $input->getArgument('domain');
public function __invoke(
SymfonyStyle $io,
#[Argument('The domain authority to which you want to set the specific redirects', name: 'domain')]
string $domainAuthority,
): int {
$domain = $this->domainService->findByAuthority($domainAuthority);
$ask = static function (string $message, string|null $current) use ($io): string|null {
@@ -95,20 +88,20 @@ class DomainRedirectsCommand extends Command
$this->domainService->configureNotFoundRedirects($domainAuthority, NotFoundRedirects::withRedirects(
$ask(
'URL to redirect to when a user hits this domain\'s base URL',
$domain?->baseUrlRedirect(),
$domain?->baseUrlRedirect,
),
$ask(
'URL to redirect to when a user hits a not found URL other than an invalid short URL',
$domain?->regular404Redirect(),
$domain?->regular404Redirect,
),
$ask(
'URL to redirect to when a user hits an invalid short URL',
$domain?->invalidShortUrlRedirect(),
$domain?->invalidShortUrlRedirect,
),
));
$io->success(sprintf('"Not found" redirects properly set for "%s"', $domainAuthority));
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
}

View File

@@ -4,50 +4,36 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Ask;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
class GetDomainVisitsCommand extends AbstractVisitsListCommand
#[AsCommand(GetDomainVisitsCommand::NAME, 'Returns the list of visits for provided domain')]
class GetDomainVisitsCommand extends Command
{
public const string NAME = 'domain:visits';
public function __construct(
VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
parent::__construct($visitsHelper);
public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Returns the list of visits for provided domain.')
->addArgument('domain', InputArgument::REQUIRED, 'The domain which visits we want to get.');
}
public function __invoke(
SymfonyStyle $io,
#[Argument('The domain which visits we want to get'), Ask('For what domain do you want to get visits?')]
string $domain,
#[MapInput] VisitsListInput $input,
): int {
$paginator = $this->visitsHelper->visitsForDomain($domain, new VisitsParams($input->dateRange()));
VisitsCommandUtils::renderOutput($io, $input, $paginator);
/**
* @return Paginator<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$domain = $input->getArgument('domain');
return $this->visitsHelper->visitsForDomain($domain, new VisitsParams($dateRange));
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->shortUrl;
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
return self::SUCCESS;
}
}

View File

@@ -4,18 +4,21 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_map;
#[AsCommand(
name: ListDomainsCommand::NAME,
description: 'List all domains that have been ever used for some short URL',
)]
class ListDomainsCommand extends Command
{
public const string NAME = 'domain:list';
@@ -25,25 +28,17 @@ class ListDomainsCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('List all domains that have been ever used for some short URL')
->addOption(
'show-redirects',
'r',
InputOption::VALUE_NONE,
'Will display an extra column with the information of the "not found" redirects for every domain.',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
public function __invoke(
SymfonyStyle $io,
#[Option(
'Will display an extra column with the information of the "not found" redirects for every domain.',
shortcut: 'r',
)]
bool $showRedirects = false,
): int {
$domains = $this->domainService->listDomains();
$showRedirects = $input->getOption('show-redirects');
$commonFields = ['Domain', 'Is default'];
$table = $showRedirects ? ShlinkTable::withRowSeparators($output) : ShlinkTable::default($output);
$table = $showRedirects ? ShlinkTable::withRowSeparators($io) : ShlinkTable::default($io);
$table->render(
$showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields,
@@ -54,19 +49,19 @@ class ListDomainsCommand extends Command
? [
...$commonValues,
$this->notFoundRedirectsToString($domain->notFoundRedirectConfig),
]
]
: $commonValues;
}, $domains),
);
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
private function notFoundRedirectsToString(NotFoundRedirectConfigInterface $config): string
{
$baseUrl = $config->baseUrlRedirect() ?? 'N/A';
$regular404 = $config->regular404Redirect() ?? 'N/A';
$invalidShortUrl = $config->invalidShortUrlRedirect() ?? 'N/A';
$baseUrl = $config->baseUrlRedirect ?? 'N/A';
$regular404 = $config->regular404Redirect ?? 'N/A';
$invalidShortUrl = $config->invalidShortUrlRedirect ?? 'N/A';
return <<<EOL
* Base URL: {$baseUrl}

View File

@@ -4,38 +4,25 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Integration;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface;
use Shlinkio\Shlink\Core\Matomo\VisitSendingProgressTrackerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\dateRangeToHumanFriendly;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
use function sprintf;
class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface
{
public const string NAME = 'integration:matomo:send-visits';
private readonly bool $matomoEnabled;
private SymfonyStyle $io;
public function __construct(MatomoOptions $matomoOptions, private readonly MatomoVisitSenderInterface $visitSender)
{
$this->matomoEnabled = $matomoOptions->enabled;
parent::__construct();
}
protected function configure(): void
{
$help = <<<HELP
#[AsCommand(
name: MatomoSendVisitsCommand::NAME,
description: 'Send existing visits to the configured matomo instance',
help: <<<HELP
This command allows you to send existing visits from this Shlink instance to the configured Matomo server.
Its intention is to allow you to configure Matomo at some point in time, and still have your whole visits
@@ -55,44 +42,40 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra
Send all visits created during 2022:
<info>%command.name% --since 2022-01-01 --until 2022-12-31</info>
HELP;
HELP,
)]
class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface
{
public const string NAME = 'integration:matomo:send-visits';
$this
->setName(self::NAME)
->setDescription(sprintf(
'%sSend existing visits to the configured matomo instance',
$this->matomoEnabled ? '' : '[MATOMO INTEGRATION DISABLED] ',
))
->setHelp($help)
->addOption(
'since',
's',
InputOption::VALUE_REQUIRED,
'Only visits created since this date, inclusively, will be sent to Matomo',
)
->addOption(
'until',
'u',
InputOption::VALUE_REQUIRED,
'Only visits created until this date, inclusively, will be sent to Matomo',
);
private readonly bool $matomoEnabled;
private SymfonyStyle $io;
public function __construct(MatomoOptions $matomoOptions, private readonly MatomoVisitSenderInterface $visitSender)
{
$this->matomoEnabled = $matomoOptions->enabled;
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->io = new SymfonyStyle($input, $output);
public function __invoke(
SymfonyStyle $io,
InputInterface $input,
#[Option('Only visits created since this date, inclusively, will be sent to Matomo', shortcut: 's')]
string|null $since = null,
#[Option('Only visits created until this date, inclusively, will be sent to Matomo', shortcut: 'u')]
string|null $until = null,
): int {
$this->io = $io;
if (! $this->matomoEnabled) {
$this->io->warning('Matomo integration is not enabled in this Shlink instance');
return ExitCode::EXIT_WARNING;
return self::INVALID;
}
// TODO Validate provided date formats
$since = $input->getOption('since');
$until = $input->getOption('until');
$dateRange = buildDateRange(
startDate: $since !== null ? Chronos::parse($since) : null,
endDate: $until !== null ? Chronos::parse($until) : null,
startDate: normalizeOptionalDate($since),
endDate: normalizeOptionalDate($until),
);
if ($input->isInteractive()) {
@@ -103,7 +86,7 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra
. 'you have verified only visits in the right date range are going to be sent.',
]);
if (! $this->io->confirm('Continue?', default: false)) {
return ExitCode::EXIT_WARNING;
return self::INVALID;
}
}
@@ -122,7 +105,7 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra
default => $this->io->info('There was no visits matching provided date range.'),
};
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
public function success(int $index): void

View File

@@ -4,55 +4,47 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\RedirectRule;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\CLI\RedirectRule\RedirectRuleHandlerInterface;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
#[AsCommand(
name: ManageRedirectRulesCommand::NAME,
description: 'Set redirect rules for a short URL',
)]
class ManageRedirectRulesCommand extends Command
{
public const string NAME = 'short-url:manage-rules';
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
public function __construct(
protected readonly ShortUrlResolverInterface $shortUrlResolver,
protected readonly ShortUrlRedirectRuleServiceInterface $ruleService,
protected readonly RedirectRuleHandlerInterface $ruleHandler,
) {
parent::__construct();
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
$this,
shortCodeDesc: 'The short code which rules we want to set.',
domainDesc: 'The domain for the short code.',
);
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Set redirect rules for a short URL');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
public function __invoke(
SymfonyStyle $io,
#[Argument('The short code which rules we want to set')] string $shortCode,
#[Option('The domain of the short code', shortcut: 'd')] string|null $domain = null,
): int {
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
try {
$shortUrl = $this->shortUrlResolver->resolveShortUrl($identifier);
} catch (ShortUrlNotFoundException) {
$io->error(sprintf('Short URL for %s not found', $identifier->__toString()));
return ExitCode::EXIT_FAILURE;
return self::FAILURE;
}
$rulesToSave = $this->ruleHandler->manageRules($io, $shortUrl, $this->ruleService->rulesForShortUrl($shortUrl));
@@ -61,6 +53,6 @@ class ManageRedirectRulesCommand extends Command
$io->success('Rules properly saved');
}
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
}

View File

@@ -4,125 +4,52 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Command\ShortUrl\Input\ShortUrlCreationInput;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
#[AsCommand(
name: CreateShortUrlCommand::NAME,
description: 'Generates a short URL for provided long URL and returns it',
)]
class CreateShortUrlCommand extends Command
{
public const string NAME = 'short-url:create';
private SymfonyStyle $io;
private readonly ShortUrlDataInput $shortUrlDataInput;
public function __construct(
private readonly UrlShortenerInterface $urlShortener,
private readonly ShortUrlStringifierInterface $stringifier,
private readonly UrlShortenerOptions $options,
) {
parent::__construct();
$this->shortUrlDataInput = new ShortUrlDataInput($this);
}
protected function configure(): void
public function __invoke(SymfonyStyle $io, #[MapInput] ShortUrlCreationInput $inputData): int
{
$this
->setName(self::NAME)
->setDescription('Generates a short URL for provided long URL and returns it')
->addOption(
'domain',
'd',
InputOption::VALUE_REQUIRED,
'The domain to which this short URL will be attached.',
)
->addOption(
'custom-slug',
'c',
InputOption::VALUE_REQUIRED,
'If provided, this slug will be used instead of generating a short code',
)
->addOption(
'short-code-length',
'l',
InputOption::VALUE_REQUIRED,
'The length for generated short code (it will be ignored if --custom-slug was provided).',
)
->addOption(
'path-prefix',
'p',
InputOption::VALUE_REQUIRED,
'Prefix to prepend before the generated short code or provided custom slug',
)
->addOption(
'find-if-exists',
'f',
InputOption::VALUE_NONE,
'This will force existing matching URL to be returned if found, instead of creating a new one.',
);
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$this->verifyLongUrlArgument($input, $output);
}
private function verifyLongUrlArgument(InputInterface $input, OutputInterface $output): void
{
$longUrl = $input->getArgument('longUrl');
if (! empty($longUrl)) {
return;
}
$io = $this->getIO($input, $output);
$longUrl = $io->ask('Which URL do you want to shorten?');
if (! empty($longUrl)) {
$input->setArgument('longUrl', $longUrl);
}
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = $this->getIO($input, $output);
try {
$result = $this->urlShortener->shorten($this->shortUrlDataInput->toShortUrlCreation(
$input,
$this->options,
customSlugField: 'custom-slug',
shortCodeLengthField: 'short-code-length',
pathPrefixField: 'path-prefix',
findIfExistsField: 'find-if-exists',
domainField: 'domain',
));
$result = $this->urlShortener->shorten($inputData->toShortUrlCreation($this->options));
$result->onEventDispatchingError(static fn () => $io->isVerbose() && $io->warning(
'Short URL properly created, but the real-time updates cannot be notified when generating the '
. 'short URL from the command line. Migrate to roadrunner in order to bypass this limitation.',
. 'short URL from the command line. Migrate to roadrunner in order to bypass this limitation.',
));
$io->writeln([
sprintf('Processed long URL: <info>%s</info>', $result->shortUrl->getLongUrl()),
sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($result->shortUrl)),
]);
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
} catch (NonUniqueSlugException $e) {
$io->error($e->getMessage());
return ExitCode::EXIT_FAILURE;
return self::FAILURE;
}
}
private function getIO(InputInterface $input, OutputInterface $output): SymfonyStyle
{
return $this->io ??= new SymfonyStyle($input, $output);
}
}

View File

@@ -4,17 +4,20 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
#[AsCommand(
name: DeleteExpiredShortUrlsCommand::NAME,
description: 'Deletes all short URLs that are considered expired, because they have a validUntil date in the past',
)]
class DeleteExpiredShortUrlsCommand extends Command
{
public const string NAME = 'short-url:delete-expired';
@@ -24,32 +27,17 @@ class DeleteExpiredShortUrlsCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription(
'Deletes all short URLs that are considered expired, because they have a validUntil date in the past',
)
->addOption(
'evaluate-max-visits',
mode: InputOption::VALUE_NONE,
description: 'Also take into consideration short URLs which have reached their max amount of visits.',
)
->addOption('force', 'f', InputOption::VALUE_NONE, 'Delete short URLs with no confirmation')
->addOption(
'dry-run',
mode: InputOption::VALUE_NONE,
description: 'Delete short URLs with no confirmation',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$force = $input->getOption('force') || ! $input->isInteractive();
$dryRun = $input->getOption('dry-run');
$conditions = new ExpiredShortUrlsConditions(maxVisitsReached: $input->getOption('evaluate-max-visits'));
public function __invoke(
SymfonyStyle $io,
InputInterface $input,
#[Option('Also take into consideration short URLs which have reached their max amount of visits.')]
bool $evaluateMaxVisits = false,
#[Option('Delete short URLs with no confirmation', shortcut: 'f')] bool $force = false,
#[Option('Only check how many short URLs would be affected, without actually deleting them')]
bool $dryRun = false,
): int {
$conditions = new ExpiredShortUrlsConditions(maxVisitsReached: $evaluateMaxVisits);
$force = $force || ! $input->isInteractive();
if (! $force && ! $dryRun) {
$io->warning([
@@ -58,18 +46,19 @@ class DeleteExpiredShortUrlsCommand extends Command
'This action cannot be undone. Proceed at your own risk',
]);
if (! $io->confirm('Continue?', default: false)) {
return ExitCode::EXIT_WARNING;
return self::INVALID;
}
}
if ($dryRun) {
$result = $this->deleteShortUrlService->countExpiredShortUrls($conditions);
$io->success(sprintf('There are %s expired short URLs matching provided conditions', $result));
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
$result = $this->deleteShortUrlService->deleteExpiredShortUrls($conditions);
$io->success(sprintf('%s expired short URLs have been deleted', $result));
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
}

View File

@@ -4,61 +4,47 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
#[AsCommand(name: DeleteShortUrlCommand::NAME, description: 'Deletes a short URL')]
class DeleteShortUrlCommand extends Command
{
public const string NAME = 'short-url:delete';
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
public function __construct(private readonly DeleteShortUrlServiceInterface $deleteShortUrlService)
{
parent::__construct();
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
$this,
shortCodeDesc: 'The short code for the short URL to be deleted',
domainDesc: 'The domain if the short code does not belong to the default one',
);
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Deletes a short URL')
->addOption(
'ignore-threshold',
'i',
InputOption::VALUE_NONE,
'Ignores the safety visits threshold check, which could make short URLs with many visits to be '
. 'accidentally deleted',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
$ignoreThreshold = $input->getOption('ignore-threshold');
public function __invoke(
SymfonyStyle $io,
#[Argument('The short code for the short URL to be deleted')] string $shortCode,
#[Option('The domain if the short code does not belong to the default one', shortcut: 'd')]
string|null $domain = null,
#[Option(
'Ignores the safety visits threshold check, which could make short URLs with many visits to be '
. 'accidentally deleted',
shortcut: 'i',
)]
bool $ignoreThreshold = false,
): int {
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
try {
$this->runDelete($io, $identifier, $ignoreThreshold);
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
} catch (Exception\ShortUrlNotFoundException $e) {
$io->error($e->getMessage());
return ExitCode::EXIT_FAILURE;
return self::FAILURE;
} catch (Exception\DeleteShortUrlException $e) {
return $this->retry($io, $identifier, $e->getMessage());
}
@@ -75,7 +61,7 @@ class DeleteShortUrlCommand extends Command
$io->warning('Short URL was not deleted.');
}
return $forceDelete ? ExitCode::EXIT_SUCCESS : ExitCode::EXIT_WARNING;
return $forceDelete ? self::SUCCESS : self::INVALID;
}
private function runDelete(SymfonyStyle $io, ShortUrlIdentifier $identifier, bool $ignoreThreshold): void

View File

@@ -4,55 +4,52 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Command\Visit\AbstractDeleteVisitsCommand;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Command\Util\CommandUtils;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleterInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand
#[AsCommand(DeleteShortUrlVisitsCommand::NAME, 'Deletes visits from a short URL')]
class DeleteShortUrlVisitsCommand extends Command
{
public const string NAME = 'short-url:visits-delete';
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
public function __construct(private readonly ShortUrlVisitsDeleterInterface $deleter)
{
parent::__construct();
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
$this,
shortCodeDesc: 'The short code for the short URL which visits will be deleted',
domainDesc: 'The domain if the short code does not belong to the default one',
}
public function __invoke(
SymfonyStyle $io,
#[Argument('The short code for the short URL which visits will be deleted')] string $shortCode,
#[Option('The domain if the short code does not belong to the default one', shortcut: 'd')]
string|null $domain = null,
): int {
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
return CommandUtils::executeWithWarning(
'You are about to delete all visits for a short URL. This operation cannot be undone',
$io,
fn () => $this->deleteVisits($io, $identifier),
);
}
protected function configure(): void
private function deleteVisits(SymfonyStyle $io, ShortUrlIdentifier $identifier): int
{
$this
->setName(self::NAME)
->setDescription('Deletes visits from a short URL');
}
protected function doExecute(InputInterface $input, SymfonyStyle $io): int
{
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
try {
$result = $this->deleter->deleteShortUrlVisits($identifier);
$io->success(sprintf('Successfully deleted %s visits', $result->affectedItems));
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
} catch (ShortUrlNotFoundException) {
$io->warning(sprintf('Short URL not found for "%s"', $identifier->__toString()));
return ExitCode::EXIT_WARNING;
return self::INVALID;
}
}
protected function getWarningMessage(): string
{
return 'You are about to delete all visits for a short URL. This operation cannot be undone.';
}
}

View File

@@ -4,60 +4,53 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Command\ShortUrl\Input\ShortUrlDataInput;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
#[AsCommand(
name: EditShortUrlCommand::NAME,
description: 'Edit an existing short URL',
)]
class EditShortUrlCommand extends Command
{
public const string NAME = 'short-url:edit';
private readonly ShortUrlDataInput $shortUrlDataInput;
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
public function __construct(
private readonly ShortUrlServiceInterface $shortUrlService,
private readonly ShortUrlStringifierInterface $stringifier,
) {
parent::__construct();
$this->shortUrlDataInput = new ShortUrlDataInput($this, longUrlAsOption: true);
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
$this,
shortCodeDesc: 'The short code to edit',
domainDesc: 'The domain to which the short URL is attached.',
);
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Edit an existing short URL');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
public function __invoke(
SymfonyStyle $io,
#[MapInput] ShortUrlDataInput $data,
#[Argument('The short code to edit')] string $shortCode,
#[Option('The domain to which the short URL is attached', shortcut: 'd')] string|null $domain = null,
#[Option('The long URL to set', shortcut: 'l')] string|null $longUrl = null,
): int {
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
try {
$shortUrl = $this->shortUrlService->updateShortUrl(
$identifier,
$this->shortUrlDataInput->toShortUrlEdition($input),
ShortUrlEdition::fromRawData($data->toArray()),
);
$io->success(sprintf('Short URL "%s" properly edited', $this->stringifier->stringify($shortUrl)));
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
} catch (ShortUrlNotFoundException $e) {
$io->error(sprintf('Short URL not found for "%s"', $identifier->__toString()));
@@ -65,7 +58,7 @@ class EditShortUrlCommand extends Command
$this->getApplication()?->renderThrowable($e, $io);
}
return ExitCode::EXIT_FAILURE;
return self::FAILURE;
}
}
}

View File

@@ -4,62 +4,43 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Ask;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
#[AsCommand(GetShortUrlVisitsCommand::NAME, 'Returns the detailed visits information for provided short code')]
class GetShortUrlVisitsCommand extends Command
{
public const string NAME = 'short-url:visits';
private ShortUrlIdentifierInput $shortUrlIdentifierInput;
protected function configure(): void
public function __construct(protected readonly VisitsStatsHelperInterface $visitsHelper)
{
$this
->setName(self::NAME)
->setDescription('Returns the detailed visits information for provided short code');
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
$this,
shortCodeDesc: 'The short code which visits we want to get.',
domainDesc: 'The domain for the short code.',
);
parent::__construct();
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$shortCode = $this->shortUrlIdentifierInput->shortCode($input);
if (! empty($shortCode)) {
return;
}
public function __invoke(
SymfonyStyle $io,
#[Argument('The short code which visits we want to get'), Ask('Which short code do you want to use?')]
string $shortCode,
#[MapInput] VisitsListInput $input,
#[Option('The domain for the short code', shortcut: 'd')]
string|null $domain = null,
): int {
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
$dateRange = $input->dateRange();
$paginator = $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange));
$io = new SymfonyStyle($input, $output);
$shortCode = $io->ask('A short code was not provided. Which short code do you want to use?');
if (! empty($shortCode)) {
$input->setArgument('shortCode', $shortCode);
}
}
VisitsCommandUtils::renderOutput($io, $input, $paginator);
/**
* @return Paginator<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
return $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange));
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
return [];
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl\Input;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Ask;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
/**
* Data used for short URL creation
*/
final class ShortUrlCreationInput
{
#[Argument('The long URL to set'), Ask('Which URL do you want to shorten?')]
public string $longUrl;
#[MapInput]
public ShortUrlDataInput $commonData;
#[Option('The domain to which this short URL will be attached', shortcut: 'd')]
public string|null $domain = null;
#[Option('If provided, this slug will be used instead of generating a short code', shortcut: 'c')]
public string|null $customSlug = null;
#[Option('The length for generated short code (it will be ignored if --custom-slug was provided)', shortcut: 'l')]
public int|null $shortCodeLength = null;
#[Option('Prefix to prepend before the generated short code or provided custom slug', shortcut: 'p')]
public string|null $pathPrefix = null;
#[Option(
'This will force existing matching URL to be returned if found, instead of creating a new one',
shortcut: 'f',
)]
public bool $findIfExists = false;
public function toShortUrlCreation(UrlShortenerOptions $options): ShortUrlCreation
{
$shortCodeLength = $this->shortCodeLength ?? $options->defaultShortCodesLength;
return ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => $this->longUrl,
ShortUrlInputFilter::DOMAIN => $this->domain,
ShortUrlInputFilter::CUSTOM_SLUG => $this->customSlug,
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
ShortUrlInputFilter::PATH_PREFIX => $this->pathPrefix,
ShortUrlInputFilter::FIND_IF_EXISTS => $this->findIfExists,
...$this->commonData->toArray(),
], $options);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl\Input;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Symfony\Component\Console\Attribute\Option;
use function array_unique;
/**
* Common input used for short URL creation and edition
*/
final class ShortUrlDataInput
{
/** @var string[]|null */
#[Option('Tags to apply to the short URL', name: 'tag', shortcut: 't')]
public array|null $tags = null;
#[Option(
'The date from which this short URL will be valid. '
. 'If someone tries to access it before this date, it will not be found',
shortcut: 's',
)]
public string|null $validSince = null;
#[Option(
'The date until which this short URL will be valid. '
. 'If someone tries to access it after this date, it will not be found',
shortcut: 'u',
)]
public string|null $validUntil = null;
#[Option('This will limit the number of visits for this short URL', shortcut: 'm')]
public int|null $maxVisits = null;
#[Option('A descriptive title for the short URL')]
public string|null $title = null;
#[Option('Tells if this short URL will be included as "Allow" in Shlink\'s robots.txt', shortcut: 'r')]
public bool|null $crawlable = null;
#[Option(
'Disables the forwarding of the query string to the long URL, when the short URL is visited',
shortcut: 'w',
)]
public bool|null $noForwardQuery = null;
public function toArray(): array
{
$data = [];
// Avoid setting arguments that were not explicitly provided.
// This is important when editing short URLs and should not make a difference when creating.
if ($this->validSince !== null) {
$data[ShortUrlInputFilter::VALID_SINCE] = $this->validSince;
}
if ($this->validUntil !== null) {
$data[ShortUrlInputFilter::VALID_UNTIL] = $this->validUntil;
}
if ($this->maxVisits !== null) {
$data[ShortUrlInputFilter::MAX_VISITS] = $this->maxVisits;
}
if ($this->tags !== null) {
$data[ShortUrlInputFilter::TAGS] = array_unique($this->tags);
}
if ($this->title !== null) {
$data[ShortUrlInputFilter::TITLE] = $this->title;
}
if ($this->crawlable !== null) {
$data[ShortUrlInputFilter::CRAWLABLE] = $this->crawlable;
}
if ($this->noForwardQuery !== null) {
$data[ShortUrlInputFilter::FORWARD_QUERY] = !$this->noForwardQuery;
}
return $data;
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl\Input;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
use Shlinkio\Shlink\CLI\Input\InputUtils;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Output\OutputInterface;
use function array_unique;
/**
* Input arguments and options for short-url:list command
* @see ListShortUrlsCommand
*/
final class ShortUrlsParamsInput
{
#[Option('The first page to list (10 items per page unless "--all" is provided).', shortcut: 'p')]
public int $page = 1;
#[Option(
'Disables pagination and just displays all existing URLs. Caution! If the amount of short URLs is big,this '
. 'may end up failing due to memory usage.',
)]
public bool $all = false;
#[Option('Only return short URLs older than this date', shortcut: 's')]
public string|null $startDate = null;
#[Option('Only return short URLs newer than this date', shortcut: 'e')]
public string|null $endDate = null;
#[Option('A query used to filter results by searching for it on the longUrl and shortCode fields', shortcut: 'st')]
public string|null $searchTerm = null;
#[Option(
'Used to filter results by domain. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword to filter by default domain',
shortcut: 'd',
)]
public string|null $domain = null;
/** @var string[]|null */
#[Option('A list of tags that short URLs need to include', name: 'tag', shortcut: 't')]
public array|null $tags = null;
#[Option('If --tag is provided, returns only short URLs including ALL of them')]
public bool $tagsAll = false;
/** @var string[]|null */
#[Option('A list of tags that short URLs should NOT include', name: 'exclude-tag', shortcut: 'et')]
public array|null $excludeTags = null;
#[Option('If --exclude-tag is provided, returns only short URLs not including ANY of them')]
public bool $excludeTagsAll = false;
#[Option('Excludes short URLs which reached their max amount of visits')]
public bool $excludeMaxVisitsReached = false;
#[Option('Excludes short URLs which have a "validUntil" date in the past')]
public bool $excludePastValidUntil = false;
#[Option(
'Field name to order the list by. Set the dir by optionally passing ASC or DESC after "-": --orderBy=tags-ASC',
shortcut: 'o',
)]
public string|null $orderBy = null;
#[Option('List only short URLs created by the API key matching provided name', shortcut: 'kn')]
public string|null $apiKeyName = null;
#[Option('Whether to display the tags or not')]
public bool $showTags = false;
#[Option(
'Whether to display the domain or not. Those belonging to default domain will have value '
. '"' . Domain::DEFAULT_AUTHORITY . '"',
)]
public bool $showDomain = false;
#[Option('Whether to display the API key name from which the URL was generated or not', shortcut: 'k')]
public bool $showApiKey = false;
public function toArray(OutputInterface $output): array
{
$data = [
ShortUrlsParamsInputFilter::PAGE => $this->page,
ShortUrlsParamsInputFilter::SEARCH_TERM => $this->searchTerm,
ShortUrlsParamsInputFilter::DOMAIN => $this->domain,
ShortUrlsParamsInputFilter::ORDER_BY => $this->orderBy,
ShortUrlsParamsInputFilter::START_DATE => InputUtils::processDate('start-date', $this->startDate, $output),
ShortUrlsParamsInputFilter::END_DATE => InputUtils::processDate('end-date', $this->endDate, $output),
ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED => $this->excludeMaxVisitsReached,
ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL => $this->excludePastValidUntil,
ShortUrlsParamsInputFilter::API_KEY_NAME => $this->apiKeyName,
];
if ($this->tags !== null) {
$tagsMode = $this->tagsAll ? TagsMode::ALL : TagsMode::ANY;
$data[ShortUrlsParamsInputFilter::TAGS_MODE] = $tagsMode->value;
$data[ShortUrlsParamsInputFilter::TAGS] = array_unique($this->tags);
}
if ($this->excludeTags !== null) {
$excludeTagsMode = $this->excludeTagsAll ? TagsMode::ALL : TagsMode::ANY;
$data[ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE] = $excludeTagsMode->value;
$data[ShortUrlsParamsInputFilter::EXCLUDE_TAGS] = array_unique($this->excludeTags);
}
if ($this->all) {
$data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = Paginator::ALL_ITEMS;
}
return $data;
}
}

View File

@@ -4,9 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\EndDateOption;
use Shlinkio\Shlink\CLI\Input\StartDateOption;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Command\ShortUrl\Input\ShortUrlsParamsInput;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
@@ -14,169 +12,57 @@ use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_keys;
use function array_pad;
use function explode;
use function implode;
use function Shlinkio\Shlink\Core\ArrayUtils\map;
use function sprintf;
#[AsCommand(name: ListShortUrlsCommand::NAME, description: 'List all short URLs')]
class ListShortUrlsCommand extends Command
{
public const string NAME = 'short-url:list';
private readonly StartDateOption $startDateOption;
private readonly EndDateOption $endDateOption;
public function __construct(
private readonly ShortUrlListServiceInterface $shortUrlService,
private readonly ShortUrlDataTransformerInterface $transformer,
) {
parent::__construct();
$this->startDateOption = new StartDateOption($this, 'short URLs');
$this->endDateOption = new EndDateOption($this, 'short URLs');
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('List all short URLs')
->addOption(
'page',
'p',
InputOption::VALUE_REQUIRED,
'The first page to list (10 items per page unless "--all" is provided).',
'1',
)
->addOption(
'search-term',
'st',
InputOption::VALUE_REQUIRED,
'A query used to filter results by searching for it on the longUrl and shortCode fields.',
)
->addOption(
'domain',
'd',
InputOption::VALUE_REQUIRED,
'Used to filter results by domain. Use DEFAULT keyword to filter by default domain',
)
->addOption(
'tags',
't',
InputOption::VALUE_REQUIRED,
'A comma-separated list of tags to filter results.',
)
->addOption(
'including-all-tags',
'i',
InputOption::VALUE_NONE,
'If tags is provided, returns only short URLs having ALL tags.',
)
->addOption(
'exclude-max-visits-reached',
null,
InputOption::VALUE_NONE,
'Excludes short URLs which reached their max amount of visits.',
)
->addOption(
'exclude-past-valid-until',
null,
InputOption::VALUE_NONE,
'Excludes short URLs which have a "validUntil" date in the past.',
)
->addOption(
'order-by',
'o',
InputOption::VALUE_REQUIRED,
'The field from which you want to order by. '
. 'Define ordering dir by passing ASC or DESC after "-" or ",".',
)
->addOption(
'show-tags',
null,
InputOption::VALUE_NONE,
'Whether to display the tags or not.',
)
->addOption(
'show-domain',
null,
InputOption::VALUE_NONE,
'Whether to display the domain or not. Those belonging to default domain will have value "DEFAULT".',
)
->addOption(
'show-api-key',
'k',
InputOption::VALUE_NONE,
'Whether to display the API key name from which the URL was generated or not.',
)
->addOption('show-api-key-name', 'm', InputOption::VALUE_NONE, '[DEPRECATED] Use show-api-key')
->addOption(
'all',
'a',
InputOption::VALUE_NONE,
'Disables pagination and just displays all existing URLs. Caution! If the amount of short URLs is big,'
. ' this may end up failing due to memory usage.',
);
}
public function __invoke(
SymfonyStyle $io,
InputInterface $input,
#[MapInput] ShortUrlsParamsInput $paramsInput,
): int {
$page = $paramsInput->page;
$data = $paramsInput->toArray($io);
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$page = (int) $input->getOption('page');
$searchTerm = $input->getOption('search-term');
$domain = $input->getOption('domain');
$tags = $input->getOption('tags');
$tagsMode = $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
$tags = ! empty($tags) ? explode(',', $tags) : [];
$all = $input->getOption('all');
$startDate = $this->startDateOption->get($input, $output);
$endDate = $this->endDateOption->get($input, $output);
$orderBy = $this->processOrderBy($input);
$columnsMap = $this->resolveColumnsMap($input);
$data = [
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
ShortUrlsParamsInputFilter::DOMAIN => $domain,
ShortUrlsParamsInputFilter::TAGS => $tags,
ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode,
ShortUrlsParamsInputFilter::ORDER_BY => $orderBy,
ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(),
ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(),
ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED => $input->getOption('exclude-max-visits-reached'),
ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL => $input->getOption('exclude-past-valid-until'),
];
if ($all) {
$data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = Paginator::ALL_ITEMS;
}
do {
$data[ShortUrlsParamsInputFilter::PAGE] = $page;
$result = $this->renderPage($output, $columnsMap, ShortUrlsParams::fromRawData($data), $all);
$result = $this->renderPage($io, $columnsMap, ShortUrlsParams::fromRawData($data), $paramsInput->all);
$page++;
$continue = $result->hasNextPage() && $io->confirm(
sprintf('Continue with page <options=bold>%s</>?', $page),
false,
default: false,
);
} while ($continue);
$io->newLine();
$io->success('Short URLs properly listed');
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
/**
@@ -205,17 +91,6 @@ class ListShortUrlsCommand extends Command
return $shortUrls;
}
private function processOrderBy(InputInterface $input): string|null
{
$orderBy = $input->getOption('order-by');
if (empty($orderBy)) {
return null;
}
[$field, $dir] = array_pad(explode(',', $orderBy), 2, null);
return $dir === null ? $field : sprintf('%s-%s', $field, $dir);
}
/**
* @return array<string, callable(array $serializedShortUrl, ShortUrl $shortUrl): ?string>
*/
@@ -237,7 +112,7 @@ class ListShortUrlsCommand extends Command
$columnsMap['Domain'] = static fn (array $_, ShortUrl $shortUrl): string =>
$shortUrl->getDomain()->authority ?? Domain::DEFAULT_AUTHORITY;
}
if ($input->getOption('show-api-key') || $input->getOption('show-api-key-name')) {
if ($input->getOption('show-api-key')) {
$columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): string|null =>
$shortUrl->authorApiKey?->name;
}

View File

@@ -4,65 +4,46 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Ask;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
#[AsCommand(ResolveUrlCommand::NAME, 'Returns the long URL behind a short code')]
class ResolveUrlCommand extends Command
{
public const string NAME = 'short-url:parse';
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
public const string NAME = 'short-url:resolve';
public function __construct(private readonly ShortUrlResolverInterface $urlResolver)
{
parent::__construct();
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
$this,
shortCodeDesc: 'The short code to parse',
domainDesc: 'The domain to which the short URL is attached.',
);
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Returns the long URL behind a short code');
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$shortCode = $this->shortUrlIdentifierInput->shortCode($input);
if (! empty($shortCode)) {
return;
}
$io = new SymfonyStyle($input, $output);
$shortCode = $io->ask('A short code was not provided. Which short code do you want to parse?');
if (! empty($shortCode)) {
$input->setArgument('shortCode', $shortCode);
}
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
public function __invoke(
SymfonyStyle $io,
#[
Argument('The short code to resolve'),
Ask('A short code was not provided. Which short code do you want to resolve?'),
]
string $shortCode,
#[Option('The domain to which the short URL is attached', shortcut: 'd')] string|null $domain = null,
): int {
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
try {
$url = $this->urlResolver->resolveShortUrl($this->shortUrlIdentifierInput->toShortUrlIdentifier($input));
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
return ExitCode::EXIT_SUCCESS;
$url = $this->urlResolver->resolveShortUrl($identifier);
$io->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
return self::SUCCESS;
} catch (ShortUrlNotFoundException $e) {
$io->error($e->getMessage());
return ExitCode::EXIT_FAILURE;
return self::FAILURE;
}
}
}

View File

@@ -4,14 +4,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: DeleteTagsCommand::NAME, description: 'Deletes one or more tags.')]
class DeleteTagsCommand extends Command
{
public const string NAME = 'tag:delete';
@@ -21,31 +20,21 @@ class DeleteTagsCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Deletes one or more tags.')
->addOption(
'name',
't',
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'The name of the tags to delete',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$tagNames = $input->getOption('name');
/**
* @param string[] $tagNames
*/
public function __invoke(
SymfonyStyle $io,
#[Option('The name of the tags to delete', name: 'name', shortcut: 't')] array $tagNames = [],
): int {
if (empty($tagNames)) {
$io->warning('You have to provide at least one tag name');
return ExitCode::EXIT_WARNING;
return self::INVALID;
}
$this->tagService->deleteTags($tagNames);
$io->success('Tags properly deleted');
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
}

View File

@@ -4,50 +4,47 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Ask;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
class GetTagVisitsCommand extends AbstractVisitsListCommand
#[AsCommand(GetTagVisitsCommand::NAME, 'Returns the list of visits for provided tag')]
class GetTagVisitsCommand extends Command
{
public const string NAME = 'tag:visits';
public function __construct(
VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
parent::__construct($visitsHelper);
public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Returns the list of visits for provided tag.')
->addArgument('tag', InputArgument::REQUIRED, 'The tag which visits we want to get.');
}
public function __invoke(
SymfonyStyle $io,
#[Argument('The tag which visits we want to get'), Ask('For what tag do you want to get visits')] string $tag,
#[MapInput] VisitsListInput $input,
#[Option(
'Return visits that belong to this domain only. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword for visits '
. 'in default domain',
shortcut: 'd',
)]
string|null $domain = null,
): int {
$paginator = $this->visitsHelper->visitsForTag($tag, new WithDomainVisitsParams(
dateRange: $input->dateRange(),
domain: $domain,
));
/**
* @return Paginator<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$tag = $input->getArgument('tag');
return $this->visitsHelper->visitsForTag($tag, new VisitsParams($dateRange));
}
VisitsCommandUtils::renderOutput($io, $input, $paginator);
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->shortUrl;
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
return self::SUCCESS;
}
}

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_map;
#[AsCommand(ListTagsCommand::NAME, 'Lists existing tags.')]
class ListTagsCommand extends Command
{
public const string NAME = 'tag:list';
@@ -24,17 +24,10 @@ class ListTagsCommand extends Command
parent::__construct();
}
protected function configure(): void
public function __invoke(SymfonyStyle $io): int
{
$this
->setName(self::NAME)
->setDescription('Lists existing tags.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
ShlinkTable::default($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
return ExitCode::EXIT_SUCCESS;
ShlinkTable::default($io)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
return self::SUCCESS;
}
private function getTagsRows(): array

View File

@@ -4,17 +4,16 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\Renaming;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(RenameTagCommand::NAME, 'Renames one existing tag.')]
class RenameTagCommand extends Command
{
public const string NAME = 'tag:rename';
@@ -24,28 +23,18 @@ class RenameTagCommand extends Command
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Renames one existing tag.')
->addArgument('oldName', InputArgument::REQUIRED, 'Current name of the tag.')
->addArgument('newName', InputArgument::REQUIRED, 'New name of the tag.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$oldName = $input->getArgument('oldName');
$newName = $input->getArgument('newName');
public function __invoke(
SymfonyStyle $io,
#[Argument('Current name of the tag.')] string $oldName,
#[Argument('New name of the tag.')] string $newName,
): int {
try {
$this->tagService->renameTag(Renaming::fromNames($oldName, $newName));
$io->success('Tag properly renamed.');
return ExitCode::EXIT_SUCCESS;
return Command::SUCCESS;
} catch (TagNotFoundException | TagConflictException $e) {
$io->error($e->getMessage());
return ExitCode::EXIT_FAILURE;
return Command::FAILURE;
}
}
}

View File

@@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Util;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Lock\LockFactory;
use function sprintf;
abstract class AbstractLockedCommand extends Command
{
public function __construct(private readonly LockFactory $locker)
{
parent::__construct();
}
final protected function execute(InputInterface $input, OutputInterface $output): int
{
$lockConfig = $this->getLockConfig();
$lock = $this->locker->createLock($lockConfig->lockName, $lockConfig->ttl, $lockConfig->isBlocking);
if (! $lock->acquire($lockConfig->isBlocking)) {
$output->writeln(
sprintf('<comment>Command "%s" is already in progress. Skipping.</comment>', $lockConfig->lockName),
);
return ExitCode::EXIT_WARNING;
}
try {
return $this->lockedExecute($input, $output);
} finally {
$lock->release();
}
}
abstract protected function lockedExecute(InputInterface $input, OutputInterface $output): int;
abstract protected function getLockConfig(): LockedCommandConfig;
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Util;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Lock\LockFactory;
use function sprintf;
class CommandUtils
{
/**
* Displays a warning and confirmation message before running a callback. If the response to the confirmation is
* positive, the callback is executed normally.
*
* @param callable(): int $callback
*/
public static function executeWithWarning(string $warning, SymfonyStyle $io, callable $callback): int
{
$io->warning($warning);
if (! $io->confirm('<comment>Do you want to proceed?</comment>', default: false)) {
$io->info('Operation aborted');
return Command::SUCCESS;
}
return $callback();
}
/**
* Runs a callback with a lock, making sure the lock is released after running the callback, and the callback does
* not run if the lock is already acquired.
*
* @param callable(): int $callback
*/
public static function executeWithLock(
LockFactory $locker,
LockConfig $lockConfig,
SymfonyStyle $io,
callable $callback,
): int {
$lock = $locker->createLock($lockConfig->lockName, $lockConfig->ttl, $lockConfig->isBlocking);
if (! $lock->acquire($lockConfig->isBlocking)) {
$io->writeln(
sprintf('<comment>Command "%s" is already in progress. Skipping.</comment>', $lockConfig->lockName),
);
return Command::INVALID;
}
try {
return $callback();
} finally {
$lock->release();
}
}
}

View File

@@ -4,24 +4,24 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Util;
final class LockedCommandConfig
final readonly class LockConfig
{
public const float DEFAULT_TTL = 600.0; // 10 minutes
private function __construct(
public readonly string $lockName,
public readonly bool $isBlocking,
public readonly float $ttl = self::DEFAULT_TTL,
public string $lockName,
public bool $isBlocking,
public float $ttl = self::DEFAULT_TTL,
) {
}
public static function blocking(string $lockName): self
{
return new self($lockName, true);
return new self($lockName, isBlocking: true);
}
public static function nonBlocking(string $lockName): self
{
return new self($lockName, false);
return new self($lockName, isBlocking: false);
}
}

View File

@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
abstract class AbstractDeleteVisitsCommand extends Command
{
final protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if (! $this->confirm($io)) {
$io->info('Operation aborted');
return ExitCode::EXIT_SUCCESS;
}
return $this->doExecute($input, $io);
}
private function confirm(SymfonyStyle $io): bool
{
$io->warning($this->getWarningMessage());
return $io->confirm('<comment>Continue deleting visits?</comment>', false);
}
abstract protected function doExecute(InputInterface $input, SymfonyStyle $io): int;
abstract protected function getWarningMessage(): string;
}

View File

@@ -1,89 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Input\EndDateOption;
use Shlinkio\Shlink\CLI\Input\StartDateOption;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function array_keys;
use function array_map;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\ArrayUtils\select_keys;
use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly;
abstract class AbstractVisitsListCommand extends Command
{
private readonly StartDateOption $startDateOption;
private readonly EndDateOption $endDateOption;
public function __construct(protected readonly VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct();
$this->startDateOption = new StartDateOption($this, 'visits');
$this->endDateOption = new EndDateOption($this, 'visits');
}
final protected function execute(InputInterface $input, OutputInterface $output): int
{
$startDate = $this->startDateOption->get($input, $output);
$endDate = $this->endDateOption->get($input, $output);
$paginator = $this->getVisitsPaginator($input, buildDateRange($startDate, $endDate));
[$rows, $headers] = $this->resolveRowsAndHeaders($paginator);
ShlinkTable::default($output)->render($headers, $rows);
return ExitCode::EXIT_SUCCESS;
}
/**
* @param Paginator<Visit> $paginator
*/
private function resolveRowsAndHeaders(Paginator $paginator): array
{
$extraKeys = [];
$rows = array_map(function (Visit $visit) use (&$extraKeys) {
$extraFields = $this->mapExtraFields($visit);
$extraKeys = array_keys($extraFields);
$rowData = [
'referer' => $visit->referer,
'date' => $visit->date->toAtomString(),
'userAgent' => $visit->userAgent,
'potentialBot' => $visit->potentialBot,
'country' => $visit->getVisitLocation()->countryName ?? 'Unknown',
'city' => $visit->getVisitLocation()->cityName ?? 'Unknown',
...$extraFields,
];
// Filter out unknown keys
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]);
}, [...$paginator->getCurrentPageResults()]);
$extra = array_map(camelCaseToHumanFriendly(...), $extraKeys);
return [
$rows,
['Referer', 'Date', 'User agent', 'Country', 'City', ...$extra],
];
}
/**
* @return Paginator<Visit>
*/
abstract protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator;
/**
* @return array<string, string>
*/
abstract protected function mapExtraFields(Visit $visit): array;
}

View File

@@ -4,14 +4,16 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Command\Util\CommandUtils;
use Shlinkio\Shlink\Core\Visit\VisitsDeleterInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
class DeleteOrphanVisitsCommand extends AbstractDeleteVisitsCommand
#[AsCommand(DeleteOrphanVisitsCommand::NAME, 'Deletes all orphan visits')]
class DeleteOrphanVisitsCommand extends Command
{
public const string NAME = 'visit:orphan-delete';
@@ -20,23 +22,20 @@ class DeleteOrphanVisitsCommand extends AbstractDeleteVisitsCommand
parent::__construct();
}
protected function configure(): void
public function __invoke(SymfonyStyle $io): int
{
$this
->setName(self::NAME)
->setDescription('Deletes all orphan visits');
return CommandUtils::executeWithWarning(
'You are about to delete all orphan visits. This operation cannot be undone',
$io,
fn () => $this->deleteVisits($io),
);
}
protected function doExecute(InputInterface $input, SymfonyStyle $io): int
private function deleteVisits(SymfonyStyle $io): int
{
$result = $this->deleter->deleteOrphanVisits();
$io->success(sprintf('Successfully deleted %s visits', $result->affectedItems));
return ExitCode::EXIT_SUCCESS;
}
protected function getWarningMessage(): string
{
return 'You are about to delete all orphan visits. This operation cannot be undone.';
return self::SUCCESS;
}
}

View File

@@ -4,19 +4,21 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\Core\Geolocation\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\Core\Geolocation\GeolocationDownloadProgressHandlerInterface;
use Shlinkio\Shlink\Core\Geolocation\GeolocationResult;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
#[AsCommand(
DownloadGeoLiteDbCommand::NAME,
'Checks if the GeoLite2 db file is too old or it does not exist, and tries to download an up-to-date copy if so',
)]
class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadProgressHandlerInterface
{
public const string NAME = 'visit:download-db';
@@ -29,36 +31,26 @@ class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadPro
parent::__construct();
}
protected function configure(): void
public function __invoke(SymfonyStyle $io): int
{
$this
->setName(self::NAME)
->setDescription(
'Checks if the GeoLite2 db file is too old or it does not exist, and tries to download an up-to-date '
. 'copy if so.',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->io = new SymfonyStyle($input, $output);
$this->io = $io;
try {
$result = $this->dbUpdater->checkDbUpdate($this);
if ($result === GeolocationResult::LICENSE_MISSING) {
$this->io->warning('It was not possible to download GeoLite2 db, because a license was not provided.');
return ExitCode::EXIT_WARNING;
return self::INVALID;
}
if ($result === GeolocationResult::MAX_ERRORS_REACHED) {
$this->io->warning('Max consecutive errors reached. Cannot retry for a couple of days.');
return ExitCode::EXIT_WARNING;
return self::INVALID;
}
if ($result === GeolocationResult::UPDATE_IN_PROGRESS) {
$this->io->warning('A geolocation db is already being downloaded by another process.');
return ExitCode::EXIT_WARNING;
return self::INVALID;
}
if ($this->progressBar === null) {
@@ -68,7 +60,7 @@ class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadPro
$this->io->success('GeoLite2 db file properly downloaded.');
}
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
} catch (GeolocationDbUpdateFailedException $e) {
return $this->processGeoLiteUpdateError($e, $this->io);
}
@@ -90,7 +82,7 @@ class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadPro
$this->getApplication()?->renderThrowable($e, $io);
}
return $olderDbExists ? ExitCode::EXIT_WARNING : ExitCode::EXIT_FAILURE;
return $olderDbExists ? self::INVALID : self::FAILURE;
}
public function beforeDownload(bool $olderDbExists): void

View File

@@ -4,46 +4,42 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
#[AsCommand(GetNonOrphanVisitsCommand::NAME, 'Returns the list of non-orphan visits')]
class GetNonOrphanVisitsCommand extends Command
{
public const string NAME = 'visit:non-orphan';
public function __construct(
VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
parent::__construct($visitsHelper);
public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Returns the list of non-orphan visits.');
}
public function __invoke(
SymfonyStyle $io,
#[MapInput] VisitsListInput $input,
#[Option(
'Return visits that belong to this domain only. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword for visits '
. 'in default domain',
shortcut: 'd',
)]
string|null $domain = null,
): int {
$paginator = $this->visitsHelper->nonOrphanVisits(new WithDomainVisitsParams(
dateRange: $input->dateRange(),
domain: $domain,
));
VisitsCommandUtils::renderOutput($io, $input, $paginator);
/**
* @return Paginator<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
return $this->visitsHelper->nonOrphanVisits(new VisitsParams($dateRange));
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->shortUrl;
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
return self::SUCCESS;
}
}

View File

@@ -4,47 +4,45 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use function Shlinkio\Shlink\Core\enumToString;
use function sprintf;
class GetOrphanVisitsCommand extends AbstractVisitsListCommand
#[AsCommand(GetOrphanVisitsCommand::NAME, 'Returns the list of orphan visits')]
class GetOrphanVisitsCommand extends Command
{
public const string NAME = 'visit:orphan';
protected function configure(): void
public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
{
$this
->setName(self::NAME)
->setDescription('Returns the list of orphan visits.')
->addOption('type', 't', InputOption::VALUE_REQUIRED, sprintf(
'Return visits only with this type. One of %s',
enumToString(OrphanVisitType::class),
));
parent::__construct();
}
/**
* @return Paginator<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$rawType = $input->getOption('type');
$type = $rawType !== null ? OrphanVisitType::from($rawType) : null;
return $this->visitsHelper->orphanVisits(new OrphanVisitsParams(dateRange: $dateRange, type: $type));
}
public function __invoke(
SymfonyStyle $io,
#[MapInput] VisitsListInput $input,
#[Option(
'Return visits that belong to this domain only. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword for visits '
. 'in default domain',
shortcut: 'd',
)]
string|null $domain = null,
#[Option('Return visits only with this type', shortcut: 't')] OrphanVisitType|null $type = null,
): int {
$paginator = $this->visitsHelper->orphanVisits(new OrphanVisitsParams(
dateRange: $input->dateRange(),
domain: $domain,
type: $type,
));
VisitsCommandUtils::renderOutput($io, $input, $paginator);
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
return ['type' => $visit->type->value];
return self::SUCCESS;
}
}

View File

@@ -4,9 +4,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Command\Util\CommandUtils;
use Shlinkio\Shlink\CLI\Command\Util\LockConfig;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
@@ -16,18 +15,22 @@ use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocatorInterface;
use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelperInterface;
use Shlinkio\Shlink\Core\Visit\Model\UnlocatableIpType;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Lock\LockFactory;
use Throwable;
use function sprintf;
class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocationHelperInterface
#[AsCommand(
name: LocateVisitsCommand::NAME,
description: 'Resolves visits origin locations. It implicitly downloads/updates the GeoLite2 db file if needed',
)]
class LocateVisitsCommand extends Command implements VisitGeolocationHelperInterface
{
public const string NAME = 'visit:locate';
@@ -36,46 +39,30 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
public function __construct(
private readonly VisitLocatorInterface $visitLocator,
private readonly VisitToLocationHelperInterface $visitToLocation,
LockFactory $locker,
private readonly LockFactory $locker,
) {
parent::__construct($locker);
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription(
'Resolves visits origin locations. It implicitly downloads/updates the GeoLite2 db file if needed.',
)
->addOption(
'retry',
'r',
InputOption::VALUE_NONE,
'Will retry the location of visits that were located with a not-found location, in case it was due to '
. 'a temporal issue.',
)
->addOption(
'all',
'a',
InputOption::VALUE_NONE,
'When provided together with --retry, will locate all existing visits, regardless the fact that they '
. 'have already been located.',
);
}
protected function initialize(InputInterface $input, OutputInterface $output): void
{
$this->io = new SymfonyStyle($input, $output);
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$retry = $input->getOption('retry');
$all = $input->getOption('all');
public function __invoke(
SymfonyStyle $io,
#[Option(
'Will retry the location of visits that were located with a not-found location, in case it was due to '
. 'a temporal issue.',
shortcut: 'r',
)]
bool $retry = false,
#[Option(
'When provided together with --retry, will locate all existing visits, regardless the fact that they '
. 'have already been located.',
shortcut: 'a',
)]
bool $all = false,
): int {
$this->io = $io;
if ($all && !$retry) {
$this->io->writeln(
$io->writeln(
'<comment>The <fg=yellow;options=bold>--all</> flag has no effect on its own. You have to provide it '
. 'together with <fg=yellow;options=bold>--retry</>.</comment>',
);
@@ -84,6 +71,13 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
if ($all && $retry && ! $this->warnAndVerifyContinue()) {
throw new RuntimeException('Execution aborted');
}
return CommandUtils::executeWithLock(
$this->locker,
LockConfig::nonBlocking(self::NAME),
$io,
fn () => $this->locateVisits($retry, $all),
);
}
private function warnAndVerifyContinue(): bool
@@ -98,11 +92,8 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
return $this->io->confirm('Do you want to proceed?', false);
}
protected function lockedExecute(InputInterface $input, OutputInterface $output): int
private function locateVisits(bool $retry, bool $all): int
{
$retry = $input->getOption('retry');
$all = $retry && $input->getOption('all');
try {
$this->checkDbUpdate();
@@ -116,14 +107,14 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
}
$this->io->success('Finished locating visits');
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
} catch (Throwable $e) {
$this->io->error($e->getMessage());
if ($this->io->isVerbose()) {
$this->getApplication()?->renderThrowable($e, $this->io);
}
return ExitCode::EXIT_FAILURE;
return self::FAILURE;
}
}
@@ -171,13 +162,8 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
$downloadDbCommand = $cliApp->find(DownloadGeoLiteDbCommand::NAME);
$exitCode = $downloadDbCommand->run(new ArrayInput([]), $this->io);
if ($exitCode === ExitCode::EXIT_FAILURE) {
if ($exitCode === self::FAILURE) {
throw new RuntimeException('It is not possible to locate visits without a GeoLite2 db file.');
}
}
protected function getLockConfig(): LockedCommandConfig
{
return LockedCommandConfig::nonBlocking(self::NAME);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use League\Csv\Writer;
use Shlinkio\Shlink\CLI\Input\VisitsListFormat;
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Symfony\Component\Console\Output\OutputInterface;
use function array_map;
class VisitsCommandUtils
{
/**
* @param Paginator<Visit> $paginator
*/
public static function renderOutput(
OutputInterface $output,
VisitsListInput $inputData,
Paginator $paginator,
callable|null $mapExtraFields = null,
): void {
if ($inputData->format !== VisitsListFormat::FULL) {
// Avoid running out of memory by loading visits in chunks
$paginator->setMaxPerPage(1000);
}
match ($inputData->format) {
VisitsListFormat::CSV => self::renderCSVOutput($output, $paginator),
default => self::renderHumanFriendlyOutput($output, $paginator),
};
}
/**
* @param Paginator<Visit> $paginator
*/
private static function renderCSVOutput(OutputInterface $output, Paginator $paginator): void
{
$page = 1;
do {
$paginator->setCurrentPage($page);
[$rows, $headers] = self::resolveRowsAndHeaders($paginator);
$csv = Writer::fromString();
if ($page === 1) {
$csv->insertOne($headers);
}
$csv->insertAll($rows);
$output->write($csv->toString());
$page++;
} while ($paginator->hasNextPage());
}
/**
* @param Paginator<Visit> $paginator
*/
private static function renderHumanFriendlyOutput(OutputInterface $output, Paginator $paginator): void
{
$page = 1;
do {
$paginator->setCurrentPage($page);
$page++;
[$rows, $headers] = self::resolveRowsAndHeaders($paginator);
ShlinkTable::default($output)->render(
$headers,
$rows,
footerTitle: PagerfantaUtils::formatCurrentPageMessage($paginator, 'Page %s of %s'),
);
} while ($paginator->hasNextPage());
}
/**
* @param Paginator<Visit> $paginator
*/
private static function resolveRowsAndHeaders(Paginator $paginator): array
{
$headers = [
'Date',
'Potential bot',
'User agent',
'Referer',
'Country',
'Region',
'City',
'Visited URL',
'Redirect URL',
'Type',
];
$rows = array_map(function (Visit $visit) {
$visitLocation = $visit->visitLocation;
return [
'date' => $visit->date->toAtomString(),
'potentialBot' => $visit->potentialBot ? 'Potential bot' : '',
'userAgent' => $visit->userAgent,
'referer' => $visit->referer,
'country' => $visitLocation->countryName ?? 'Unknown',
'region' => $visitLocation->regionName ?? 'Unknown',
'city' => $visitLocation->cityName ?? 'Unknown',
'visitedUrl' => $visit->visitedUrl ?? 'Unknown',
'redirectUrl' => $visit->redirectUrl ?? 'Unknown',
'type' => $visit->type->value,
];
}, [...$paginator->getCurrentPageResults()]);
return [$rows, $headers];
}
}

View File

@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Cake\Chronos\Chronos;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use function is_string;
use function sprintf;
readonly class DateOption
{
public function __construct(private Command $command, private string $name, string $shortcut, string $description)
{
$command->addOption($name, $shortcut, InputOption::VALUE_REQUIRED, $description);
}
public function get(InputInterface $input, OutputInterface $output): Chronos|null
{
$value = $input->getOption($this->name);
if (empty($value) || ! is_string($value)) {
return null;
}
try {
return Chronos::parse($value);
} catch (Throwable $e) {
$output->writeln(sprintf(
'<comment>> Ignored provided "%s" since its value "%s" is not a valid date. <</comment>',
$this->name,
$value,
));
if ($output->isVeryVerbose()) {
$this->command->getApplication()?->renderThrowable($e, $output);
}
return null;
}
}
}

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Cake\Chronos\Chronos;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function sprintf;
final readonly class EndDateOption
{
private DateOption $dateOption;
public function __construct(Command $command, string $descriptionHint)
{
$this->dateOption = new DateOption($command, 'end-date', 'e', sprintf(
'Allows to filter %s, returning only those newer than provided date.',
$descriptionHint,
));
}
public function get(InputInterface $input, OutputInterface $output): Chronos|null
{
return $this->dateOption->get($input, $output);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
use function sprintf;
final class InputUtils
{
/**
* Process a date provided via input params, and format it as ATOM.
* A warning is printed if the date cannot be parsed, returning `null` in that case.
*/
public static function processDate(string $name, string|null $value, OutputInterface $output): string|null
{
if ($value === null || $value === '') {
return null;
}
try {
return normalizeOptionalDate($value)->toAtomString();
} catch (Throwable) {
$output->writeln(sprintf(
'<comment>> Ignored provided "%s" since its value "%s" is not a valid date. <</comment>',
$name,
$value,
));
return null;
}
}
}

View File

@@ -1,136 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use function array_map;
use function array_unique;
use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
use function Shlinkio\Shlink\Core\splitByComma;
final readonly class ShortUrlDataInput
{
public function __construct(Command $command, private bool $longUrlAsOption = false)
{
if ($longUrlAsOption) {
$command->addOption('long-url', 'l', InputOption::VALUE_REQUIRED, 'The long URL to set');
} else {
$command->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to set');
}
$command
->addOption(
ShortUrlDataOption::TAGS->value,
ShortUrlDataOption::TAGS->shortcut(),
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
'Tags to apply to the short URL',
)
->addOption(
ShortUrlDataOption::VALID_SINCE->value,
ShortUrlDataOption::VALID_SINCE->shortcut(),
InputOption::VALUE_REQUIRED,
'The date from which this short URL will be valid. '
. 'If someone tries to access it before this date, it will not be found.',
)
->addOption(
ShortUrlDataOption::VALID_UNTIL->value,
ShortUrlDataOption::VALID_UNTIL->shortcut(),
InputOption::VALUE_REQUIRED,
'The date until which this short URL will be valid. '
. 'If someone tries to access it after this date, it will not be found.',
)
->addOption(
ShortUrlDataOption::MAX_VISITS->value,
ShortUrlDataOption::MAX_VISITS->shortcut(),
InputOption::VALUE_REQUIRED,
'This will limit the number of visits for this short URL.',
)
->addOption(
ShortUrlDataOption::TITLE->value,
ShortUrlDataOption::TITLE->shortcut(),
InputOption::VALUE_REQUIRED,
'A descriptive title for the short URL.',
)
->addOption(
ShortUrlDataOption::CRAWLABLE->value,
ShortUrlDataOption::CRAWLABLE->shortcut(),
InputOption::VALUE_NONE,
'Tells if this short URL will be included as "Allow" in Shlink\'s robots.txt.',
)
->addOption(
ShortUrlDataOption::NO_FORWARD_QUERY->value,
ShortUrlDataOption::NO_FORWARD_QUERY->shortcut(),
InputOption::VALUE_NONE,
'Disables the forwarding of the query string to the long URL, when the short URL is visited.',
);
}
public function toShortUrlEdition(InputInterface $input): ShortUrlEdition
{
return ShortUrlEdition::fromRawData($this->getCommonData($input));
}
public function toShortUrlCreation(
InputInterface $input,
UrlShortenerOptions $options,
string $customSlugField,
string $shortCodeLengthField,
string $pathPrefixField,
string $findIfExistsField,
string $domainField,
): ShortUrlCreation {
$shortCodeLength = $input->getOption($shortCodeLengthField) ?? $options->defaultShortCodesLength;
return ShortUrlCreation::fromRawData([
...$this->getCommonData($input),
ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption($customSlugField),
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
ShortUrlInputFilter::PATH_PREFIX => $input->getOption($pathPrefixField),
ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption($findIfExistsField),
ShortUrlInputFilter::DOMAIN => $input->getOption($domainField),
], $options);
}
private function getCommonData(InputInterface $input): array
{
$longUrl = $this->longUrlAsOption ? $input->getOption('long-url') : $input->getArgument('longUrl');
$data = [ShortUrlInputFilter::LONG_URL => $longUrl];
// Avoid setting arguments that were not explicitly provided.
// This is important when editing short URLs and should not make a difference when creating.
if (ShortUrlDataOption::VALID_SINCE->wasProvided($input)) {
$data[ShortUrlInputFilter::VALID_SINCE] = $input->getOption('valid-since');
}
if (ShortUrlDataOption::VALID_UNTIL->wasProvided($input)) {
$data[ShortUrlInputFilter::VALID_UNTIL] = $input->getOption('valid-until');
}
if (ShortUrlDataOption::MAX_VISITS->wasProvided($input)) {
$maxVisits = $input->getOption('max-visits');
$data[ShortUrlInputFilter::MAX_VISITS] = $maxVisits !== null ? (int) $maxVisits : null;
}
if (ShortUrlDataOption::TAGS->wasProvided($input)) {
$tags = array_unique(flatten(array_map(splitByComma(...), $input->getOption('tags'))));
$data[ShortUrlInputFilter::TAGS] = $tags;
}
if (ShortUrlDataOption::TITLE->wasProvided($input)) {
$data[ShortUrlInputFilter::TITLE] = $input->getOption('title');
}
if (ShortUrlDataOption::CRAWLABLE->wasProvided($input)) {
$data[ShortUrlInputFilter::CRAWLABLE] = $input->getOption('crawlable');
}
if (ShortUrlDataOption::NO_FORWARD_QUERY->wasProvided($input)) {
$data[ShortUrlInputFilter::FORWARD_QUERY] = !$input->getOption('no-forward-query');
}
return $data;
}
}

View File

@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Symfony\Component\Console\Input\InputInterface;
use function sprintf;
enum ShortUrlDataOption: string
{
case TAGS = 'tags';
case VALID_SINCE = 'valid-since';
case VALID_UNTIL = 'valid-until';
case MAX_VISITS = 'max-visits';
case TITLE = 'title';
case CRAWLABLE = 'crawlable';
case NO_FORWARD_QUERY = 'no-forward-query';
public function shortcut(): string|null
{
return match ($this) {
self::TAGS => 't',
self::VALID_SINCE => 's',
self::VALID_UNTIL => 'u',
self::MAX_VISITS => 'm',
self::TITLE => null,
self::CRAWLABLE => 'r',
self::NO_FORWARD_QUERY => 'w',
};
}
public function wasProvided(InputInterface $input): bool
{
$option = sprintf('--%s', $this->value);
$shortcut = $this->shortcut();
return $input->hasParameterOption($shortcut === null ? $option : [$option, sprintf('-%s', $shortcut)]);
}
}

View File

@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
final readonly class ShortUrlIdentifierInput
{
public function __construct(Command $command, string $shortCodeDesc, string $domainDesc)
{
$command
->addArgument('shortCode', InputArgument::REQUIRED, $shortCodeDesc)
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, $domainDesc);
}
public function shortCode(InputInterface $input): string|null
{
return $input->getArgument('shortCode');
}
public function toShortUrlIdentifier(InputInterface $input): ShortUrlIdentifier
{
$shortCode = $input->getArgument('shortCode');
$domain = $input->getOption('domain');
return ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
}
}

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