Compare commits

...

360 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
Alejandro Celaya
af2d67695b
Merge pull request #2370 from acelaya-forks/feature/missing-join-fix
Fix 500 error when listing non-orphan visits with short-url-depending API key
2025-02-19 19:37:36 +01:00
Alejandro Celaya
449a588796 Fix 500 error when listing non-orphan visits with short-url-depending API key 2025-02-19 19:33:44 +01:00
Alejandro Celaya
7bbc938743
Merge pull request #2369 from acelaya-forks/feature/redis-cluster-fix
Downgrade to symfony/lock 7.1.6
2025-02-19 17:55:53 +01:00
Alejandro Celaya
766758ff9b Downgrade to symfony/lock 7.1.6 2025-02-19 17:45:52 +01:00
Alejandro Celaya
63d943d59d
Merge pull request #2363 from acelaya-forks/feature/find-url-perf
Fix unique_short_code_plus_domain index in Microsoft SQL
2025-02-15 11:24:26 +01:00
Alejandro Celaya
053e1f3073 Update changelog 2025-02-15 11:19:30 +01:00
Alejandro Celaya
f3da345bf3 Fix unique_short_code_plus_domain index in Microsoft SQL 2025-02-15 11:17:14 +01:00
Alejandro Celaya
745255736a Simplify query to find short URL when domain is null 2025-02-14 10:20:50 +01:00
Alejandro Celaya
8fd53afe3f
Merge pull request #2361 from acelaya-forks/feature/lock-downgrade
Downgrade symfony/lock to v7.2.0 to work around redis issue
2025-02-14 08:52:33 +01:00
Alejandro Celaya
259635ea2a Downgrade symfony/lock to v7.2.0 to work around redis issue 2025-02-14 08:40:06 +01:00
Alejandro Celaya
a1f2e6dc5c
Merge pull request #2359 from acelaya-forks/feature/multi-proxy-fix
Workaround for IP resolution from x-Forwarded-For with multiple proxies
2025-02-13 22:03:36 +01:00
Alejandro Celaya
81e07bf08d
Merge pull request #2358 from acelaya-forks/feature/phpunit-12
Update to PHPUnit 12
2025-02-13 21:59:00 +01:00
Alejandro Celaya
c650a3e665 Workaround for IP resolution from x-Forwarded-For with multiple proxies 2025-02-13 21:52:38 +01:00
Alejandro Celaya
65c01034ff Update to PHPUnit 12 2025-02-13 10:35:58 +01:00
Alejandro Celaya
48f910aaaa
Merge pull request #2355 from acelaya-forks/feature/openapi-warnings
Remove suppressed warnings when running openapi tools
2025-02-05 08:43:28 +01:00
Alejandro Celaya
e511e15a87 Remove suppressed warnings when running openapi tools 2025-02-05 08:39:22 +01:00
Alejandro Celaya
ed09bf90eb Tag v4.4.2 in changelog 2025-01-29 12:05:53 +01:00
Alejandro Celaya
0ddfcb75dd
Merge pull request #2347 from acelaya-forks/feature/docker-arm
Get back docker image building for ARM architecture
2025-01-29 12:02:19 +01:00
Alejandro Celaya
193be55f0c Get back docker image building for ARM architecture 2025-01-29 11:59:42 +01:00
Alejandro Celaya
7ffb64eee1 Do not build docker image for ARM 2025-01-28 15:51:20 +01:00
Alejandro Celaya
0a2cc554c6 Build docker image with buildx 0.19.2 2025-01-28 15:38:47 +01:00
Alejandro Celaya
af783dea57 Add v4.4.1 to changelog 2025-01-28 10:12:15 +01:00
Alejandro Celaya
a68a17f6b4
Merge pull request #2343 from acelaya-forks/feature/defensive-title-encoding
Fix error when creating short URL for page with unsupported encoding
2025-01-28 10:11:04 +01:00
Alejandro Celaya
e9fe1ac5d4 Fix error when creating short URL for page with unsupported encoding 2025-01-28 10:04:30 +01:00
Alejandro Celaya
88e97f18ad
Merge pull request #2342 from acelaya-forks/feature/too-many-connections
Close connections after every async job that uses the db
2025-01-27 15:48:22 +01:00
Alejandro Celaya
3372a2a9c8 Close connections after every async job that uses the db 2025-01-27 15:45:37 +01:00
Alejandro Celaya
f02a8c876c
Merge pull request #2340 from acelaya-forks/feature/update-shlink-deps
Update shlink packages
2025-01-25 16:16:42 +01:00
Alejandro Celaya
1549509eb8 Update shlink packages 2025-01-25 16:13:40 +01:00
Alejandro Celaya
62fde5a8e2 Update changelog 2025-01-13 08:47:19 +01:00
Alejandro Celaya
221e061ea6
Merge pull request #2332 from MaZe3D/develop
Add ADDRESS environment vairable to define the listening interface.
2025-01-13 08:45:20 +01:00
Mark Orlando Zeller
9ad565f8c8 Add ADDRESS environment vairable to define the listening interface. 2025-01-10 22:10:51 +01:00
Alejandro Celaya
11fa28e489
Merge pull request #2316 from acelaya-forks/feature/v4.4
Add v4.4.0 to changelog and update dependencies
2024-12-27 16:27:06 +01:00
Alejandro Celaya
d7e51b388e Add v4.4.0 to changelog and update dependencies 2024-12-27 16:24:25 +01:00
Alejandro Celaya
5ef2df3d53
Merge pull request #2315 from acelaya-forks/feature/import-redirect-rules
Implement logic to import redirect rules from other Shlink instances
2024-12-22 18:50:10 +01:00
Alejandro Celaya
9c251b3646 Update changelog 2024-12-22 18:41:58 +01:00
Alejandro Celaya
2807b9ce2f Fix ImportedLinksProcessorTest 2024-12-22 18:41:03 +01:00
Alejandro Celaya
2f39aff2fe Implement logic to import redirect rules from other Shlink instances 2024-12-22 12:42:06 +01:00
Alejandro Celaya
b8d7917691
Merge pull request #2314 from acelaya-forks/feature/database-ssl
Support encrypted connections to MySQL/Maria and Postgres
2024-12-20 09:54:59 +01:00
Alejandro Celaya
d228c16f82 Fix test for ip middleware 2024-12-20 09:52:30 +01:00
Alejandro Celaya
c34bfac6b1 Update installer with support for DB_USE_ENCRYPTION option 2024-12-20 09:29:28 +01:00
Alejandro Celaya
4e7d09035a Support encrypted connections to MySQL/Maria and Postgres 2024-12-19 09:00:01 +01:00
Alejandro Celaya
83570f5c25
Merge pull request #2313 from acelaya-forks/feature/qr-disable-logo
Allow QR code logo to be individually disabled
2024-12-18 09:14:47 +01:00
Alejandro Celaya
6ad8b03850 Allow QR code logo to be individually disabled 2024-12-18 09:10:53 +01:00
Alejandro Celaya
736e09adfe
Merge pull request #2310 from acelaya-forks/feature/less-restrictive-custom-slugs
Be less restrictive on what characters are disallowed in custom slugs
2024-12-17 18:08:51 +01:00
Alejandro Celaya
e80af78e09 Be less restrictive on what characters are disallowed in custom slugs 2024-12-17 18:04:38 +01:00
Alejandro Celaya
d533adf7ce
Merge pull request #2308 from acelaya-forks/feature/geolocation-updates
Improve how geolocation DB files are downloaded/updated
2024-12-16 20:21:35 +01:00
Alejandro Celaya
509ef668e6 Fix GeolocationDbUpdater test 2024-12-16 19:50:06 +01:00
Alejandro Celaya
e715a0fb6f Track reason for which a geolocation db download was attempted 2024-12-16 09:23:30 +01:00
Alejandro Celaya
72a962ec6d Handle differently when trying to update geolocation and already in progress 2024-12-15 12:03:01 +01:00
Alejandro Celaya
853c50a819 Fix some cases of database download in GeolocationDbUpdater 2024-12-15 11:34:38 +01:00
Alejandro Celaya
f10a9d3972 Simplify geolocation_db_updates indexes 2024-12-15 10:08:22 +01:00
Alejandro Celaya
a77e07f906 Refactor geolocation download logic based on database table 2024-12-15 10:05:32 +01:00
Alejandro Celaya
d4d97c3182 Create new table to track geolocation updates 2024-12-13 10:33:53 +01:00
Alejandro Celaya
55724dbff6
Merge pull request #2306 from acelaya-forks/feature/update-docker-images
Update docker images to Alpine 3.21
2024-12-12 09:06:49 +01:00
Alejandro Celaya
9e34183901 Update docker images to Alpine 3.21 2024-12-12 08:52:01 +01:00
Alejandro Celaya
88c283952c
Merge pull request #2304 from acelaya-forks/feature/geolocation-services-refactor
Move GeolocationDbUpdater to Core module
2024-12-11 08:58:23 +01:00
Alejandro Celaya
2ede615da8 Fix DownloadGeoLiteDbCommandTest 2024-12-11 08:50:56 +01:00
Alejandro Celaya
84d12f6811 Move GeolocationDbUpdaterTest to Core module 2024-12-11 08:47:13 +01:00
Alejandro Celaya
4f3c2c7d2d Fix UpdateGeoLiteDbTest 2024-12-11 08:35:24 +01:00
Alejandro Celaya
b8ac9f3673 Add more strict parameter for GeolocationDbUpdater 2024-12-11 08:27:56 +01:00
Alejandro Celaya
06c0a94b31 Move GeolocationDbUpdater from CLI to Core module 2024-12-10 10:58:08 +01:00
Alejandro Celaya
5d12b1d952
Merge pull request #2302 from acelaya-forks/feature/openapi-names
Use the openapi terminology over swagger
2024-12-06 11:40:15 +01:00
Alejandro Celaya
85c4c09afa Use the openapi terminology over swagger 2024-12-06 11:36:47 +01:00
Alejandro Celaya
e7c83d0b38
Merge pull request #2300 from acelaya-forks/feature/drop-8.2-support
Drop support for PHP 8.2
2024-12-02 09:21:50 +01:00
Alejandro Celaya
58de998596 Drop support for PHP 8.2 2024-12-02 09:16:15 +01:00
Alejandro Celaya
bfaab6c494
Merge pull request #2298 from acelaya-forks/feature/ignore-extra-path
Allow the extra path to be ignored when redirecting
2024-12-01 12:37:56 +01:00
Alejandro Celaya
d83081f4e9 Update shlink-installer 2024-12-01 12:28:29 +01:00
Alejandro Celaya
c65349d265 Allow the extra path to be ignored when redirecting 2024-12-01 09:56:09 +01:00
Alejandro Celaya
e74ee793a0
Merge pull request #2297 from acelaya-forks/feature/docker-php-8.4
Update docker images to PHP 8.4
2024-11-30 18:35:03 +01:00
Alejandro Celaya
ede58efe96 Update docker images to PHP 8.4 2024-11-30 13:53:19 +01:00
Alejandro Celaya
3f30af4794
Merge pull request #2294 from acelaya-forks/feature/user-agent
Migrate from mobiledetectlib to phpuseragentparser
2024-11-28 12:11:29 +01:00
Alejandro Celaya
6331fa3ed3 Migrate from mobiledetectlib to phpuseragentparser 2024-11-28 12:05:10 +01:00
Alejandro Celaya
d121d4d496
Merge pull request #2289 from acelaya-forks/feature/delete-old-migrations
Delete some old migrations
2024-11-28 09:00:40 +01:00
Alejandro Celaya
8499087a3b Move DEFAULT_DOMAIN constant to domains module 2024-11-28 08:54:29 +01:00
Alejandro Celaya
bb72c96ebb Delete some old migrations 2024-11-26 10:17:28 +01:00
Alejandro Celaya
557c74286b Add v4.3.1 to changelog 2024-11-25 23:45:02 +01:00
Alejandro Celaya
67abe21716
Merge pull request #2287 from acelaya-forks/feature/ms-index-fix
Fix columns order in unique_short_code_plus_domain index in MSSQL
2024-11-25 23:43:55 +01:00
Alejandro Celaya
33cea36b15 Fix columns order in unique_short_code_plus_domain index in MSSQL 2024-11-25 22:48:04 +01:00
Alejandro Celaya
4e8f3f737a
Merge pull request #2286 from acelaya-forks/feature/crawler-detect
Use jaybizzle/crawler-detect instead of acelaya/crawler-detect
2024-11-25 22:21:14 +01:00
Alejandro Celaya
35b835ec7b Use jaybizzle/crawler-detect instead of acelaya/crawler-detect 2024-11-25 22:17:15 +01:00
Alejandro Celaya
eff4f1fca3
Merge pull request #2284 from acelaya-forks/feature/rka-ip-address
Go back to using akrabat/ip-address-middleware instead of acelaya/ip-address-middleware
2024-11-25 09:31:43 +01:00
Alejandro Celaya
6f6388b2fc Go back to using akrabat/ip-address-middleware instead of acelaya/ip-address-middleware 2024-11-25 09:23:43 +01:00
Alejandro Celaya
19f56e7ab0 Add v4.3.0 to changelog 2024-11-24 14:26:09 +01:00
Alejandro Celaya
6a96b72b94 Add real version constraints for Shlink packages 2024-11-24 14:23:12 +01:00
Alejandro Celaya
7634f55587
Merge pull request #2282 from acelaya-forks/feature/track-redirect-url
Add redirect_url field to track where a visitor is redirected for a visit
2024-11-24 14:20:12 +01:00
Alejandro Celaya
571a4643ab Update changelog 2024-11-24 14:13:59 +01:00
Alejandro Celaya
d5544554ef Improve API docs description for redirectUrl fields 2024-11-24 14:08:23 +01:00
Alejandro Celaya
85065c9330 Test behavior to track redirect URL 2024-11-24 14:05:33 +01:00
Alejandro Celaya
86cc2b717c Save where a visitor is redirected for any kind of tracked visit 2024-11-24 13:21:48 +01:00
Alejandro Celaya
89f70114e4 Fix typo in migration 2024-11-24 13:18:32 +01:00
Alejandro Celaya
8274525f75 Add redirect_url field to track where a visitor is redirected for a visit 2024-11-24 12:53:49 +01:00
Alejandro Celaya
fef512a7a3
Merge pull request #2280 from acelaya-forks/feature/php-8.4-support
Feature/php 8.4 support
2024-11-24 11:41:59 +01:00
Alejandro Celaya
deb9d4bdc7 Update docker images to Alpine 3.20 2024-11-24 11:37:08 +01:00
Alejandro Celaya
259aadfdb2 Update changelog 2024-11-24 11:05:36 +01:00
Alejandro Celaya
fe660654ed Add PHP 8.4 to the release pipeline 2024-11-24 11:04:41 +01:00
Alejandro Celaya
b2fc19af44 Replace akrabat/ip-address-middleware with acelaya/ip-address-middleware 2024-11-24 11:04:14 +01:00
Alejandro Celaya
7434616a8d Update mobiledetect/mobiledetectlib to a commit including PHP 8.4 fixes 2024-11-24 10:55:55 +01:00
Alejandro Celaya
fbf1aabcf5 Replace jaybizzle/crawler-detect with acelaya/crawler-detect 2024-11-24 10:49:44 +01:00
Alejandro Celaya
8ee905882f
Merge pull request #2277 from acelaya-forks/feature/ip-address-factory
Use `IpAddressFactory` from akrabat/ip-address-middleware
2024-11-22 09:13:01 +01:00
Alejandro Celaya
2946b630c5 Use IpAddressFactory from akrabat/ip-address-middleware 2024-11-22 09:01:27 +01:00
Alejandro Celaya
b2bfe9799a
Merge pull request #2276 from acelaya-forks/feature/visits-list-duplication
Reduce duplication in actions listing visits
2024-11-20 09:51:54 +01:00
Alejandro Celaya
d7e300e2d5 Reduce duplication in actions listing visits 2024-11-20 09:48:12 +01:00
Alejandro Celaya
0c75202936
Merge pull request #2273 from acelaya-forks/feature/remove-laminas-config
Remove dependency on laminas config
2024-11-19 20:15:28 +01:00
Alejandro Celaya
81bed53f90 Update Shlink libraries to remove dependency on laminas-config 2024-11-19 20:12:38 +01:00
Alejandro Celaya
a56ff1293e Remove direct dependency on laminas/laminas-config 2024-11-19 09:18:06 +01:00
Alejandro Celaya
c323bfcd63
Merge pull request #2272 from acelaya-forks/feature/geolocate-localhost-fix
Make sure IpGeolocationMiddleware skips localhost
2024-11-19 09:14:45 +01:00
Alejandro Celaya
f57f159002 Remove no longer used Visit::isLocatable method 2024-11-19 09:10:47 +01:00
Alejandro Celaya
fa08014226 Make sure IpGeolocationMiddleware skips localhost 2024-11-19 09:08:04 +01:00
Alejandro Celaya
052c9e76a1
Merge pull request #2271 from acelaya-forks/feature/api-key-domain-exceptions
Use more meaningful domain exceptions to represent ApiKeyService thrown errors
2024-11-18 09:59:25 +01:00
Alejandro Celaya
8298ef36f8 Use more meaningful domain exceptions to represent ApiKeyService thrown errors 2024-11-18 09:51:27 +01:00
Alejandro Celaya
b11d5c6864 Do not ignore platform reqs when using PHP 8.4 2024-11-18 08:50:20 +01:00
Alejandro Celaya
08394431f8
Merge pull request #2269 from acelaya-forks/feature/no-php-8.4-error
Do not allow pipelines to continue on error
2024-11-17 10:25:33 +01:00
Alejandro Celaya
a9ae4a24d0 Do not allow pipelines to continue on error 2024-11-17 10:15:25 +01:00
Alejandro Celaya
9b7b91402c
Merge pull request #2268 from acelaya-forks/feature/delete-visits-fix
Fix visits counts not being deleted when deleting short URL or orphan visits
2024-11-15 19:26:57 +01:00
Alejandro Celaya
178a99b993 Fix visits counts not being deleted when deleting short URL or orphan visits 2024-11-15 19:22:29 +01:00
Alejandro Celaya
a8f046dfff
Merge pull request #2266 from acelaya-forks/feature/geolocation-middleware
Feature/geolocation middleware
2024-11-15 10:47:18 +01:00
Alejandro Celaya
42ff0d5b69 Create IpGeolocationMiddlewareTest 2024-11-15 10:17:56 +01:00
Alejandro Celaya
6aaea2ac26 Simplify logic in RedirectRule when checking geolocation conditions 2024-11-15 09:00:59 +01:00
Alejandro Celaya
b5ff568651 Use IpGeolocationMiddleware to geolocate visitors instead of LocateVisit event 2024-11-15 08:55:43 +01:00
Alejandro Celaya
4a0b7e3fc9 Refactor Visitor model and allow a Location object to be passed to it 2024-11-14 14:48:18 +01:00
Alejandro Celaya
1fee745786
Merge pull request #2263 from acelaya-forks/feature/geolocation-city-name-redirects
Add support for city name dynamic redirects
2024-11-14 10:07:01 +01:00
Alejandro Celaya
a6e0916272 Add support for city name dynamic redirects 2024-11-14 09:58:53 +01:00
Alejandro Celaya
dbef32ffcb
Merge pull request #2257 from acelaya-forks/feature/geolocation-country-code-redirects
Add new geolocatio-country-code redirect condition type
2024-11-14 09:43:10 +01:00
Alejandro Celaya
7ddb3e7a70 Add tests covering country code validation 2024-11-14 09:40:10 +01:00
Alejandro Celaya
fd34332e69 Improve ExtraPathRedirectMiddlewareTest 2024-11-14 09:28:10 +01:00
Alejandro Celaya
51d838870d Add reference to ISO 3166-1 alpha-2 country codes wikipedia page 2024-11-14 09:14:17 +01:00
Alejandro Celaya
4619ebd014 After tracking a visit, set its location in the request as attribute 2024-11-14 08:21:16 +01:00
Alejandro Celaya
f2371b6124 Update RedirectRuleHandlerTest 2024-11-13 10:01:52 +01:00
Alejandro Celaya
b5b5f92eda Add validation for country-code redirect conditions 2024-11-12 10:25:39 +01:00
Alejandro Celaya
781c083c9f Add new geolocatio-country-code redirect condition type 2024-11-12 10:25:39 +01:00
Alejandro Celaya
a444ed0246
Merge pull request #2258 from acelaya-forks/feature/phpstan-2
Update to PHPStan 2.0
2024-11-12 10:25:02 +01:00
Alejandro Celaya
9a69d06531 Update to PHPStan 2.0 2024-11-12 10:22:23 +01:00
Alejandro Celaya
15cb3bb73c
Merge pull request #2256 from acelaya-forks/feature/unecessary-flush
Remove unnecessary flush calls when used in wrapInTransaction
2024-11-11 09:35:30 +01:00
Alejandro Celaya
7ca605e216 Remove unnecessary flush calls when used in wrapInTransaction 2024-11-11 09:31:23 +01:00
Alejandro Celaya
59a4704658
Merge pull request #2255 from acelaya-forks/feature/expose-tracked-visits
Return `Visit` object created when tracking a visit successfully
2024-11-11 09:19:20 +01:00
Alejandro Celaya
48ecef3436 Update RequestTracker so that its methods return the new Visit instance, if any 2024-11-11 08:58:16 +01:00
Alejandro Celaya
a5a98bd578 Update VisitsTracker so that its methods return the new Visit instance, if any 2024-11-11 08:51:55 +01:00
Alejandro Celaya
12a08cb373
Merge pull request #2253 from acelaya-forks/feature/api-key-improvements
Feature/api key improvements
2024-11-09 12:23:10 +01:00
Alejandro Celaya
3c6f12aec6 Ensure auto-generated name API keys do not throw duplicated name 2024-11-09 12:07:07 +01:00
Alejandro Celaya
d228b88e51 Lock transaction to avoid race conditions when renaming an API key 2024-11-09 11:16:36 +01:00
Alejandro Celaya
95685d958d Update to latest test utils 2024-11-09 11:02:10 +01:00
Alejandro Celaya
1a278eaf07
Merge pull request #2252 from acelaya-forks/feature/readonly-classes
Make classes readonly when possible
2024-11-09 09:58:56 +01:00
Alejandro Celaya
72f1e243b5 Make classes readonly when possible 2024-11-09 09:55:51 +01:00
Alejandro Celaya
d6b103de83
Merge pull request #2251 from acelaya-forks/feature/inject-repos
Feature/inject repos
2024-11-09 09:54:06 +01:00
Alejandro Celaya
fca3891819 Inject ShortUrlRepository in ShortCodeUniquenessHelper 2024-11-09 09:47:47 +01:00
Alejandro Celaya
3ec24e3c67 Inject ShortUrlRepository in UrlShortener 2024-11-09 09:43:55 +01:00
Alejandro Celaya
532102e662 Inject ShortUrlRepository in ShortUrlResolver 2024-11-09 09:39:56 +01:00
Alejandro Celaya
fcd82522ab
Merge pull request #2250 from acelaya-forks/feature/inject-tag-repo
Inject TagRepository in TagService, instead of getting it from EntityManager
2024-11-09 09:39:03 +01:00
Alejandro Celaya
102169b6c7 Inject DomainRepository in DomainService 2024-11-09 09:34:24 +01:00
Alejandro Celaya
dba9302f78 Inject TagRepository in TagService, instead of getting it from EntityManager 2024-11-09 09:25:01 +01:00
Alejandro Celaya
92ad6d2732
Merge pull request #2249 from acelaya-forks/feature/hash-api-keys
Feature/hash api keys
2024-11-09 09:14:38 +01:00
Alejandro Celaya
7e573bdb9b Add tests for RenameApiKeyCOmmand and ApiKeyMeta 2024-11-08 09:58:02 +01:00
Alejandro Celaya
6f837b3b91 Move logic to determine if a new key has a duplicated name to the APiKeyService 2024-11-08 09:03:50 +01:00
Alejandro Celaya
b08c498b13 Create command to rename API keys 2024-11-08 08:47:49 +01:00
Alejandro Celaya
a661d05100 Allow API keys to be renamed 2024-11-08 08:25:07 +01:00
Alejandro Celaya
9e6f129de6 Make sure a unique name is required by api-key:generate command 2024-11-07 14:52:06 +01:00
Alejandro Celaya
4c1ff72438 Add method to check if an API exists for a given name 2024-11-07 09:55:10 +01:00
Alejandro Celaya
6f95acc202 Inject ApiKeyRepository in ApiKeyService 2024-11-07 09:34:42 +01:00
Alejandro Celaya
bd73362c94 Update api-key:disable command to allow passing a name 2024-11-06 20:10:06 +01:00
Alejandro Celaya
f6d70c599e Make name required in ApiKey entity 2024-11-06 08:57:10 +01:00
Alejandro Celaya
1b9c8377ae Hash existing API keys, and do checks against the hash 2024-11-05 23:27:39 +01:00
Alejandro Celaya
9f6975119e Show only API key name in short URLs list 2024-11-05 22:52:01 +01:00
Alejandro Celaya
a094be2b9e Fall back API key names to auto-generated keys 2024-11-05 11:26:39 +01:00
Alejandro Celaya
819a535bfe Create migration to set API keys in name column 2024-11-05 11:08:11 +01:00
Alejandro Celaya
e4fe7adf00
Merge pull request #2248 from acelaya-forks/feature/api-key-simplification
Simplify ApiKey entity by exposing key as a readonly prop
2024-11-04 23:17:17 +01:00
Alejandro Celaya
79c5418ac2 Simplify ApiKey entity by exposing key as a readonly prop 2024-11-04 14:22:39 +01:00
Alejandro Celaya
b5010e4d8c
Merge pull request #2246 from acelaya-forks/feature/nanoid-2
Update to hidehalo/nanoid-php 2.0
2024-11-04 08:55:17 +01:00
Alejandro Celaya
3085fa76cf Update to hidehalo/nanoid-php 2.0 2024-11-04 08:50:58 +01:00
Alejandro Celaya
1fd7d58084 Update Bluesky handle 2024-11-03 11:38:31 +01:00
Alejandro Celaya
eae001a34a Rename ShortUrlWithVisitsSummary to ShortUrlWithDeps 2024-11-03 11:38:31 +01:00
Alejandro Celaya
d7ecef94f2 Avoid selecting domains for every short URL in list 2024-11-03 11:38:31 +01:00
Alejandro Celaya
98364a1aae Update to mlocati/ip-lib 1.18.1 2024-11-03 11:38:31 +01:00
Alejandro Celaya
9ccb866e5e Display warnings and deprecations in all test suites 2024-11-03 11:38:31 +01:00
Alejandro Celaya
3f1d61e01e Update to PHP coding standard 2.4.0 2024-11-03 11:38:31 +01:00
Alejandro Celaya
93a277a94d Allow short URLs to be filtered by domain from the command line 2024-11-03 11:38:30 +01:00
Alejandro Celaya
a10ca655a2 Cover domain filtering in ListShortUrls API test 2024-11-03 11:37:59 +01:00
Alejandro Celaya
bb270396b6 Allow short URLs list to be filtered by domain authority 2024-11-03 11:37:59 +01:00
Alejandro Celaya
525a306ec6 Create constant representing default domain identifier 2024-11-03 11:37:59 +01:00
Alejandro Celaya
1dd71d2ee7 Update changelog 2024-11-03 11:37:59 +01:00
Alejandro Celaya
ac2e249746 Update swagger Short URL examples to include forwardQuery and hasRedirectRules 2024-11-03 11:37:16 +01:00
Alejandro Celaya
af569ad7a5 Fix PHPStan rules 2024-11-03 11:37:16 +01:00
Alejandro Celaya
bf121c58ba Fix API tests 2024-11-03 11:37:16 +01:00
Alejandro Celaya
d2403367b5 Fix PublishingUpdatesGeneratorTest 2024-11-03 11:37:16 +01:00
Alejandro Celaya
84a187a26f Include left join with domains when listing short URLs to avoid N+1 SELECT problem 2024-11-03 11:37:15 +01:00
Alejandro Celaya
3149adebdb Expose the fact that a short URL has redirect rules attached to it 2024-11-03 11:36:50 +01:00
Alejandro Celaya
228bf093d3
Merge pull request #2245 from acelaya-forks/feature/fix-redis-7.4-scan
Update to shlink-common 6.5 to fix integration with redis 7.4
2024-11-03 11:35:50 +01:00
Alejandro Celaya
26589e6126 Update to shlink-common 6.5 to fix integration with redis 7.4 2024-11-03 11:32:44 +01:00
520 changed files with 7567 additions and 4855 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

@ -43,5 +43,5 @@ runs:
coverage: xdebug
- name: Install dependencies
if: ${{ inputs.install-deps == 'yes' }}
run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.4' && '--ignore-platform-req=php' || '' }}
run: composer install --no-interaction --prefer-dist
shell: bash

View File

@ -13,12 +13,11 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.2', '8.3', '8.4']
continue-on-error: ${{ matrix.php-version == '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
@ -36,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

@ -8,3 +8,5 @@ on:
jobs:
build-docker-image:
uses: shlinkio/github-actions/.github/workflows/docker-image-build-ci.yml@main
with:
platforms: 'linux/arm64/v8,linux/amd64'

View File

@ -13,12 +13,11 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.2', '8.3', '8.4']
continue-on-error: ${{ matrix.php-version == '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
@ -33,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']
command: ['cs', 'stan', 'swagger:validate']
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

@ -1,4 +1,4 @@
name: Publish swagger spec
name: Publish openapi spec
on:
push:
@ -10,9 +10,9 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.2']
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
@ -20,10 +20,10 @@ jobs:
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
- run: composer swagger:inline
extensions-cache-key: publish-openapi-spec-extensions-${{ matrix.php-version }}
- run: composer openapi:inline
- run: mkdir ${{ steps.determine_version.outputs.version }}
- run: mv docs/swagger/swagger-inlined.json ${{ steps.determine_version.outputs.version }}/open-api-spec.json
- run: mv docs/swagger/openapi-inlined.json ${{ steps.determine_version.outputs.version }}/open-api-spec.json
- name: Publish spec
uses: JamesIves/github-pages-deploy-action@v4
with:

View File

@ -10,16 +10,16 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
php-version: ['8.2', '8.3'] # TODO 8.4
php-version: ['8.4', '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-*

3
.gitignore vendored
View File

@ -10,7 +10,6 @@ data/database.sqlite
data/shlink-tests.db
data/GeoLite2-City.*
data/infra/matomo
docs/swagger-ui*
docs/mercure.html
.phpunit.result.cache
docs/swagger/swagger-inlined.json
docs/swagger/openapi-inlined.json

View File

@ -4,6 +4,396 @@ 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).
## [Unreleased]
### Added
* *Nothing*
### 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.
* [#2540](https://github.com/shlinkio/shlink/issues/2540) Update Symfony packages to 8.0.
### 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` deprecated 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.
### Fixed
* *Nothing*
## [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*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2366](https://github.com/shlinkio/shlink/issues/2366) Fix error "Cannot use 'SCRIPT' with redis-cluster" thrown when creating a lock while using a redis cluster.
* [#2368](https://github.com/shlinkio/shlink/issues/2368) Fix error when listing non-orphan visits using API key with `AUTHORED_SHORT_URLS` role.
## [4.4.3] - 2025-02-15
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2351](https://github.com/shlinkio/shlink/issues/2351) Fix visitor IP address resolution when Shlink is served behind more than one reverse proxy.
This regression was introduced due to a change in behavior in `akrabat/rka-ip-address-middleware`, that now picks the first address from the right after excluding all trusted proxies.
Since Shlink does not set trusted proxies, this means the first IP from the right is now picked instead of the first from the left, so we now reverse the list before trying to resolve the IP.
In the future, Shlink will allow you to define trusted proxies, to avoid other potential side effects because of this reversing of the list.
* [#2354](https://github.com/shlinkio/shlink/issues/2354) Fix error "NOSCRIPT No matching script. Please use EVAL" thrown when creating a lock in redis.
* [#2319](https://github.com/shlinkio/shlink/issues/2319) Fix unique index for `short_code` and `domain_id` in `short_urls` table not being used in Microsoft SQL engines for rows where `domain_id` is `null`.
## [4.4.2] - 2025-01-29
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2346](https://github.com/shlinkio/shlink/issues/2346) Get back docker images for ARM architectures.
## [4.4.1] - 2025-01-28
### Added
* [#2331](https://github.com/shlinkio/shlink/issues/2331) Add `ADDRESS` env var which allows to customize the IP address to which RoadRunner binds, when using the official docker image.
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2341](https://github.com/shlinkio/shlink/issues/2341) Ensure all asynchronous jobs that interact with the database do not leave idle connections open.
* [#2334](https://github.com/shlinkio/shlink/issues/2334) Improve how page titles are encoded to UTF-8, falling back from mbstring to iconv if available, and ultimately using the original title in case of error, but never causing the short URL creation to fail.
## [4.4.0] - 2024-12-27
### Added
* [#2265](https://github.com/shlinkio/shlink/issues/2265) Add a new `REDIRECT_EXTRA_PATH_MODE` option that accepts three values:
* `default`: Short URLs only match if the path matches their short code or custom slug.
* `append`: Short URLs are matched as soon as the path starts with the short code or custom slug, and the extra path is appended to the long URL before redirecting.
* `ignore`: Short URLs are matched as soon as the path starts with the short code or custom slug, and the extra path is ignored.
This option effectively replaces the old `REDIRECT_APPEND_EXTRA_PATH` option, which is now deprecated and will be removed in Shlink 5.0.0
* [#2156](https://github.com/shlinkio/shlink/issues/2156) Be less restrictive on what characters are disallowed in custom slugs.
All [URI-reserved characters](https://datatracker.ietf.org/doc/html/rfc3986#section-2.2) were disallowed up until now, but from now on, only the gen-delimiters are.
* [#2229](https://github.com/shlinkio/shlink/issues/2229) Add `logo=disabled` query param to dynamically disable the default logo on QR codes.
* [#2206](https://github.com/shlinkio/shlink/issues/2206) Add new `DB_USE_ENCRYPTION` config option to enable SSL database connections trusting any server certificate.
* [#2209](https://github.com/shlinkio/shlink/issues/2209) Redirect rules are now imported when importing short URLs from a Shlink >=4.0 instance.
### Changed
* [#2281](https://github.com/shlinkio/shlink/issues/2281) Update docker image to PHP 8.4
* [#2124](https://github.com/shlinkio/shlink/issues/2124) Improve how Shlink decides if a GeoLite db file needs to be downloaded, and reduces the chances for API limits to be reached.
Now Shlink tracks all download attempts, and knows which of them failed and succeeded. This lets it know when was the last error or success, how many consecutive errors have happened, etc.
It also tracks now the reason for a download to be attempted, and the error that happened when one fails.
### Deprecated
* *Nothing*
### Removed
* [#2247](https://github.com/shlinkio/shlink/issues/2247) Drop support for PHP 8.2
### Fixed
* *Nothing*
## [4.3.1] - 2024-11-25
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2285](https://github.com/shlinkio/shlink/issues/2285) Fix performance degradation when using Microsoft SQL due to incorrect order of columns in `unique_short_code_plus_domain` index.
## [4.3.0] - 2024-11-24
### Added
* [#2159](https://github.com/shlinkio/shlink/issues/2159) Add support for PHP 8.4.
* [#2207](https://github.com/shlinkio/shlink/issues/2207) Add `hasRedirectRules` flag to short URL API model. This flag tells if a specific short URL has any redirect rules attached to it.
* [#1520](https://github.com/shlinkio/shlink/issues/1520) Allow short URLs list to be filtered by `domain`.
This change applies both to the `GET /short-urls` endpoint, via the `domain` query parameter, and the `short-url:list` console command, via the `--domain`|`-d` flag.
* [#1774](https://github.com/shlinkio/shlink/issues/1774) Add new geolocation redirect rules for the dynamic redirects system.
* `geolocation-country-code`: Allows to perform redirections based on the ISO 3166-1 alpha-2 two-letter country code resolved while geolocating the visitor.
* `geolocation-city-name`: Allows to perform redirections based on the city name resolved while geolocating the visitor.
* [#2032](https://github.com/shlinkio/shlink/issues/2032) Save the URL to which a visitor is redirected when a visit is tracked.
The value is exposed in the API as a new `redirectUrl` field for visit objects.
This is useful to know where a visitor was redirected for a short URL with dynamic redirect rules, for special redirects, or simply in case the long URL was changed over time, and you still want to know where visitors were redirected originally.
Some visits may not have a redirect URL if a redirect didn't happen, like for orphan visits when no special redirects are configured, or when a visit is tracked as part of the pixel action.
### Changed
* [#2193](https://github.com/shlinkio/shlink/issues/2193) API keys are now hashed using SHA256, instead of being saved in plain text.
As a side effect, API key names have now become more important, and are considered unique.
When people update to this Shlink version, existing API keys will be hashed for everything to continue working.
In order to avoid data to be lost, plain-text keys will be written in the `name` field, either together with any existing name, or as the name itself. Then users are responsible for renaming them using the new `api-key:rename` command.
For newly created API keys, it is recommended to provide a name, but if not provided, a name will be generated from a redacted version of the new API key.
* Update to Shlink PHP coding standard 2.4
* Update to `hidehalo/nanoid-php` 2.0
* Update to PHPStan 2.0
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2264](https://github.com/shlinkio/shlink/issues/2264) Fix visits counts not being deleted when deleting short URL or orphan visits.
## [4.2.5] - 2024-11-03
### Added
* *Nothing*
### Changed
* Update to Shlink PHP coding standard 2.4
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#2244](https://github.com/shlinkio/shlink/issues/2244) Fix integration with Redis 7.4 and Valkey.
## [4.2.4] - 2024-10-27
### Added
* *Nothing*

View File

@ -1,23 +1,23 @@
FROM php:8.3-alpine3.19 as base
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 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.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 && \
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
@ -36,7 +36,7 @@ RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
apk del .phpize-deps
# Install shlink
FROM base as builder
FROM base AS builder
COPY . .
COPY --from=composer:2 /usr/bin/composer ./composer.phar
RUN apk add --no-cache git && \

View File

@ -7,7 +7,7 @@
[![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/main/LICENSE)
[![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio)
[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlinkio.bsky.social)
[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlink.io)
[![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate)
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own domain.
@ -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.2 or 8.3
* 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)

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,69 +12,66 @@
}
],
"require": {
"php": "^8.2",
"php": "^8.4",
"ext-curl": "*",
"ext-gd": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.3",
"akrabat/ip-address-middleware": "^2.6",
"cakephp/chronos": "^3.1",
"doctrine/dbal": "^4.2",
"doctrine/migrations": "^3.8",
"doctrine/orm": "^3.3",
"endroid/qr-code": "^6.0",
"doctrine/dbal": "^4.3",
"doctrine/migrations": "^3.9",
"doctrine/orm": "^3.5",
"donatj/phpuseragentparser": "^1.10",
"friendsofphp/proxy-manager-lts": "^1.0",
"geoip2/geoip2": "^3.0",
"geoip2/geoip2": "^3.1",
"guzzlehttp/guzzle": "^7.9",
"hidehalo/nanoid-php": "^1.1",
"jaybizzle/crawler-detect": "^1.2.116",
"laminas/laminas-config": "^3.9",
"laminas/laminas-config-aggregator": "^1.15",
"hidehalo/nanoid-php": "^2.0",
"jaybizzle/crawler-detect": "^1.3",
"laminas/laminas-config-aggregator": "^1.17",
"laminas/laminas-diactoros": "^3.5",
"laminas/laminas-inputfilter": "^2.30",
"laminas/laminas-servicemanager": "^3.22",
"laminas/laminas-stdlib": "^3.19",
"laminas/laminas-inputfilter": "^2.31",
"laminas/laminas-servicemanager": "^3.23",
"laminas/laminas-stdlib": "^3.20",
"matomo/matomo-php-tracker": "^3.3",
"mezzio/mezzio": "^3.20",
"mezzio/mezzio-fastroute": "^3.12",
"mezzio/mezzio-problem-details": "^1.15",
"mlocati/ip-lib": "^1.18",
"mobiledetect/mobiledetectlib": "^4.8",
"mlocati/ip-lib": "^1.18.1",
"pagerfanta/core": "^3.8",
"ramsey/uuid": "^4.7",
"shlinkio/doctrine-specification": "^2.1.1",
"shlinkio/shlink-common": "^6.4",
"shlinkio/shlink-config": "^3.3",
"shlinkio/shlink-event-dispatcher": "^4.1",
"shlinkio/shlink-importer": "^5.3.2",
"shlinkio/shlink-installer": "^9.2",
"shlinkio/shlink-ip-geolocation": "^4.1",
"shlinkio/shlink-json": "^1.1",
"spiral/roadrunner": "^2024.1",
"spiral/roadrunner-cli": "^2.6",
"shlinkio/doctrine-specification": "^2.2",
"shlinkio/shlink-common": "dev-main#f2550b5 as 7.3.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#4498f0a as 5.7.0",
"shlinkio/shlink-installer": "dev-develop#40e08cb as 10.0.0",
"shlinkio/shlink-ip-geolocation": "dev-main#e0c45b2 as 5.0.0",
"shlinkio/shlink-json": "dev-main#7c096d6 as 1.3.0",
"spiral/roadrunner": "^2025.1",
"spiral/roadrunner-cli": "^2.7",
"spiral/roadrunner-http": "^3.5",
"spiral/roadrunner-jobs": "^4.5",
"symfony/console": "^7.1",
"symfony/filesystem": "^7.1",
"symfony/lock": "^7.1",
"symfony/process": "^7.1",
"symfony/string": "^7.1"
"spiral/roadrunner-jobs": "^4.6",
"symfony/console": "^8.0 || ^7.4",
"symfony/filesystem": "^8.0",
"symfony/lock": "^8.0",
"symfony/process": "^8.0",
"symfony/string": "^8.0"
},
"require-dev": {
"devizzent/cebe-php-openapi": "^1.0.1",
"devizzent/cebe-php-openapi": "^1.1.2",
"devster/ubench": "^2.1",
"phpstan/phpstan": "^1.12",
"phpstan/phpstan-doctrine": "^1.5",
"phpstan/phpstan-phpunit": "^1.4",
"phpstan/phpstan-symfony": "^1.4",
"phpunit/php-code-coverage": "^11.0",
"phpunit/phpcov": "^10.0",
"phpunit/phpunit": "^11.4",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-doctrine": "^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.10",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.3.0",
"shlinkio/shlink-test-utils": "^4.1.1",
"symfony/var-dumper": "^7.1",
"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": {
@ -109,7 +106,7 @@
},
"scripts": {
"ci": [
"@parallel cs stan swagger:validate test:unit:ci test:db:sqlite:ci test:db:postgres test:db:mysql test:db:maria test:db:ms",
"@parallel cs stan openapi:validate test:unit:ci test:db:sqlite:ci test:db:postgres test:db:mysql test:db:maria test:db:ms",
"@parallel test:api:ci test:cli:ci"
],
"cs": "phpcs -s",
@ -148,43 +145,17 @@
"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"
],
"swagger:validate": "php-openapi validate docs/swagger/swagger.json",
"swagger:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/swagger-inlined.json",
"openapi:validate": "php-openapi validate docs/swagger/swagger.json",
"openapi:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/openapi-inlined.json",
"clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php"
},
"scripts-descriptions": {
"ci": "<fg=blue;options=bold>Alias for \"cs\", \"stan\", \"swagger:validate\" and \"test:ci\"</>",
"cs": "<fg=blue;options=bold>Checks coding styles</>",
"cs:fix": "<fg=blue;options=bold>Fixes coding styles, when possible</>",
"stan": "<fg=blue;options=bold>Inspects code with phpstan</>",
"test": "<fg=blue;options=bold>Runs all test suites</>",
"test:unit": "<fg=blue;options=bold>Runs unit test suites</>",
"test:unit:ci": "<fg=blue;options=bold>Runs unit test suites, generating all needed reports and logs for CI envs</>",
"test:unit:pretty": "<fg=blue;options=bold>Runs unit test suites and generates an HTML code coverage report</>",
"test:db": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL, MariaDB, PostgreSQL and MsSQL</>",
"test:db:sqlite": "<fg=blue;options=bold>Runs database test suites on a SQLite database</>",
"test:db:sqlite:ci": "<fg=blue;options=bold>Runs database test suites on a SQLite database, generating all needed reports and logs for CI envs</>",
"test:db:mysql": "<fg=blue;options=bold>Runs database test suites on a MySQL database</>",
"test:db:maria": "<fg=blue;options=bold>Runs database test suites on a MariaDB database</>",
"test:db:postgres": "<fg=blue;options=bold>Runs database test suites on a PostgreSQL database</>",
"test:db:ms": "<fg=blue;options=bold>Runs database test suites on a Microsoft SQL Server database</>",
"test:api": "<fg=blue;options=bold>Runs API test suites</>",
"test:api:ci": "<fg=blue;options=bold>Runs API test suites, and generates code coverage for CI</>",
"test:api:pretty": "<fg=blue;options=bold>Runs API test suites, and generates code coverage in HTML format</>",
"test:cli": "<fg=blue;options=bold>Runs CLI test suites</>",
"test:cli:ci": "<fg=blue;options=bold>Runs CLI test suites, and generates code coverage for CI</>",
"test:cli:pretty": "<fg=blue;options=bold>Runs CLI test suites, and generates code coverage in HTML format</>",
"swagger:validate": "<fg=blue;options=bold>Validates the swagger docs, making sure they fulfil the spec</>",
"swagger:inline": "<fg=blue;options=bold>Inlines swagger docs in a single file</>",
"clean:dev": "<fg=blue;options=bold>Deletes artifacts which are gitignored and could affect dev env</>"
},
"config": {
"sort-packages": true,
"platform-check": false,

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,20 +0,0 @@
<?php
declare(strict_types=1);
return [
'ip_address_resolution' => [
'headers_to_inspect' => [
'CF-Connecting-IP',
'X-Forwarded-For',
'X-Forwarded',
'Forwarded',
'True-Client-IP',
'X-Real-IP',
'X-Cluster-Client-Ip',
'Client-Ip',
],
],
];

View File

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

View File

@ -12,9 +12,10 @@ use function Shlinkio\Shlink\Core\ArrayUtils\contains;
return (static function (): array {
$driver = EnvVars::DB_DRIVER->loadFromEnv();
$useEncryption = (bool) EnvVars::DB_USE_ENCRYPTION->loadFromEnv();
$isMysqlCompatible = contains($driver, ['maria', 'mysql']);
$resolveDriver = static fn () => match ($driver) {
$doctrineDriver = match ($driver) {
'postgres' => 'pdo_pgsql',
'mssql' => 'pdo_sqlsrv',
default => 'pdo_mysql',
@ -23,31 +24,40 @@ return (static function (): array {
$value = $envVar->loadFromEnv();
return $value === null ? null : (string) $value;
};
$resolveCharset = static fn () => match ($driver) {
$charset = match ($driver) {
// This does not determine charsets or collations in tables or columns, but the charset used in the data
// flowing in the connection, so it has to match what has been set in the database.
'maria', 'mysql' => 'utf8mb4',
'postgres' => 'utf8',
default => null,
};
$resolveConnection = static fn () => match ($driver) {
$driverOptions = match ($driver) {
'mssql' => ['TrustServerCertificate' => 'true'],
'maria', 'mysql' => ! $useEncryption ? [] : [
1007 => true, // PDO::MYSQL_ATTR_SSL_KEY: Require using SSL
1014 => false, // PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT: Trust any certificate
],
'postgres' => ! $useEncryption ? [] : [
'sslmode' => 'require', // Require connections to be encrypted
'sslrootcert' => '', // Allow any certificate
],
default => [],
};
$connection = match ($driver) {
null, 'sqlite' => [
'driver' => 'pdo_sqlite',
'path' => 'data/database.sqlite',
],
default => [
'driver' => $resolveDriver(),
'driver' => $doctrineDriver,
'dbname' => EnvVars::DB_NAME->loadFromEnv(),
'user' => $readCredentialAsString(EnvVars::DB_USER),
'password' => $readCredentialAsString(EnvVars::DB_PASSWORD),
'host' => EnvVars::DB_HOST->loadFromEnv(),
'port' => EnvVars::DB_PORT->loadFromEnv(),
'unix_socket' => $isMysqlCompatible ? EnvVars::DB_UNIX_SOCKET->loadFromEnv() : null,
'charset' => $resolveCharset(),
'driverOptions' => $driver !== 'mssql' ? [] : [
'TrustServerCertificate' => 'true',
],
'charset' => $charset,
'driverOptions' => $driverOptions,
],
};
@ -63,7 +73,7 @@ return (static function (): array {
Events::postFlush => [ShortUrlVisitsCountTracker::class, OrphanVisitsCountTracker::class],
],
],
'connection' => $resolveConnection(),
'connection' => $connection,
],
];

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,
@ -20,6 +21,7 @@ return [
Option\Database\DatabaseUserConfigOption::class,
Option\Database\DatabasePasswordConfigOption::class,
Option\Database\DatabaseUnixSocketConfigOption::class,
Option\Database\DatabaseUseEncryptionConfigOption::class,
Option\UrlShortener\ShortDomainHostConfigOption::class,
Option\UrlShortener\ShortDomainSchemaConfigOption::class,
Option\Redirect\BaseUrlRedirectConfigOption::class,
@ -31,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,
@ -40,8 +44,9 @@ return [
Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class,
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
Option\UrlShortener\RedirectCacheVisibilityConfigOption::class,
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
Option\UrlShortener\AppendExtraPathConfigOption::class,
Option\UrlShortener\ExtraPathModeConfigOption::class,
Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class,
Option\UrlShortener\EnableTrailingSlashConfigOption::class,
Option\UrlShortener\ShortUrlModeConfigOption::class,
@ -55,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,
@ -75,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

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use RKA\Middleware\IpAddress;
use RKA\Middleware\Mezzio\IpAddressFactory;
use Shlinkio\Shlink\Core\Config\EnvVars;
use function Shlinkio\Shlink\Core\splitByComma;
use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE;
return (static function (): array {
$trustedProxies = EnvVars::TRUSTED_PROXIES->loadFromEnv();
$proxiesIsHopCount = is_numeric($trustedProxies);
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,
],
],
];
})();

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

@ -11,6 +11,7 @@ use RKA\Middleware\IpAddress;
use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware;
use Shlinkio\Shlink\Common\Middleware\ContentLengthMiddleware;
use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware;
use Shlinkio\Shlink\Core\Geolocation\Middleware\IpGeolocationMiddleware;
return [
@ -67,8 +68,11 @@ return [
],
'not-found' => [
'middleware' => [
// This middleware is in front of tracking actions explicitly. Putting here for orphan visits tracking
// These two middlewares are in front of other tracking actions.
// Putting them here for orphan visits tracking
IpAddress::class,
IpGeolocationMiddleware::class,
Core\ErrorHandler\NotFoundTypeResolverMiddleware::class,
Core\ShortUrl\Middleware\ExtraPathRedirectMiddleware::class,
Core\ErrorHandler\NotFoundTrackerMiddleware::class,

View File

@ -8,6 +8,7 @@ use Fig\Http\Message\RequestMethodInterface;
use RKA\Middleware\IpAddress;
use Shlinkio\Shlink\Core\Action as CoreAction;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Shlinkio\Shlink\Core\Geolocation\Middleware\IpGeolocationMiddleware;
use Shlinkio\Shlink\Core\ShortUrl\Middleware\TrimTrailingSlashMiddleware;
use Shlinkio\Shlink\Rest\Action;
use Shlinkio\Shlink\Rest\ConfigProvider;
@ -88,23 +89,17 @@ return (static function (): array {
'path' => '/{shortCode}/track',
'middleware' => [
IpAddress::class,
IpGeolocationMiddleware::class,
CoreAction\PixelAction::class,
],
'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),
'middleware' => [
IpAddress::class,
IpGeolocationMiddleware::class,
TrimTrailingSlashMiddleware::class,
CoreAction\RedirectAction::class,
],

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,13 +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

@ -7,18 +7,20 @@ server:
command: 'php -dopcache.enable_cli=1 -dopcache.validate_timestamps=0 ../../bin/roadrunner-worker.php'
http:
address: '0.0.0.0:${PORT:-8080}'
address: '${ADDRESS:-0.0.0.0}:${PORT:-8080}'
middleware: ['static']
static:
dir: '../../public'
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

@ -3,7 +3,7 @@
set -ex
curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add -
curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list > /etc/apt/sources.list.d/mssql-release.list
curl https://packages.microsoft.com/config/ubuntu/24.04/prod.list > /etc/apt/sources.list.d/mssql-release.list
apt-get update
ACCEPT_EULA=Y apt-get install msodbcsql18
# apt-get install unixodbc-dev

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,52 @@
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 libzip-dev zlib-dev
RUN docker-php-ext-install zip
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 && \
docker-php-ext-install sockets && \
apk del .phpize-deps
RUN docker-php-ext-install bcmath
# Install xdebug and 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 && \
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.3-fpm-alpine3.19
FROM php:8.4-fpm-alpine3.21
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.23
ENV PDO_SQLSRV_VERSION 5.12.0
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.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
@ -25,9 +25,6 @@ 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

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,10 +1,9 @@
FROM php:8.3-alpine3.19
FROM php:8.4-alpine3.21
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.23
ENV PDO_SQLSRV_VERSION 5.12.0
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.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
@ -25,9 +24,6 @@ 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
@ -36,16 +32,6 @@ RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \
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
RUN apk add --update linux-headers && \
wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/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
@ -118,13 +149,13 @@ services:
shlink_redis:
container_name: shlink_redis
image: redis:6.2-alpine
image: redis:7.4-alpine
ports:
- "6380:6379"
shlink_redis_acl:
container_name: shlink_redis_acl
image: redis:6.2-alpine
image: redis:7.4-alpine
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
ports:
- "6382:6379"
@ -144,7 +175,7 @@ services:
shlink_mercure:
container_name: shlink_mercure
image: dunglas/mercure:v0.15
image: dunglas/mercure:v0.18
ports:
- "3080:80"
environment:

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

@ -141,6 +141,14 @@
"crawlable": {
"type": "boolean",
"description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt."
},
"forwardQuery": {
"type": "boolean",
"description": "Tells if this URL will forward the query params to the long URL when visited, as explained in [the docs](https://shlink.io/documentation/some-features/#query-params-forwarding)."
},
"hasRedirectRules": {
"type": "boolean",
"description": "Whether this short URL has redirect rules attached to it or not. Use [this endpoint](https://api-spec.shlink.io/#/Redirect%20rules/listShortUrlRedirectRules) to get the actual list of rules."
}
},
"example": {
@ -164,7 +172,9 @@
},
"domain": "example.com",
"title": "The title",
"crawlable": false
"crawlable": false,
"forwardQuery": false,
"hasRedirectRules": true
}
},
"ShortUrlMeta": {
@ -237,6 +247,11 @@
"type": "string",
"nullable": true,
"description": "The originally visited URL that triggered the tracking of this visit"
},
"redirectUrl": {
"type": "string",
"nullable": true,
"description": "The URL to which the visitor was redirected, or null if a redirect did not occur, like for 404 requests or pixel tracking"
}
},
"example": {

View File

@ -15,14 +15,23 @@
"properties": {
"type": {
"type": "string",
"enum": ["device", "language", "query-param", "ip-address"],
"enum": [
"device",
"language",
"query-param",
"any-value-query-param",
"valueless-query-param",
"ip-address",
"geolocation-country-code",
"geolocation-city-name"
],
"description": "The type of the condition, which will determine the logic used to match it"
},
"matchKey": {
"type": ["string", "null"]
},
"matchValue": {
"type": "string"
"type": ["string", "null"]
}
}
}

View File

@ -11,7 +11,8 @@
"domain",
"title",
"crawlable",
"forwardQuery"
"forwardQuery",
"hasRedirectRules"
],
"properties": {
"shortCode": {
@ -59,6 +60,10 @@
"forwardQuery": {
"type": "boolean",
"description": "Tells if this URL will forward the query params to the long URL when visited, as explained in [the docs](https://shlink.io/documentation/some-features/#query-params-forwarding)."
},
"hasRedirectRules": {
"type": "boolean",
"description": "Whether this short URL has redirect rules attached to it or not. Use [this endpoint](https://api-spec.shlink.io/#/Redirect%20rules/listShortUrlRedirectRules) to get the actual list of rules."
}
}
}

View File

@ -25,6 +25,10 @@
"visitedUrl": {
"type": ["string", "null"],
"description": "The originally visited URL that triggered the tracking of this visit"
},
"redirectUrl": {
"type": ["string", "null"],
"description": "The URL to which the visitor was redirected, or null if a redirect did not occur, like for 404 requests or pixel tracking"
}
}
}

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",
@ -125,6 +147,24 @@
"false"
]
}
},
{
"name": "domain",
"in": "query",
"description": "Get short URLs for this particular domain only. Use **DEFAULT** keyword for default domain.",
"required": false,
"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": [
@ -180,7 +220,9 @@
},
"domain": null,
"title": "Welcome to Steam",
"crawlable": false
"crawlable": false,
"forwardQuery": true,
"hasRedirectRules": true
},
{
"shortCode": "12Kb3",
@ -202,7 +244,9 @@
},
"domain": null,
"title": null,
"crawlable": false
"crawlable": false,
"forwardQuery": true,
"hasRedirectRules": false
},
{
"shortCode": "123bA",
@ -222,7 +266,9 @@
},
"domain": "example.com",
"title": null,
"crawlable": false
"crawlable": false,
"forwardQuery": false,
"hasRedirectRules": true
}
],
"pagination": {
@ -337,7 +383,9 @@
},
"domain": null,
"title": null,
"crawlable": false
"crawlable": false,
"forwardQuery": true,
"hasRedirectRules": false
}
}
}

View File

@ -72,7 +72,9 @@
},
"domain": null,
"title": null,
"crawlable": false
"crawlable": false,
"forwardQuery": true,
"hasRedirectRules": false
}
},
"text/plain": {

View File

@ -50,7 +50,9 @@
},
"domain": null,
"title": null,
"crawlable": false
"crawlable": false,
"forwardQuery": true,
"hasRedirectRules": true
}
}
}
@ -163,7 +165,9 @@
},
"domain": null,
"title": "Shlink - The URL shortener",
"crawlable": false
"crawlable": false,
"forwardQuery": false,
"hasRedirectRules": true
}
}
}

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,110 +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"
}
}
],
"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,8 +26,10 @@ 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,
Command\Tag\ListTagsCommand::NAME => Command\Tag\ListTagsCommand::class,
Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class,

View File

@ -7,9 +7,9 @@ namespace Shlinkio\Shlink\CLI;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
use Shlinkio\Shlink\Core\Config\Options\TrackingOptions;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Geolocation\GeolocationDbUpdater;
use Shlinkio\Shlink\Core\Matomo;
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleService;
use Shlinkio\Shlink\Core\ShortUrl;
@ -17,15 +17,11 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\Tag\TagService;
use Shlinkio\Shlink\Core\Visit;
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2ReaderFactory;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Symfony\Component\Console as SymfonyCli;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Process\PhpExecutableFinder;
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
return [
'dependencies' => [
@ -34,7 +30,6 @@ return [
SymfonyCli\Helper\ProcessHelper::class => ProcessHelperFactory::class,
PhpExecutableFinder::class => InvokableFactory::class,
GeoLite\GeolocationDbUpdater::class => ConfigAbstractFactory::class,
RedirectRule\RedirectRuleHandler::class => InvokableFactory::class,
Util\ProcessRunner::class => ConfigAbstractFactory::class,
@ -57,8 +52,10 @@ 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,
Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class,
Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class,
@ -81,12 +78,6 @@ return [
],
ConfigAbstractFactory::class => [
GeoLite\GeolocationDbUpdater::class => [
DbUpdater::class,
GeoLite2ReaderFactory::class,
LOCAL_LOCK_FACTORY,
TrackingOptions::class,
],
Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class],
ApiKey\RoleResolver::class => [DomainService::class, UrlShortenerOptions::class],
@ -106,7 +97,7 @@ return [
Command\ShortUrl\DeleteShortUrlVisitsCommand::class => [ShortUrl\ShortUrlVisitsDeleter::class],
Command\ShortUrl\DeleteExpiredShortUrlsCommand::class => [ShortUrl\DeleteShortUrlService::class],
Command\Visit\DownloadGeoLiteDbCommand::class => [GeoLite\GeolocationDbUpdater::class],
Command\Visit\DownloadGeoLiteDbCommand::class => [GeolocationDbUpdater::class],
Command\Visit\LocateVisitsCommand::class => [
Visit\Geolocation\VisitLocator::class,
Visit\Geolocation\VisitToLocationHelper::class,
@ -118,8 +109,10 @@ return [
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],
Command\Tag\ListTagsCommand::class => [TagService::class],
Command\Tag\RenameTagCommand::class => [TagService::class],

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,45 +4,75 @@ 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\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 NAME = 'api-key:disable';
public const string NAME = 'api-key:disable';
public function __construct(private ApiKeyServiceInterface $apiKeyService)
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
{
parent::__construct();
}
protected function configure(): void
protected function interact(InputInterface $input, OutputInterface $output): void
{
$this->setName(self::NAME)
->setDescription('Disables an API key.')
->addArgument('apiKey', InputArgument::REQUIRED, 'The API key to disable');
$name = $input->getArgument('name');
if ($name === null) {
$apiKeys = $this->apiKeyService->listKeys(enabledOnly: true);
$name = new SymfonyStyle($input, $output)->choice(
'What API key do you want to disable?',
map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
);
$input->setArgument('name', $name);
}
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$apiKey = $input->getArgument('apiKey');
$io = new SymfonyStyle($input, $output);
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 Command::INVALID;
}
try {
$this->apiKeyService->disable($apiKey);
$io->success(sprintf('API key "%s" properly disabled', $apiKey));
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

@ -6,7 +6,6 @@ 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\Util\ShlinkTable;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Role;
@ -23,7 +22,7 @@ use function sprintf;
class GenerateKeyCommand extends Command
{
public const NAME = 'api-key:generate';
public const string NAME = 'api-key:generate';
public function __construct(
private readonly ApiKeyServiceInterface $apiKeyService,
@ -100,26 +99,29 @@ class GenerateKeyCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$expirationDate = $input->getOption('expiration-date');
$apiKey = $this->apiKeyService->create(ApiKeyMeta::fromParams(
$apiKeyMeta = ApiKeyMeta::fromParams(
name: $input->getOption('name'),
expirationDate: isset($expirationDate) ? Chronos::parse($expirationDate) : null,
roleDefinitions: $this->roleResolver->determineRoles($input),
));
);
$io = new SymfonyStyle($input, $output);
$io->success(sprintf('Generated API key: "%s"', $apiKey->toString()));
$apiKey = $this->apiKeyService->create($apiKeyMeta);
$io->success(sprintf('Generated API key: "%s"', $apiKeyMeta->key));
if ($input->isInteractive()) {
$io->warning('Save the key in a secure location. You will not be able to get it afterwards.');
}
if (! ApiKey::isAdmin($apiKey)) {
ShlinkTable::default($io)->render(
['Role name', 'Role metadata'],
$apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, 0)]),
null,
'Roles',
$apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, indentSize: 0)]),
headerTitle: 'Roles',
);
}
return ExitCode::EXIT_SUCCESS;
return Command::SUCCESS;
}
}

View File

@ -4,40 +4,35 @@ 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 NAME = 'api-key:initial';
public const string NAME = 'api-key:initial';
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
{
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

@ -4,57 +4,51 @@ 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 ERROR_STRING_PATTERN = '<fg=red>%s</>';
private const SUCCESS_STRING_PATTERN = '<info>%s</info>';
private const WARNING_STRING_PATTERN = '<comment>%s</comment>';
private const string ERROR_STRING_PATTERN = '<fg=red>%s</>';
private const string SUCCESS_STRING_PATTERN = '<info>%s</info>';
private const string WARNING_STRING_PATTERN = '<comment>%s</comment>';
public const NAME = 'api-key:list';
public const string NAME = 'api-key:list';
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
{
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);
// Set columns for this row
$rowData = [sprintf($messagePattern, $apiKey), sprintf($messagePattern, $apiKey->name ?? '-')];
$rowData = [sprintf($messagePattern, $apiKey->name ?? '-')];
if (! $enabledOnly) {
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
}
@ -66,15 +60,14 @@ class ListKeysCommand extends Command
return $rowData;
}, $this->apiKeyService->listKeys($enabledOnly));
ShlinkTable::withRowSeparators($output)->render(array_filter([
'Key',
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

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
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\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;
#[AsCommand(
name: RenameApiKeyCommand::NAME,
description: 'Renames an API key by name',
)]
class RenameApiKeyCommand extends Command
{
public const string NAME = 'api-key:rename';
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
{
parent::__construct();
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$oldName = $input->getArgument('old-name');
$newName = $input->getArgument('new-name');
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('old-name', $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('new-name', $requestedNewName);
}
}
public function __invoke(
SymfonyStyle $io,
#[Argument(description: 'Current name of the API key to rename')] string $oldName,
#[Argument(description: 'New name to set to the API key')] string $newName,
): int {
$this->apiKeyService->renameApiKey(Renaming::fromNames($oldName, $newName));
$io->success('API key properly renamed');
return Command::SUCCESS;
}
}

View File

@ -5,11 +5,11 @@ 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\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;
@ -19,32 +19,28 @@ 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 NAME = 'env-var:read';
public const string NAME = 'env-var:read';
/** @var Closure(string $envVar): mixed */
private readonly Closure $loadEnvVar;
public function __construct(?Closure $loadEnvVar = null)
public function __construct(Closure|null $loadEnvVar = null)
{
$this->loadEnvVar = $loadEnvVar ?? static fn (string $envVar) => EnvVars::from($envVar)->loadFromEnv();
parent::__construct();
}
protected function configure(): 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');
$envVar = $input->getArgument('env-var');
$validEnvVars = enumValues(EnvVars::class);
if ($envVar === null) {
@ -55,14 +51,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

@ -7,7 +7,6 @@ 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\Util\ProcessRunnerInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@ -24,9 +23,9 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
{
private readonly Connection $regularConn;
public const NAME = 'db:create';
public const DOCTRINE_SCRIPT = 'bin/doctrine';
public const DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create';
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 function __construct(
LockFactory $locker,
@ -55,7 +54,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
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
@ -63,7 +62,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
$this->runPhpCommand($output, [self::DOCTRINE_SCRIPT, self::DOCTRINE_CREATE_SCHEMA_COMMAND]);
$io->success('Database properly created!');
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
private function databaseTablesExist(): bool

View File

@ -4,16 +4,15 @@ 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 Symfony\Component\Console\Style\SymfonyStyle;
class MigrateDatabaseCommand extends AbstractDatabaseCommand
{
public const NAME = 'db:migrate';
public const DOCTRINE_MIGRATIONS_SCRIPT = 'vendor/doctrine/migrations/bin/doctrine-migrations.php';
public const DOCTRINE_MIGRATE_COMMAND = 'migrations:migrate';
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';
protected function configure(): void
{
@ -31,6 +30,6 @@ class MigrateDatabaseCommand extends AbstractDatabaseCommand
$this->runPhpCommand($output, [self::DOCTRINE_MIGRATIONS_SCRIPT, self::DOCTRINE_MIGRATE_COMMAND]);
$io->success('Database properly migrated!');
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
}

View File

@ -4,12 +4,12 @@ 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\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;
@ -19,27 +19,19 @@ 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 NAME = 'domain:redirects';
public const string NAME = 'domain:redirects';
public function __construct(private readonly DomainServiceInterface $domainService)
{
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
{
/** @var string|null $domain */
@ -68,13 +60,14 @@ 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 $current) use ($io): ?string {
$ask = static function (string $message, string|null $current) use ($io): string|null {
if ($current === null) {
return $io->ask(sprintf('%s (Leave empty for no redirect)', $message));
}
@ -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

@ -16,7 +16,7 @@ use Symfony\Component\Console\Input\InputInterface;
class GetDomainVisitsCommand extends AbstractVisitsListCommand
{
public const NAME = 'domain:visits';
public const string NAME = 'domain:visits';
public function __construct(
VisitsStatsHelperInterface $visitsHelper,

View File

@ -4,46 +4,41 @@ 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 NAME = 'domain:list';
public const string NAME = 'domain:list';
public function __construct(private readonly DomainServiceInterface $domainService)
{
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

@ -5,14 +5,13 @@ 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;
@ -20,22 +19,9 @@ use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\dateRangeToHumanFriendly;
use function sprintf;
class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface
{
public const 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,
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,41 +41,45 @@ 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
protected function configure(): void
{
$this->io = new SymfonyStyle($input, $output);
$this->setDescription(sprintf(
'%sSend existing visits to the configured matomo instance',
$this->matomoEnabled ? '' : '<comment>[MATOMO INTEGRATION DISABLED]</comment> ',
));
}
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,
@ -103,7 +93,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 +112,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

@ -6,7 +6,6 @@ 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\ShortUrlResolverInterface;
@ -19,7 +18,7 @@ use function sprintf;
class ManageRedirectRulesCommand extends Command
{
public const NAME = 'short-url:manage-rules';
public const string NAME = 'short-url:manage-rules';
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
@ -52,7 +51,7 @@ class ManageRedirectRulesCommand extends Command
$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 +60,6 @@ class ManageRedirectRulesCommand extends Command
$io->success('Rules properly saved');
}
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
}

View File

@ -5,7 +5,6 @@ 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\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
@ -20,9 +19,9 @@ use function sprintf;
class CreateShortUrlCommand extends Command
{
public const NAME = 'short-url:create';
public const string NAME = 'short-url:create';
private ?SymfonyStyle $io;
private SymfonyStyle $io;
private readonly ShortUrlDataInput $shortUrlDataInput;
public function __construct(
@ -114,10 +113,10 @@ class CreateShortUrlCommand extends Command
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;
}
}

View File

@ -4,52 +4,40 @@ 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 NAME = 'short-url:delete-expired';
public const string NAME = 'short-url:delete-expired';
public function __construct(private readonly DeleteShortUrlServiceInterface $deleteShortUrlService)
{
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

@ -5,7 +5,6 @@ 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;
@ -19,7 +18,7 @@ use function sprintf;
class DeleteShortUrlCommand extends Command
{
public const NAME = 'short-url:delete';
public const string NAME = 'short-url:delete';
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
@ -55,10 +54,10 @@ class DeleteShortUrlCommand extends Command
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 +74,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

@ -6,7 +6,6 @@ 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\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleterInterface;
use Symfony\Component\Console\Input\InputInterface;
@ -16,7 +15,7 @@ use function sprintf;
class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand
{
public const NAME = 'short-url:visits-delete';
public const string NAME = 'short-url:visits-delete';
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
@ -44,10 +43,10 @@ class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand
$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;
}
}

View File

@ -6,7 +6,6 @@ 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\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface;
@ -19,7 +18,7 @@ use function sprintf;
class EditShortUrlCommand extends Command
{
public const NAME = 'short-url:edit';
public const string NAME = 'short-url:edit';
private readonly ShortUrlDataInput $shortUrlDataInput;
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
@ -57,7 +56,7 @@ class EditShortUrlCommand extends Command
);
$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 +64,7 @@ class EditShortUrlCommand extends Command
$this->getApplication()?->renderThrowable($e, $io);
}
return ExitCode::EXIT_FAILURE;
return self::FAILURE;
}
}
}

View File

@ -16,7 +16,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
{
public const NAME = 'short-url:visits';
public const string NAME = 'short-url:visits';
private ShortUrlIdentifierInput $shortUrlIdentifierInput;

View File

@ -6,13 +6,14 @@ 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\Input\TagsOption;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
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\ShortUrlWithVisitsSummary;
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;
@ -32,10 +33,11 @@ use function sprintf;
class ListShortUrlsCommand extends Command
{
public const NAME = 'short-url:list';
public const string NAME = 'short-url:list';
private readonly StartDateOption $startDateOption;
private readonly EndDateOption $endDateOption;
private readonly TagsOption $tagsOption;
public function __construct(
private readonly ShortUrlListServiceInterface $shortUrlService,
@ -44,6 +46,7 @@ class ListShortUrlsCommand extends Command
parent::__construct();
$this->startDateOption = new StartDateOption($this, 'short URLs');
$this->endDateOption = new EndDateOption($this, 'short URLs');
$this->tagsOption = new TagsOption($this, 'A list of tags that short URLs need to include.');
}
protected function configure(): void
@ -65,16 +68,26 @@ class ListShortUrlsCommand extends Command
'A query used to filter results by searching for it on the longUrl and shortCode fields.',
)
->addOption(
'tags',
't',
'domain',
'd',
InputOption::VALUE_REQUIRED,
'A comma-separated list of tags to filter results.',
'Used to filter results by domain. Use DEFAULT keyword to filter by default domain',
)
->addOption(
'including-all-tags',
'i',
InputOption::VALUE_NONE,
'If tags is provided, returns only short URLs having ALL tags.',
'tags-all',
mode: InputOption::VALUE_NONE,
description: 'If --tag is provided, returns only short URLs including ALL of them',
)
->addOption(
'exclude-tag',
'et',
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'A list of tags that short URLs should not have.',
)
->addOption(
'exclude-tags-all',
mode: InputOption::VALUE_NONE,
description: 'If --exclude-tag is provided, returns only short URLs not including ANY of them',
)
->addOption(
'exclude-max-visits-reached',
@ -95,6 +108,12 @@ class ListShortUrlsCommand extends Command
'The field from which you want to order by. '
. 'Define ordering dir by passing ASC or DESC after "-" or ",".',
)
->addOption(
'api-key-name',
'kn',
InputOption::VALUE_REQUIRED,
'List only short URLs created by the API key matching provided name.',
)
->addOption(
'show-tags',
null,
@ -111,12 +130,6 @@ class ListShortUrlsCommand extends Command
'show-api-key',
'k',
InputOption::VALUE_NONE,
'Whether to display the API key from which the URL was generated or not.',
)
->addOption(
'show-api-key-name',
'm',
InputOption::VALUE_NONE,
'Whether to display the API key name from which the URL was generated or not.',
)
->addOption(
@ -133,31 +146,30 @@ class ListShortUrlsCommand extends Command
$io = new SymfonyStyle($input, $output);
$page = (int) $input->getOption('page');
$searchTerm = $input->getOption('search-term');
$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);
$tagsMode = $input->getOption('tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
$excludeTagsMode = $input->getOption('exclude-tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
$data = [
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
ShortUrlsParamsInputFilter::TAGS => $tags,
ShortUrlsParamsInputFilter::SEARCH_TERM => $input->getOption('search-term'),
ShortUrlsParamsInputFilter::DOMAIN => $input->getOption('domain'),
ShortUrlsParamsInputFilter::TAGS => $this->tagsOption->get($input),
ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode,
ShortUrlsParamsInputFilter::ORDER_BY => $orderBy,
ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(),
ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(),
ShortUrlsParamsInputFilter::EXCLUDE_TAGS => $input->getOption('exclude-tag'),
ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE => $excludeTagsMode,
ShortUrlsParamsInputFilter::ORDER_BY => $this->processOrderBy($input),
ShortUrlsParamsInputFilter::START_DATE => $this->startDateOption->get($input, $output)?->toAtomString(),
ShortUrlsParamsInputFilter::END_DATE => $this->endDateOption->get($input, $output)?->toAtomString(),
ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED => $input->getOption('exclude-max-visits-reached'),
ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL => $input->getOption('exclude-past-valid-until'),
ShortUrlsParamsInputFilter::API_KEY_NAME => $input->getOption('api-key-name'),
];
$all = $input->getOption('all');
if ($all) {
$data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = Paginator::ALL_ITEMS;
}
$columnsMap = $this->resolveColumnsMap($input);
do {
$data[ShortUrlsParamsInputFilter::PAGE] = $page;
$result = $this->renderPage($output, $columnsMap, ShortUrlsParams::fromRawData($data), $all);
@ -165,19 +177,19 @@ class ListShortUrlsCommand extends Command
$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;
}
/**
* @param array<string, callable(array $serializedShortUrl, ShortUrl $shortUrl): ?string> $columnsMap
* @return Paginator<ShortUrlWithVisitsSummary>
* @return Paginator<ShortUrlWithDeps>
*/
private function renderPage(
OutputInterface $output,
@ -187,7 +199,7 @@ class ListShortUrlsCommand extends Command
): Paginator {
$shortUrls = $this->shortUrlService->listShortUrls($params);
$rows = map([...$shortUrls], function (ShortUrlWithVisitsSummary $shortUrl) use ($columnsMap) {
$rows = map([...$shortUrls], function (ShortUrlWithDeps $shortUrl) use ($columnsMap) {
$serializedShortUrl = $this->transformer->transform($shortUrl);
return map($columnsMap, fn (callable $call) => $call($serializedShortUrl, $shortUrl->shortUrl));
});
@ -201,7 +213,7 @@ class ListShortUrlsCommand extends Command
return $shortUrls;
}
private function processOrderBy(InputInterface $input): ?string
private function processOrderBy(InputInterface $input): string|null
{
$orderBy = $input->getOption('order-by');
if (empty($orderBy)) {
@ -231,14 +243,10 @@ class ListShortUrlsCommand extends Command
}
if ($input->getOption('show-domain')) {
$columnsMap['Domain'] = static fn (array $_, ShortUrl $shortUrl): string =>
$shortUrl->getDomain()?->authority ?? 'DEFAULT';
$shortUrl->getDomain()->authority ?? Domain::DEFAULT_AUTHORITY;
}
if ($input->getOption('show-api-key')) {
$columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string =>
$shortUrl->authorApiKey?->__toString() ?? '';
}
if ($input->getOption('show-api-key-name')) {
$columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): ?string =>
$columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): string|null =>
$shortUrl->authorApiKey?->name;
}

View File

@ -5,7 +5,6 @@ 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\ShortUrlResolverInterface;
use Symfony\Component\Console\Command\Command;
@ -17,7 +16,7 @@ use function sprintf;
class ResolveUrlCommand extends Command
{
public const NAME = 'short-url:parse';
public const string NAME = 'short-url:parse';
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
@ -59,10 +58,10 @@ class ResolveUrlCommand extends Command
try {
$url = $this->urlResolver->resolveShortUrl($this->shortUrlIdentifierInput->toShortUrlIdentifier($input));
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
} catch (ShortUrlNotFoundException $e) {
$io->error($e->getMessage());
return ExitCode::EXIT_FAILURE;
return self::FAILURE;
}
}
}

View File

@ -4,48 +4,37 @@ 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 NAME = 'tag:delete';
public const string NAME = 'tag:delete';
public function __construct(private TagServiceInterface $tagService)
public function __construct(private readonly TagServiceInterface $tagService)
{
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

@ -5,24 +5,34 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
use Shlinkio\Shlink\CLI\Input\DomainOption;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
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 function sprintf;
class GetTagVisitsCommand extends AbstractVisitsListCommand
{
public const NAME = 'tag:visits';
public const string NAME = 'tag:visits';
private readonly DomainOption $domainOption;
public function __construct(
VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
parent::__construct($visitsHelper);
$this->domainOption = new DomainOption($this, sprintf(
'Return visits that belong to this domain only. Use %s keyword for visits in default domain',
Domain::DEFAULT_AUTHORITY,
));
}
protected function configure(): void
@ -39,7 +49,10 @@ class GetTagVisitsCommand extends AbstractVisitsListCommand
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$tag = $input->getArgument('tag');
return $this->visitsHelper->visitsForTag($tag, new VisitsParams($dateRange));
return $this->visitsHelper->visitsForTag($tag, new WithDomainVisitsParams(
dateRange: $dateRange,
domain: $this->domainOption->get($input),
));
}
/**

View File

@ -4,37 +4,30 @@ 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 NAME = 'tag:list';
public const string NAME = 'tag:list';
public function __construct(private readonly TagServiceInterface $tagService)
{
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,48 +4,37 @@ 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\Tag\Model\TagRenaming;
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 NAME = 'tag:rename';
public const string NAME = 'tag:rename';
public function __construct(private readonly TagServiceInterface $tagService)
{
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(TagRenaming::fromNames($oldName, $newName));
$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

@ -4,7 +4,6 @@ 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;
@ -28,7 +27,7 @@ abstract class AbstractLockedCommand extends Command
$output->writeln(
sprintf('<comment>Command "%s" is already in progress. Skipping.</comment>', $lockConfig->lockName),
);
return ExitCode::EXIT_WARNING;
return self::INVALID;
}
try {

View File

@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Util;
final class LockedCommandConfig
{
public const DEFAULT_TTL = 600.0; // 10 minutes
public const float DEFAULT_TTL = 600.0; // 10 minutes
private function __construct(
public readonly string $lockName,

View File

@ -4,7 +4,6 @@ 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;
@ -17,7 +16,7 @@ abstract class AbstractDeleteVisitsCommand extends Command
$io = new SymfonyStyle($input, $output);
if (! $this->confirm($io)) {
$io->info('Operation aborted');
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
return $this->doExecute($input, $io);

View File

@ -6,7 +6,6 @@ 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;
@ -43,7 +42,7 @@ abstract class AbstractVisitsListCommand extends Command
ShlinkTable::default($output)->render($headers, $rows);
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
/**
@ -61,8 +60,8 @@ abstract class AbstractVisitsListCommand extends Command
'date' => $visit->date->toAtomString(),
'userAgent' => $visit->userAgent,
'potentialBot' => $visit->potentialBot,
'country' => $visit->getVisitLocation()?->countryName ?? 'Unknown',
'city' => $visit->getVisitLocation()?->cityName ?? 'Unknown',
'country' => $visit->getVisitLocation()->countryName ?? 'Unknown',
'city' => $visit->getVisitLocation()->cityName ?? 'Unknown',
...$extraFields,
];

View File

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Visit\VisitsDeleterInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
@ -13,7 +12,7 @@ use function sprintf;
class DeleteOrphanVisitsCommand extends AbstractDeleteVisitsCommand
{
public const NAME = 'visit:orphan-delete';
public const string NAME = 'visit:orphan-delete';
public function __construct(private readonly VisitsDeleterInterface $deleter)
{
@ -32,7 +31,7 @@ class DeleteOrphanVisitsCommand extends AbstractDeleteVisitsCommand
$result = $this->deleter->deleteOrphanVisits();
$io->success(sprintf('Successfully deleted %s visits', $result->affectedItems));
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
}
protected function getWarningMessage(): string

View File

@ -4,73 +4,71 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult;
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;
class DownloadGeoLiteDbCommand extends Command
#[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 NAME = 'visit:download-db';
public const string NAME = 'visit:download-db';
private ?ProgressBar $progressBar = null;
private ProgressBar|null $progressBar = null;
private SymfonyStyle $io;
public function __construct(private GeolocationDbUpdaterInterface $dbUpdater)
public function __construct(private readonly GeolocationDbUpdaterInterface $dbUpdater)
{
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
{
$io = new SymfonyStyle($input, $output);
$this->io = $io;
try {
$result = $this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) use ($io): void {
$io->text(sprintf('<fg=blue>%s GeoLite2 db file...</>', $olderDbExists ? 'Updating' : 'Downloading'));
$this->progressBar = new ProgressBar($io);
}, function (int $total, int $downloaded): void {
$this->progressBar?->setMaxSteps($total);
$this->progressBar?->setProgress($downloaded);
});
$result = $this->dbUpdater->checkDbUpdate($this);
if ($result === GeolocationResult::LICENSE_MISSING) {
$io->warning('It was not possible to download GeoLite2 db, because a license was not provided.');
return ExitCode::EXIT_WARNING;
$this->io->warning('It was not possible to download GeoLite2 db, because a license was not provided.');
return self::INVALID;
}
if ($result === GeolocationResult::MAX_ERRORS_REACHED) {
$this->io->warning('Max consecutive errors reached. Cannot retry for a couple of days.');
return self::INVALID;
}
if ($result === GeolocationResult::UPDATE_IN_PROGRESS) {
$this->io->warning('A geolocation db is already being downloaded by another process.');
return self::INVALID;
}
if ($this->progressBar === null) {
$io->info('GeoLite2 db file is up to date.');
$this->io->info('GeoLite2 db file is up to date.');
} else {
$this->progressBar->finish();
$io->success('GeoLite2 db file properly downloaded.');
$this->io->success('GeoLite2 db file properly downloaded.');
}
return ExitCode::EXIT_SUCCESS;
return self::SUCCESS;
} catch (GeolocationDbUpdateFailedException $e) {
return $this->processGeoLiteUpdateError($e, $io);
return $this->processGeoLiteUpdateError($e, $this->io);
}
}
private function processGeoLiteUpdateError(GeolocationDbUpdateFailedException $e, SymfonyStyle $io): int
{
$olderDbExists = $e->olderDbExists();
$olderDbExists = $e->olderDbExists;
if ($olderDbExists) {
$io->warning(
@ -84,6 +82,18 @@ class DownloadGeoLiteDbCommand extends Command
$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
{
$this->io->text(sprintf('<fg=blue>%s GeoLite2 db file...</>', $olderDbExists ? 'Updating' : 'Downloading'));
$this->progressBar = new ProgressBar($this->io);
}
public function handleProgress(int $total, int $downloaded, bool $olderDbExists): void
{
$this->progressBar?->setMaxSteps($total);
$this->progressBar?->setProgress($downloaded);
}
}

View File

@ -4,23 +4,33 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Input\DomainOption;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputInterface;
use function sprintf;
class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
{
public const NAME = 'visit:non-orphan';
public const string NAME = 'visit:non-orphan';
private readonly DomainOption $domainOption;
public function __construct(
VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
parent::__construct($visitsHelper);
$this->domainOption = new DomainOption($this, sprintf(
'Return visits that belong to this domain only. Use %s keyword for visits in default domain',
Domain::DEFAULT_AUTHORITY,
));
}
protected function configure(): void
@ -35,7 +45,10 @@ class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
return $this->visitsHelper->nonOrphanVisits(new VisitsParams($dateRange));
return $this->visitsHelper->nonOrphanVisits(new WithDomainVisitsParams(
dateRange: $dateRange,
domain: $this->domainOption->get($input),
));
}
/**

View File

@ -4,11 +4,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Input\DomainOption;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@ -17,7 +20,18 @@ use function sprintf;
class GetOrphanVisitsCommand extends AbstractVisitsListCommand
{
public const NAME = 'visit:orphan';
public const string NAME = 'visit:orphan';
private readonly DomainOption $domainOption;
public function __construct(VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct($visitsHelper);
$this->domainOption = new DomainOption($this, sprintf(
'Return visits that belong to this domain only. Use %s keyword for visits in default domain',
Domain::DEFAULT_AUTHORITY,
));
}
protected function configure(): void
{
@ -37,7 +51,11 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand
{
$rawType = $input->getOption('type');
$type = $rawType !== null ? OrphanVisitType::from($rawType) : null;
return $this->visitsHelper->orphanVisits(new OrphanVisitsParams(dateRange: $dateRange, type: $type));
return $this->visitsHelper->orphanVisits(new OrphanVisitsParams(
dateRange: $dateRange,
domain: $this->domainOption->get($input),
type: $type,
));
}
/**

View File

@ -6,7 +6,6 @@ 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\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
@ -29,7 +28,7 @@ use function sprintf;
class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocationHelperInterface
{
public const NAME = 'visit:locate';
public const string NAME = 'visit:locate';
private SymfonyStyle $io;
@ -116,14 +115,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,7 +170,7 @@ 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.');
}
}

View File

@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Exception;
use RuntimeException;
use Throwable;
use function sprintf;
class GeolocationDbUpdateFailedException extends RuntimeException implements ExceptionInterface
{
private bool $olderDbExists;
private function __construct(string $message, ?Throwable $previous = null)
{
parent::__construct($message, previous: $previous);
}
public static function withOlderDb(?Throwable $prev = null): self
{
$e = new self(
'An error occurred while updating geolocation database, but an older DB is already present.',
$prev,
);
$e->olderDbExists = true;
return $e;
}
public static function withoutOlderDb(?Throwable $prev = null): self
{
$e = new self(
'An error occurred while updating geolocation database, and an older version could not be found.',
$prev,
);
$e->olderDbExists = false;
return $e;
}
public static function withInvalidEpochInOldDb(mixed $buildEpoch): self
{
$e = new self(sprintf(
'Build epoch with value "%s" from existing geolocation database, could not be parsed to integer.',
$buildEpoch,
));
$e->olderDbExists = true;
return $e;
}
public function olderDbExists(): bool
{
return $this->olderDbExists;
}
}

View File

@ -1,135 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\GeoLite;
use Cake\Chronos\Chronos;
use Closure;
use GeoIp2\Database\Reader;
use MaxMind\Db\Reader\Metadata;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\Core\Config\Options\TrackingOptions;
use Shlinkio\Shlink\IpGeolocation\Exception\DbUpdateException;
use Shlinkio\Shlink\IpGeolocation\Exception\MissingLicenseException;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Symfony\Component\Lock\LockFactory;
use function is_int;
class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
{
private const LOCK_NAME = 'geolocation-db-update';
/** @var Closure(): Reader */
private readonly Closure $geoLiteDbReaderFactory;
/**
* @param callable(): Reader $geoLiteDbReaderFactory
*/
public function __construct(
private readonly DbUpdaterInterface $dbUpdater,
callable $geoLiteDbReaderFactory,
private readonly LockFactory $locker,
private readonly TrackingOptions $trackingOptions,
) {
$this->geoLiteDbReaderFactory = $geoLiteDbReaderFactory(...);
}
/**
* @throws GeolocationDbUpdateFailedException
*/
public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): GeolocationResult
{
if ($this->trackingOptions->disableTracking || $this->trackingOptions->disableIpTracking) {
return GeolocationResult::CHECK_SKIPPED;
}
$lock = $this->locker->createLock(self::LOCK_NAME);
$lock->acquire(true); // Block until lock is released
try {
return $this->downloadIfNeeded($beforeDownload, $handleProgress);
} finally {
$lock->release();
}
}
/**
* @throws GeolocationDbUpdateFailedException
*/
private function downloadIfNeeded(?callable $beforeDownload, ?callable $handleProgress): GeolocationResult
{
if (! $this->dbUpdater->databaseFileExists()) {
return $this->downloadNewDb(false, $beforeDownload, $handleProgress);
}
$meta = ($this->geoLiteDbReaderFactory)()->metadata();
if ($this->buildIsTooOld($meta)) {
return $this->downloadNewDb(true, $beforeDownload, $handleProgress);
}
return GeolocationResult::DB_IS_UP_TO_DATE;
}
private function buildIsTooOld(Metadata $meta): bool
{
$buildTimestamp = $this->resolveBuildTimestamp($meta);
$buildDate = Chronos::createFromTimestamp($buildTimestamp);
return Chronos::now()->greaterThan($buildDate->addDays(35));
}
private function resolveBuildTimestamp(Metadata $meta): int
{
// In theory the buildEpoch should be an int, but it has been reported to come as a string.
// See https://github.com/shlinkio/shlink/issues/1002 for context
/** @var int|string $buildEpoch */
$buildEpoch = $meta->buildEpoch;
if (is_int($buildEpoch)) {
return $buildEpoch;
}
$intBuildEpoch = (int) $buildEpoch;
if ($buildEpoch === (string) $intBuildEpoch) {
return $intBuildEpoch;
}
throw GeolocationDbUpdateFailedException::withInvalidEpochInOldDb($buildEpoch);
}
/**
* @throws GeolocationDbUpdateFailedException
*/
private function downloadNewDb(
bool $olderDbExists,
?callable $beforeDownload,
?callable $handleProgress,
): GeolocationResult {
if ($beforeDownload !== null) {
$beforeDownload($olderDbExists);
}
try {
$this->dbUpdater->downloadFreshCopy($this->wrapHandleProgressCallback($handleProgress, $olderDbExists));
return $olderDbExists ? GeolocationResult::DB_UPDATED : GeolocationResult::DB_CREATED;
} catch (MissingLicenseException) {
return GeolocationResult::LICENSE_MISSING;
} catch (DbUpdateException | WrongIpException $e) {
throw $olderDbExists
? GeolocationDbUpdateFailedException::withOlderDb($e)
: GeolocationDbUpdateFailedException::withoutOlderDb($e);
}
}
private function wrapHandleProgressCallback(?callable $handleProgress, bool $olderDbExists): ?callable
{
if ($handleProgress === null) {
return null;
}
return static fn (int $total, int $downloaded) => $handleProgress($total, $downloaded, $olderDbExists);
}
}

View File

@ -1,12 +0,0 @@
<?php
namespace Shlinkio\Shlink\CLI\GeoLite;
enum GeolocationResult
{
case CHECK_SKIPPED;
case LICENSE_MISSING;
case DB_CREATED;
case DB_UPDATED;
case DB_IS_UP_TO_DATE;
}

View File

@ -21,7 +21,7 @@ readonly class DateOption
$command->addOption($name, $shortcut, InputOption::VALUE_REQUIRED, $description);
}
public function get(InputInterface $input, OutputInterface $output): ?Chronos
public function get(InputInterface $input, OutputInterface $output): Chronos|null
{
$value = $input->getOption($this->name);
if (empty($value) || ! is_string($value)) {

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