Compare commits

..

303 Commits

Author SHA1 Message Date
Alejandro Celaya
750e6cff45 Merge pull request #1200 from shlinkio/develop
Release 2.9.0
2021-10-10 22:45:17 +02:00
Alejandro Celaya
f49e94052d Merge pull request #1199 from acelaya-forks/feature/address-based-tracking
Feature/address based tracking
2021-10-10 22:42:27 +02:00
Alejandro Celaya
ceb642b745 Updated to latest installer and changelog 2021-10-10 22:31:26 +02:00
Alejandro Celaya
ed1d886f01 Added option to disable tracking based on IP address patterns 2021-10-10 22:00:22 +02:00
Alejandro Celaya
db98d811b0 Merge pull request #1198 from acelaya-forks/feature/orphan-visits-webhook
Feature/orphan visits webhook
2021-10-09 13:08:05 +02:00
Alejandro Celaya
14ba11e1ab Enhanced changelog 2021-10-09 12:36:37 +02:00
Alejandro Celaya
483bdddb18 Updated to installer version with support for orphan visits webhooks 2021-10-09 12:35:45 +02:00
Alejandro Celaya
d16fda3f16 Added option to send orphan visits to webhooks 2021-10-09 10:53:21 +02:00
Alejandro Celaya
c718b94937 Fixed crash when notifying orphan visits to a webhook 2021-10-09 10:35:37 +02:00
Alejandro Celaya
bb21ab073f Merge pull request #1196 from acelaya-forks/feature/redis-sentinels
Feature/redis sentinels
2021-10-08 19:05:17 +02:00
Alejandro Celaya
3ffe530461 Updated changelog 2021-10-08 18:52:53 +02:00
Alejandro Celaya
95cf0d86bc Added support to provide redis sentinel when using redis cache 2021-10-08 18:52:17 +02:00
Alejandro Celaya
9899a5fc56 Merge pull request #1195 from acelaya-forks/feature/not-found-redirect-placeholders
Feature/not found redirect placeholders
2021-10-03 17:04:17 +02:00
Alejandro Celaya
952648185c Removed duplicated space 2021-10-03 16:48:39 +02:00
Alejandro Celaya
69740493b7 Updated changelog 2021-10-03 16:47:43 +02:00
Alejandro Celaya
994a28f31d Ensured NotFoundRedirectResolver replaces placeholders from the URL 2021-10-03 16:45:13 +02:00
Alejandro Celaya
b0a8a03f0a Refactored NotFoundRedirectResolver to remove duplicated lines and non-strict code 2021-10-03 10:35:35 +02:00
Alejandro Celaya
36e740f4cc Added logic to forward path and domain to not-found redirects when they contain placeholders 2021-10-02 17:30:25 +02:00
Alejandro Celaya
a5874a3f80 Merge pull request #1194 from acelaya-forks/feature/optinally-forward-query
Feature/optinally forward query
2021-10-02 10:56:48 +02:00
Alejandro Celaya
0c95b978b4 Added option in CLI to disable query forwarding when creating Short URLs 2021-10-02 10:45:00 +02:00
Alejandro Celaya
e21f9dd1fb Added forwardQuery prop to the SHortUrl serialization 2021-10-02 10:31:23 +02:00
Alejandro Celaya
74a08b86ce Estended ShortUrlRedirectionBuilderTest covering short URLS withput query forwarding 2021-10-02 10:16:56 +02:00
Alejandro Celaya
8212d3c540 Allowed to set and update the forwardQuery param on short URLs 2021-10-02 10:02:47 +02:00
Alejandro Celaya
1ed6458b39 Added forwardQuery property in short URLs, that determines if the query should be forwarded to the long URL 2021-10-02 09:32:04 +02:00
Alejandro Celaya
60c8f23a63 Merge pull request #1193 from acelaya-forks/feature/api-key-visits
Added extra DB tests ensuring proper short URL visits are resolved fr…
2021-10-01 19:59:30 +02:00
Alejandro Celaya
5e627641ea Added more tests ensuring any short URL can be fetched by using an admin API key 2021-10-01 19:32:34 +02:00
Alejandro Celaya
abc954aa47 Added extra DB tests ensuring proper short URL visits are resolved from an API key 2021-09-30 22:57:24 +02:00
Alejandro Celaya
3bfa27e682 Merge pull request #1191 from acelaya-forks/feature/default-qr-codes-config
Feature/default qr codes config
2021-09-26 20:39:09 +02:00
Alejandro Celaya
4b7e122254 Updated changelog 2021-09-26 20:15:00 +02:00
Alejandro Celaya
cfd3c13751 Updated to latest installer 2021-09-26 20:13:50 +02:00
Alejandro Celaya
6a1ee2b894 Added new config to set custom defaults for QR codes 2021-09-26 13:25:02 +02:00
Alejandro Celaya
cbec4a4e81 Moved constants to its own file inside config folder 2021-09-26 11:26:26 +02:00
Alejandro Celaya
c7d8c1cab5 Merge pull request #1189 from acelaya-forks/feature/roll-back-domain-redirects-logic
Reolled-back logic that would have made domains with no specific redi…
2021-09-26 11:22:58 +02:00
Alejandro Celaya
c39e1e649d Reolled-back logic that would have made domains with no specific redirects to not fall back to the default redirects 2021-09-26 11:10:00 +02:00
Alejandro Celaya
95ab64ba77 Merge pull request #1187 from acelaya-forks/feature/build-8.1
Feature/build 8.1
2021-09-26 10:43:55 +02:00
Alejandro Celaya
1f8fcdb0f3 Fixed typo in ci workflow 2021-09-26 10:20:09 +02:00
Alejandro Celaya
fb26a8ae50 Downgraded pdo_sqlsrv version for PHP 8.0 2021-09-26 10:19:26 +02:00
Alejandro Celaya
42dbeaa1a5 Updated MS native deps in swoole dev container 2021-09-26 10:06:35 +02:00
Alejandro Celaya
3305f4c03a Updated pdo_sqlsrv version used in CI workflow 2021-09-26 10:04:50 +02:00
Alejandro Celaya
f5beec70c8 Updated MS native deps 2021-09-26 10:03:07 +02:00
Alejandro Celaya
c2cd21c15e Updated swoole version used in CI workflow 2021-09-26 09:53:58 +02:00
Alejandro Celaya
633e389275 Updated changelog 2021-09-26 09:50:35 +02:00
Alejandro Celaya
f5aaf298e1 Added experimental builds under PHP 8.1 2021-09-26 09:49:51 +02:00
Alejandro Celaya
7db6136436 Simplified how the not-found redirects are resolved 2021-09-26 09:40:24 +02:00
Alejandro Celaya
ce7296eebb Merge pull request #1186 from acelaya-forks/feature/deprecate-domain-env-vars
Feature/deprecate domain env vars
2021-09-26 09:23:01 +02:00
Alejandro Celaya
c6226547f7 Updated changelog 2021-09-26 09:12:26 +02:00
Alejandro Celaya
e7ec8f0489 Deprecated SHORT_DOMAIN_* env vars with replacements 2021-09-26 09:10:54 +02:00
Alejandro Celaya
dc466f238b Updated changelog 2021-09-12 08:32:24 +02:00
Alejandro Celaya
f164656874 Merge pull request #1172 from NReilingh/patch-1
Slight misuse of VOLUME in Dockerfile
2021-09-12 08:30:28 +02:00
Nick Reilingh
ef3c59152f Dockerfile -- remove unneeded VOLUME instructions 2021-09-11 16:40:09 -04:00
Nick Reilingh
14c6ead389 Dockerfile - comment misused VOLUME instructions
Issuing a VOLUME instruction in a production Dockerfile requires the Docker engine to create a volume whether or not it is mapped to the host or a named volume. Neither of these paths have data that needs to be persisted for production use, so their inclusion under a typical `docker run` example forces the engine to create extraneous volumes which quickly become orphaned whenever the container is recreated.
2021-09-11 13:46:52 -04:00
Alejandro Celaya
b0d33f3a85 Merge pull request #1166 from acelaya-forks/feature/fix-undefined-var
Feature/fix undefined var
2021-08-26 10:06:33 +02:00
Alejandro Celaya
066cc20ee6 Updated changelog 2021-08-26 09:53:10 +02:00
Alejandro Celaya
0f51b5b1ce Fixed warning displayed when trying to late visits and there are no pending 2021-08-26 09:52:11 +02:00
Alejandro Celaya
ebcf3e0119 Merge pull request #1158 from acelaya-forks/feature/global-cors
Feature/global cors
2021-08-16 13:02:18 +02:00
Alejandro Celaya
6ee248d656 Updated changelog 2021-08-16 12:50:18 +02:00
Alejandro Celaya
8a46b410f6 Ensured Cors middleware applies for all routes, not only rest ones 2021-08-16 12:49:15 +02:00
Alejandro Celaya
cd06cea153 Fixed merge conflicts 2021-08-15 19:32:27 +02:00
Alejandro Celaya
8393d44c50 Merge pull request #1156 from acelaya-forks/feature/query-num-keys
Fixed numeric query params being replaced by 0 in long URLs
2021-08-15 19:25:13 +02:00
Alejandro Celaya
3e8ce80f80 Fixed numeric query params being replaced by 0 in long URLs 2021-08-15 19:13:26 +02:00
Alejandro Celaya
80e033c91d Fixed local dev config for db 2021-08-14 19:23:08 +02:00
Alejandro Celaya
a7dd441333 Added missing double quote. Closes #1151 2021-08-09 22:16:12 +02:00
Alejandro Celaya
48efaa9fd7 Merge pull request #1150 from acelaya-forks/feature/env-config
Feature/env config
2021-08-07 14:13:26 +02:00
Alejandro Celaya
92e831175f Ensure no DB driver config falls back to SQLite 2021-08-07 13:32:59 +02:00
Alejandro Celaya
9b75e076b5 Updated changelog 2021-08-07 11:08:52 +02:00
Alejandro Celaya
2c5d6d1651 Moved env vars to common global config files, so that theycan be used in non-docker contexts too 2021-08-07 11:05:20 +02:00
Alejandro Celaya
c5cf116f33 Fixed changelog message 2021-08-06 19:59:35 +02:00
Alejandro Celaya
66a4a9bce6 Moved bugfix from Unreleased to v2.8.0, as it's already fixed there 2021-08-06 13:57:39 +02:00
Alejandro Celaya
7e7ef64c79 Merge pull request #1146 from acelaya-forks/feature/coding-standard
Updated to coding standard v2.2.0
2021-08-05 19:58:34 +02:00
Alejandro Celaya
9a31f53d4d Updated to coding standard v2.2.0 2021-08-05 19:47:17 +02:00
Alejandro Celaya
60d6314262 Merge pull request #1145 from acelaya-forks/feature/update-cache
Feature/update cache
2021-08-05 17:07:41 +02:00
Alejandro Celaya
eff7445804 Updated changelog 2021-08-05 16:50:50 +02:00
Alejandro Celaya
2bfe21aef4 Documented architectural decission on what component to pick for caching 2021-08-05 16:47:30 +02:00
Alejandro Celaya
6ae0c7dcfc Updated to latest common with symfony/cache support 2021-08-05 14:05:44 +02:00
Alejandro Celaya
883ac1007a Updated to provisional hero-common v4.0 2021-08-04 18:46:19 +02:00
Alejandro Celaya
ff6747dab5 Merge pull request #1143 from shlinkio/develop
Release 2.8.0
2021-08-04 15:43:59 +02:00
Alejandro Celaya
555e6f804c Merge pull request #1141 from acelaya-forks/feature/update-deps
Feature/update deps
2021-08-04 15:36:28 +02:00
Alejandro Celaya
98c5c7990f Updated changelog 2021-08-04 13:29:33 +02:00
Alejandro Celaya
27dcdb517d Updated dockerfile dependencies 2021-08-04 13:28:14 +02:00
Alejandro Celaya
916d75d161 Updated project dependencies 2021-08-04 13:22:16 +02:00
Alejandro Celaya
57bd16f4f5 Updated test utils lib to v2.2 2021-08-04 11:11:00 +02:00
Alejandro Celaya
444a1756a2 Merge pull request #1140 from acelaya-forks/feature/domain-redirects-endpoint
Feature/domain redirects endpoint
2021-08-03 19:59:54 +02:00
Alejandro Celaya
0c97c8f04f Updated changelog 2021-08-03 19:47:44 +02:00
Alejandro Celaya
de81e81ecb Created API test for Domain redirects 2021-08-03 19:43:30 +02:00
Alejandro Celaya
40a7d5a112 Documented error when trying to edit default domain redirects through endpoint 2021-08-03 18:33:50 +02:00
Alejandro Celaya
7c06633a67 Ensured default domain redirects cannot be edited through regular approach 2021-08-03 18:28:09 +02:00
Alejandro Celaya
9abf611d63 Created DomainResirectsAction unit test 2021-08-03 18:09:39 +02:00
Alejandro Celaya
565fe4c348 Added redirects to the list of domains 2021-08-03 17:00:26 +02:00
Alejandro Celaya
7b43403b1c Fixed error when editing domain redirects for a new domain 2021-08-03 16:48:17 +02:00
Alejandro Celaya
9f25979b4c Added validation to not found redirects for domain 2021-08-03 14:08:36 +02:00
Alejandro Celaya
20f70b8b07 Created new table with row separators for CLI, to use with multi-line rows 2021-08-03 10:21:42 +02:00
Alejandro Celaya
8fbf05acd4 Added deprecated keyword to ensure something is changed for v3.0.0 2021-08-03 10:02:44 +02:00
Alejandro Celaya
6860855c71 Prevent double flush when editing domain redirects 2021-08-03 09:55:21 +02:00
Alejandro Celaya
b78660c685 Updated installer 2021-08-02 20:50:35 +02:00
Alejandro Celaya
6a40bbdcb5 Created new action to set redirects for a domain 2021-08-02 20:49:39 +02:00
Alejandro Celaya
5a1a4f5594 Added support to configure domain redirects but taking into consideration the permissions on an API key 2021-08-02 20:49:39 +02:00
Alejandro Celaya
2ac7be4363 Extended DomainNotFoundException to allow creating from an authority 2021-08-02 20:49:39 +02:00
Alejandro Celaya
4ef5ab7a90 Fixed wrong domains getting resolved for an API key roles 2021-08-02 20:49:39 +02:00
Alejandro Celaya
192308a6a3 Added swagger docs for endpoint do edit domain redirects 2021-08-02 20:49:39 +02:00
Alejandro Celaya
c9ce111643 Fixed merge conflicts 2021-08-02 20:39:33 +02:00
Alejandro Celaya
32fda231ad Merge pull request #1138 from acelaya-forks/feature/fix-import-with-no-visits
Feature/fix import with no visits
2021-08-02 20:34:06 +02:00
Alejandro Celaya
e4d4686717 Ensure visits lists where the page is lower than 1, fall back to page 1 to avoid errors 2021-08-02 20:22:07 +02:00
Alejandro Celaya
ca6c6a1b6e Updated importer to v2.3.1 2021-08-02 18:29:16 +02:00
Alejandro Celaya
806c4ce168 Merge pull request #1134 from acelaya-forks/feature/infection24
Feature/infection24
2021-08-01 10:11:53 +02:00
Alejandro Celaya
9d14597be0 Added --only-covering-test-cases flag when running infection commands 2021-08-01 10:00:24 +02:00
Alejandro Celaya
dc68bb907c Updated infection to v0.24 2021-08-01 09:57:34 +02:00
Alejandro Celaya
e4598c058a Merge pull request #1133 from acelaya-forks/feature/docker-cron-permissions
Disabled user change on Dockerfile, as it produces some issues
2021-08-01 09:11:06 +02:00
Alejandro Celaya
377562cdff Disabled user change on Dockerfile, as it produces some issues 2021-08-01 08:55:39 +02:00
Alejandro Celaya
969fcccc1f Merge pull request #1131 from acelaya-forks/feature/clean-workarounds-from-fix
Removed hardcoded dependency
2021-07-30 18:54:45 +02:00
Alejandro Celaya
4c00764146 Removed hardcoded dependency 2021-07-30 18:40:26 +02:00
Alejandro Celaya
e98ee64695 Merge branch 'main' into develop 2021-07-30 18:25:48 +02:00
Alejandro Celaya
51c7d0ed3e Removed deprecated env var for publish release 2021-07-30 18:25:00 +02:00
Alejandro Celaya
db93498ee6 Fixed merge conflicts 2021-07-30 18:19:32 +02:00
Alejandro Celaya
b3af493758 Merge pull request #1130 from acelaya-forks/feature/docker-memory-limit
Fixed memory too low limit on docker image
2021-07-30 18:16:40 +02:00
Alejandro Celaya
7b9ebbbb5f Fixed use of ImplicitOptionsMiddleware with its new signature 2021-07-30 18:05:03 +02:00
Alejandro Celaya
ea735fc0a0 Ensured guzzle/psr7 1.7 is used as the project still has deprecated calls 2021-07-30 17:48:43 +02:00
Alejandro Celaya
06227e97d0 Fixed memory too low limit on docker image 2021-07-30 17:39:45 +02:00
Alejandro Celaya
dbc50b6d4f Merge pull request #1124 from acelaya-forks/feature/domain-specific-redirects
Feature/domain specific redirects
2021-07-23 18:59:24 +02:00
Alejandro Celaya
8b75ad1e7f Covered detached domains with redirects in domains list API test 2021-07-23 13:11:09 +02:00
Alejandro Celaya
8f3c740b57 Ensured domains not used in short URLs but with redirects configured are returned in domains list 2021-07-23 13:06:03 +02:00
Alejandro Celaya
24a6a0c23f Added test for DomainRedirectCommand 2021-07-22 20:48:58 +02:00
Alejandro Celaya
267d72a76c Improved unit tests covering new not found redirects for domains capability 2021-07-22 17:49:37 +02:00
Alejandro Celaya
021cecc216 Created command that allows configuring not found redirects for every domain 2021-07-21 21:09:33 +02:00
Alejandro Celaya
4642480bbb Updated changelog 2021-07-21 09:41:58 +02:00
Alejandro Celaya
4d48482d1e Added support to define differnet not-found redirects per domain 2021-07-21 09:28:21 +02:00
Alejandro Celaya
2054784a4a Merge pull request #1123 from acelaya-forks/feature/match-in-db-tests
Replaced map with match
2021-07-20 14:04:19 +02:00
Alejandro Celaya
57d816b862 Replaced map with match 2021-07-20 14:03:19 +02:00
Alejandro Celaya
32bb66c42b Merge pull request #1122 from acelaya-forks/feature/phpstan-level
Feature/phpstan level
2021-07-20 14:01:45 +02:00
Alejandro Celaya
e4d15e64b6 Ensured static analysis is run with APP_ENV=test 2021-07-20 13:50:14 +02:00
Alejandro Celaya
b11daeae7d Fixed version constraint in composer.json 2021-07-20 13:41:55 +02:00
Alejandro Celaya
8e78f8527e Updated changelog 2021-07-20 13:37:00 +02:00
Alejandro Celaya
bc385744db Temporarely ignored some phpstan errors until a custom rule is defined 2021-07-20 13:36:09 +02:00
Alejandro Celaya
02fd28edec Installed phpstan-dcotrine and fixed more static analysis errors 2021-07-20 13:29:50 +02:00
Alejandro Celaya
95770ac104 Increased phpstan level to 8 2021-07-20 12:51:07 +02:00
Alejandro Celaya
2eeb762cd9 Moved specific phpstan ignore to their own lines 2021-07-19 22:50:32 +02:00
Alejandro Celaya
de5666d262 Resolved all phpstan errors 2021-07-19 22:47:12 +02:00
Alejandro Celaya
934d266880 Added phpstan-symfony plugin to improve inspections on getArgument and getOption 2021-07-19 20:00:53 +02:00
Alejandro Celaya
b8fa234dbb Fixed some phpstan errors 2021-07-19 18:35:42 +02:00
Alejandro Celaya
bceea090ed Increaed phpstan level to 7 2021-07-17 20:58:24 +02:00
Alejandro Celaya
8efda2ef56 Merge pull request #1108 from kanadaj/develop
Change the Docker user to non-root
2021-07-15 20:19:42 +02:00
Alejandro Celaya
f86cda6730 Removed deprecated env var for publish release 2021-07-15 19:53:42 +02:00
Alejandro Celaya
43f59a19fb Merge pull request #1120 from acelaya-forks/feature/redirect-with-extra-path
Feature/redirect with extra path
2021-07-15 19:48:16 +02:00
Alejandro Celaya
eabaa94e06 Created ExtraPathRedirectMiddleware test 2021-07-15 19:37:09 +02:00
Alejandro Celaya
20575a2b0f Added support to provide append_extra_path config from installer or env vars for docker 2021-07-15 18:57:32 +02:00
Alejandro Celaya
0096a778ac Created RequestTracker test 2021-07-15 17:43:29 +02:00
Alejandro Celaya
050f83e3bb Wrapped logic to track requests to a new RequestTracker service 2021-07-15 17:23:09 +02:00
Alejandro Celaya
32f7b4fbf6 Created new middleware that redirects to short URLs with an extra path 2021-07-15 16:54:54 +02:00
Alejandro Celaya
265e8cdeaf Refactored tracking actions 2021-07-15 13:28:31 +02:00
Alejandro Celaya
fe5460e0c5 Created ShortUrlRedirectBuilder test 2021-07-14 16:44:21 +02:00
Alejandro Celaya
d4cad337fc Created component wrapping the logic to determine what's the URL to redirect to for a ShortUrl 2021-07-14 16:36:03 +02:00
Alejandro Celaya
0af6ecbd34 Merge pull request #1115 from acelaya-forks/feature/qr-code-correction
Feature/qr code correction
2021-07-13 14:13:34 +02:00
Alejandro Celaya
6466045363 Updated changelog 2021-07-13 14:00:54 +02:00
Alejandro Celaya
67c7e503d9 Used lowercase values when trying to match the QR code error level 2021-07-13 13:55:00 +02:00
Alejandro Celaya
01e06f0503 Improved swagger docs for QR code endpoint 2021-07-13 13:53:10 +02:00
Alejandro Celaya
d6e155d874 Extracted logic to determine QR code params to its own data object 2021-07-13 13:46:01 +02:00
Alejandro Celaya
5a2350bac1 Added suport for error correction level to QR codes 2021-07-13 13:22:50 +02:00
kanadaj
2b97f9ac9e Update Dockerfile
Security update
2021-06-13 23:54:35 +01:00
kanadaj
090b215179 Update Dockerfile 2021-06-13 23:51:16 +01:00
Alejandro Celaya
32f483c333 Merge pull request #1107 from PxSonny/patch-1
Update CONTRIBUTING.md
2021-06-13 21:21:44 +02:00
Sonny Alves Dias
655652f94f Update CONTRIBUTING.md
Fixing a typo
2021-06-13 22:24:20 +08:00
Alejandro Celaya
53b84c147c Merge branch 'develop' of github.com:shlinkio/shlink into develop 2021-05-30 17:55:37 +02:00
Alejandro Celaya
d8b4827601 Updated changelog 2021-05-30 17:55:30 +02:00
Alejandro Celaya
5737acf759 Merge pull request #1099 from mikafouenski/develop
Run periodic `visit:locate` as opt-in
2021-05-30 17:55:13 +02:00
Alejandro Celaya
58262e8604 Update docker/docker-entrypoint.sh 2021-05-30 17:41:40 +02:00
Alejandro Celaya
b9e5eaf689 Update docker/docker-entrypoint.sh 2021-05-30 17:41:00 +02:00
Alejandro Celaya
6d78cd59e9 Fixed merge conflicts 2021-05-30 13:31:37 +02:00
Alejandro Celaya
aa00e33b6d Added v2.7.1 to changelog 2021-05-30 13:25:37 +02:00
Alejandro Celaya
4ef04c641e Merge pull request #1101 from acelaya-forks/feature/disable-geolite-download
Feature/disable geolite download
2021-05-30 13:02:30 +02:00
Alejandro Celaya
bfcccd8c33 Added test to check for GeoLite db update disabling based on tracking options 2021-05-30 12:36:58 +02:00
Alejandro Celaya
f7d3c73c4a Skip downloading GeoLite db if global tracking or IP tracking are disabled 2021-05-30 12:30:03 +02:00
Mickaël Bernardini
bfdece1c23 add ENABLE_PERIODIC_VISIT_LOCATE opt-in
This will trigger `visit:locate` every hour
2021-05-26 15:45:24 +02:00
Alejandro Celaya
a68f450d36 Merge pull request #1097 from acelaya-forks/feature/php8
Feature/php8
2021-05-23 12:54:12 +02:00
Alejandro Celaya
d1df225e47 Moved changelog line 2021-05-23 12:39:00 +02:00
Alejandro Celaya
9c6ba4bc61 More PHP 8 syntactic sugar 2021-05-23 12:37:53 +02:00
Alejandro Celaya
c01121d61a Added nullsafe operator to simplify conditions 2021-05-23 12:31:10 +02:00
Alejandro Celaya
e0f0bb5523 Migrated all constructor props to property promotion when possible 2021-05-23 11:57:31 +02:00
Alejandro Celaya
a3b7742992 Merge pull request #1096 from shlinkio/develop
Release 2.7.0
2021-05-23 09:31:01 +02:00
Alejandro Celaya
4b5fa6ddad Merge pull request #1095 from acelaya-forks/feature/update-docker-deps
Updated docker dependencies
2021-05-23 09:16:54 +02:00
Alejandro Celaya
b6aca82da6 Updated docker dependencies 2021-05-23 09:05:35 +02:00
Alejandro Celaya
8ee3bb4d58 Merge pull request #1094 from acelaya-forks/feature/improve-locks
Feature/improve locks
2021-05-23 08:54:10 +02:00
Alejandro Celaya
46bea241e6 Updated changelog 2021-05-23 08:42:26 +02:00
Alejandro Celaya
5e6d2881bc Used ShorturlIdentifier model whenever possible 2021-05-23 08:41:42 +02:00
Alejandro Celaya
cd19876419 Removed methods to create tags and domains with lock, as they do not really lock as expected 2021-05-23 08:21:40 +02:00
Alejandro Celaya
f82e103bc5 Added locks to tag and domain creation during short URL creation 2021-05-22 22:06:40 +02:00
Alejandro Celaya
3ff4ac84c4 Added locking to short URL creation when checking if URL exists 2021-05-22 22:06:40 +02:00
Alejandro Celaya
bf0c679a48 Added real versions from some shlink dependencies 2021-05-22 22:06:08 +02:00
Alejandro Celaya
96c5bc164a Merge pull request #1093 from acelaya-forks/feature/improve-mutation-ci
Split execution of db and unit mutation tests during ci workflow
2021-05-22 21:50:59 +02:00
Alejandro Celaya
73aead01b4 Split execution of db and unit mutation tests during ci workflow 2021-05-22 21:35:32 +02:00
Alejandro Celaya
e19b3cc45d Merge pull request #1092 from acelaya-forks/feature/bot-detection
Feature/bot detection
2021-05-22 21:34:59 +02:00
Alejandro Celaya
a1cab4ca7d Fixed typos 2021-05-22 21:22:15 +02:00
Alejandro Celaya
4b89687e45 Updated changelog 2021-05-22 21:17:00 +02:00
Alejandro Celaya
1c861fecfc Documented the excludeBots query param for visits endpoints 2021-05-22 21:14:15 +02:00
Alejandro Celaya
a12c9f54c4 Added API tests covering the excludion of bot visits 2021-05-22 21:05:54 +02:00
Alejandro Celaya
69d72e754f Added logic to exclude bots from visits when requested 2021-05-22 20:49:24 +02:00
Alejandro Celaya
db3c5a3031 Added new models to pass to repositories when listing visits of any kind 2021-05-22 20:32:30 +02:00
Alejandro Celaya
6327ed814a Added new models to pass to repositories when counting visits of any kind 2021-05-22 20:16:32 +02:00
Alejandro Celaya
9fa32b5b6b Added detection of visits from potential bots 2021-05-22 15:09:14 +02:00
Alejandro Celaya
663ae9f6bb Merge pull request #1091 from acelaya-forks/feature/improved-crawling
Feature/improved crawling
2021-05-22 11:48:55 +02:00
Alejandro Celaya
70c73bc5d6 Removed no-longer valid false positive for static analysis 2021-05-22 10:08:33 +02:00
Alejandro Celaya
05d73552cf Used PHP 8.0 in ci workflow when running against just one PHP version 2021-05-22 09:49:24 +02:00
Alejandro Celaya
70384237c1 Updated changelog 2021-05-22 09:42:24 +02:00
Alejandro Celaya
36e4a0dd32 Added tests for findCrawlableShortCodes 2021-05-22 09:41:12 +02:00
Alejandro Celaya
3ef02d46c0 Added logic to resolve crawlable short codes 2021-05-22 09:34:42 +02:00
Alejandro Celaya
e6ce84aa14 Added more missing API spec docs 2021-05-22 07:40:21 +02:00
Alejandro Celaya
348e34d52e Added new crawlable flag to Short URLs 2021-05-22 07:35:47 +02:00
Alejandro Celaya
2e6b3c0561 Documented crawlable prop in API specs 2021-05-22 07:32:47 +02:00
Alejandro Celaya
7280b48cdc Created action to dynamically build the robots.txt 2021-05-22 07:15:34 +02:00
Alejandro Celaya
2803f65479 Merge pull request #1087 from acelaya-forks/feature/granular-tracking
Feature/granular tracking
2021-05-16 13:26:52 +02:00
Alejandro Celaya
3535688c3b Updated to latest installer with support for all tracking options 2021-05-16 13:21:12 +02:00
Alejandro Celaya
9cff570c45 Updated changelog 2021-05-16 10:12:35 +02:00
Alejandro Celaya
15c028e151 Ensured visitor is normalized before creating the visit 2021-05-16 10:08:05 +02:00
Alejandro Celaya
f0dc32b6e5 Added logic for new tracking options 2021-05-16 09:53:44 +02:00
Alejandro Celaya
d423d18249 Defined new structure for tracking config, together with new options 2021-05-16 09:30:15 +02:00
Alejandro Celaya
8a8e3c3fc8 Merge pull request #1076 from acelaya-forks/feature/import-from-shlink
Feature/import from shlink
2021-04-18 17:43:48 +02:00
Alejandro Celaya
111fc3c37d Updated changelog 2021-04-18 17:09:06 +02:00
Alejandro Celaya
e9a5284dde Encapsulated logic to get rid of nested ifs 2021-04-18 17:07:56 +02:00
Alejandro Celaya
b277f431c2 Added test covering imported short URLs with visits 2021-04-18 12:44:02 +02:00
Alejandro Celaya
c8b8947b1f Allowed to import visits to existing already imported short URLs 2021-04-18 11:58:59 +02:00
Alejandro Celaya
9a78d1585d Ensured only pending visits are imported when processing a short URL which already has imported visits 2021-04-11 20:00:08 +02:00
Alejandro Celaya
09414a8834 Allowed to optionally import visits from other shlink instance 2021-04-11 13:30:12 +02:00
Alejandro Celaya
1efa973507 Updated ImportedLinksProcessor to support importing visits if provided 2021-04-11 11:44:10 +02:00
Alejandro Celaya
e23cd6a856 Removed old MySQL connection options 2021-04-11 11:44:10 +02:00
Alejandro Celaya
743bb7a6ee Updated ShortUrl importing to take metadata into account 2021-04-11 11:44:10 +02:00
Alejandro Celaya
086efe3c63 Merge pull request #1064 from KetchupBomb/develop
Feature/show API key info in short-url CLI
2021-04-11 11:42:51 +02:00
Alejandro Celaya
d751df70fd Updated changelog 2021-04-11 11:30:43 +02:00
Alejandro Celaya
334d95c843 Improved test covering ListSHortUrlsCommand with optional tags 2021-04-11 11:29:42 +02:00
Alejandro Celaya
5ddac7866b Ensured short URL transformation happens only once per short URL when listing from CLI 2021-04-11 11:06:29 +02:00
Alejandro Celaya
a896fbbb90 Fixed coding styles 2021-04-11 10:50:35 +02:00
Alejandro Celaya
a478699fe8 Merge pull request #1068 from acelaya-forks/feature/dependency-persistence
Feature/dependency persistence
2021-04-10 12:15:31 +02:00
Alejandro Celaya
6387e50276 Updated changelog 2021-04-10 12:03:40 +02:00
Alejandro Celaya
28c06de685 Fixed issue when trying to persist several short URLs which include the same new tag/domain at once 2021-04-10 11:59:43 +02:00
Alejandro Celaya
823573cea7 Updated PersistenceShortUrlRelationResolver to prevent duplicated tags 2021-04-10 10:16:09 +02:00
KetchupBomb
5d0f306bcc Feature/show API key info in short-url CLI 2021-04-10 07:10:22 +00:00
Alejandro Celaya
f30e922074 Merge pull request #1065 from acelaya-forks/feature/split-db-update-and-location
Feature/split db update and location
2021-04-08 17:13:29 +02:00
Alejandro Celaya
96ff0bffda Updated changelog 2021-04-08 17:00:57 +02:00
Alejandro Celaya
d9b675fc8b Updated to an installer version with support to download the GeoLite db file 2021-04-08 16:56:55 +02:00
Alejandro Celaya
104b7390da Updated docker entry point so that it tries to download the GeoLite2 db file when the license key was provided 2021-04-08 14:32:19 +02:00
Alejandro Celaya
7b4456e73f Ensured events triggered as a result of a new visit are never skipped 2021-04-08 14:09:26 +02:00
Alejandro Celaya
86230d9bf3 Removed duplicated code during CLI command tests 2021-04-08 13:44:24 +02:00
Alejandro Celaya
1f8994ca8b Created DownloadGeoLiteDbCommandTest 2021-04-08 13:34:14 +02:00
Alejandro Celaya
f7b6f4ba19 Created new command containing the logic to download the GeoLite2 db file 2021-04-08 13:12:37 +02:00
Alejandro Celaya
74ea5969be Created new listener to update the GeoLite db after a visit occurs 2021-04-07 16:29:29 +02:00
Alejandro Celaya
c4718e7523 Extended error handling on LocateVisit handler 2021-04-07 12:53:53 +02:00
Alejandro Celaya
5de706e0fe Fixed LocateVisitTest 2021-04-07 11:52:50 +02:00
Alejandro Celaya
77d06b4b03 Renamed argument to have a more clear intention 2021-04-07 11:48:01 +02:00
Alejandro Celaya
b4d137375a Flipped events triggered when locating a visit, so that geolocation is done synchronously 2021-04-07 11:35:02 +02:00
Alejandro Celaya
0621ae7735 Ensured visits tracking is run transactionally together with the event dispatched afterwards 2021-04-07 11:33:23 +02:00
Alejandro Celaya
3a6a1f25a7 Updated to latest doctrine/orm without security issues 2021-04-06 17:34:31 +02:00
Alejandro Celaya
731dc64f44 Merge pull request #1061 from acelaya-forks/feature/update-mercure
Updated to symfony/mercure 0.5
2021-04-02 09:59:31 +02:00
Alejandro Celaya
d72b9cf646 Updated to symfony/mercure 0.5 2021-04-02 09:46:02 +02:00
Alejandro Celaya
0f0c4dc549 Fixed comment 2021-03-30 18:18:38 +02:00
Alejandro Celaya
ea0820d881 Merge pull request #1060 from LeagueRaINi/patch-1
Create volume for /etc/shlink/data
2021-03-30 18:11:50 +02:00
RaINi_
312f20d2f1 Create volume for /etc/shlink/data
Makes it so shlink can be used as a docker service without losing ur data every time
2021-03-29 12:46:45 +02:00
Alejandro Celaya
f8289fa4be Merge pull request #1054 from acelaya-forks/feature/migrations-fix
Updated to latest migrations patch and removed workaround
2021-03-14 12:45:23 +01:00
Alejandro Celaya
554209d644 Updated to latest migrations patch and removed workaround 2021-03-14 12:28:30 +01:00
Alejandro Celaya
4ce44034cb Ensured API key name appears in the proper color in the console, for disabled or expired API keys 2021-03-14 10:20:05 +01:00
Alejandro Celaya
221b62ea57 Merge pull request #1053 from acelaya-forks/feature/api-key-improvements
Feature/api key improvements
2021-03-14 10:14:45 +01:00
Alejandro Celaya
0a5c265b12 Extracted ApiKey metadata to the ApiKeyMeta object 2021-03-14 09:59:35 +01:00
Alejandro Celaya
9b55389538 First steps to create ApiKeyMeta 2021-03-14 09:15:52 +01:00
Alejandro Celaya
60a8d6e986 Merge pull request #1052 from acelaya-forks/feature/api-test-logs
Feature/api test logs
2021-03-14 09:15:10 +01:00
Alejandro Celaya
d7523bcb57 Reduced duplication by creating a function that builds test logger config 2021-03-14 09:01:11 +01:00
Alejandro Celaya
562110fac4 Updated changelog 2021-03-14 08:55:39 +01:00
Alejandro Celaya
d104265f04 Updated CONTRIBUTING file, explaining how the logs are dumped during API tests 2021-03-14 08:54:05 +01:00
Alejandro Celaya
4439685403 Fixed logs generated by shlink during API tests 2021-03-14 08:49:38 +01:00
Alejandro Celaya
9feb72235a Added config to log in filesystem while running API tests 2021-03-14 08:33:00 +01:00
Alejandro Celaya
c372a498cc Fixed merge conflicts 2021-03-12 16:34:00 +01:00
Alejandro Celaya
be35349350 Fixed typo 2021-03-12 16:22:53 +01:00
Alejandro Celaya
771fd74978 Merge pull request #1049 from acelaya-forks/feature/mysql-migrations-error
Feature/mysql migrations error
2021-03-12 16:22:13 +01:00
Alejandro Celaya
3ba9ee7bf1 Updated changelog 2021-03-12 11:56:41 +01:00
Alejandro Celaya
a0062a62e8 Ensured all migrations are non-transactional, which allows woring around an issue in doctrine-migrations 2021-03-12 11:52:43 +01:00
Alejandro Celaya
0f2bd77ebc Fixed dependencies pinned to older versions 2021-03-12 08:54:23 +01:00
Alejandro Celaya
744b368cc1 Updated changelog 2021-03-08 19:50:43 +01:00
Alejandro Celaya
a03c4519c9 Updated CONTRIBUTING doc 2021-03-08 07:00:25 +01:00
Alejandro Celaya
66327881d5 Merge pull request #1045 from KetchupBomb/develop
Feature/name api keys
2021-03-07 23:14:53 +01:00
KetchupBomb
b93b14986e Feature/name api keys 2021-03-07 21:30:37 +00:00
Alejandro Celaya
1ade4e9917 Updated CONTRIBUTING doc mentioning API tests are run using Postgres 2021-03-07 09:22:04 +01:00
Alejandro Celaya
65f2ab6720 Changed approach to ensure the default value for the version while building the docker image is latest 2021-03-01 21:17:32 +01:00
Alejandro Celaya
7d38ba12bd Merge pull request #1042 from acelaya-forks/feature/fix-latest-docker-version
Ensured latest docker image is built with SHLINK_VERSION=latest
2021-03-01 17:21:30 +01:00
Alejandro Celaya
8128e85b6b Ensured latest docker image is built with SHLINK_VERSION=latest 2021-03-01 17:04:16 +01:00
Alejandro Celaya
3d99819be4 Merge pull request #1040 from acelaya-forks/feature/endroid-4.0
Feature/endroid 4.0
2021-02-28 16:56:11 +01:00
Alejandro Celaya
a2ca1618ea Updated changelog 2021-02-28 16:42:25 +01:00
Alejandro Celaya
b244c56862 Updated to endroid/qr-code 4 2021-02-28 16:41:52 +01:00
Alejandro Celaya
c931874bac Merge pull request #1038 from acelaya-forks/feature/happyr-spec-2
Feature/happyr spec 2
2021-02-26 20:47:29 +01:00
Alejandro Celaya
1b168ac3d2 Updated changelog 2021-02-26 20:34:04 +01:00
Alejandro Celaya
0fc123b249 Fixed coding styles 2021-02-26 20:28:41 +01:00
Alejandro Celaya
c622804950 Fixed unit tests 2021-02-26 20:27:41 +01:00
Alejandro Celaya
e093480a5b Fixed API tests 2021-02-26 20:24:57 +01:00
Alejandro Celaya
1498b72966 Updated to happyr/doctrine-specification 2, with some fixes 2021-02-26 20:01:16 +01:00
Alejandro Celaya
51e1c7cd50 Merge pull request #1035 from shlinkio/develop
Release 2.6.1
2021-02-22 22:18:02 +01:00
Alejandro Celaya
40040b627f Added v2.6.1 to changelog 2021-02-22 22:02:45 +01:00
Alejandro Celaya
b752f8a357 Updated to latest mezzio-swoole to fix warning when stopping shlink with swoole 2021-02-20 11:26:42 +01:00
Alejandro Celaya
5b93cf42b1 Merge pull request #1032 from acelaya-forks/feature/twitter-validation
Feature/twitter validation
2021-02-18 21:47:43 +01:00
Alejandro Celaya
fa8145df9f Updated changelog 2021-02-18 21:35:11 +01:00
Alejandro Celaya
5ddb6a7f99 Added e2e tests covering shortening of twitter URLs with url validatio enabled 2021-02-18 21:33:30 +01:00
Alejandro Celaya
8ad34357d3 Added User-Agent to UrlValidator, so that remote servers don't consider Shlink a bot 2021-02-18 21:27:46 +01:00
Alejandro Celaya
81eb2684bf Merge pull request #1027 from acelaya-forks/feature/remove-non-inclusive-terms
Feature/remove non inclusive terms
2021-02-16 17:31:37 +01:00
Alejandro Celaya
d2c0745efa Updated changelog 2021-02-16 15:32:11 +01:00
Alejandro Celaya
3f2d38a86a Removed all uses of the 'whitelist' term 2021-02-16 15:28:03 +01:00
Alejandro Celaya
4df4db05f4 Merge pull request #1025 from acelaya-forks/feature/wrong-skip-migration
Feature/wrong skip migration
2021-02-15 22:51:45 +01:00
Alejandro Celaya
6526fda960 Updated changelog 2021-02-15 22:22:07 +01:00
Alejandro Celaya
32fdb257a3 Fixed migration that could be incorrectly skipped due to wrong condition being used 2021-02-15 22:16:58 +01:00
Alejandro Celaya
9247cd874e Fixed wrong indentation in changelog 2021-02-14 08:30:17 +01:00
Alejandro Celaya
4ceb42b88d Small readme improvement 2021-02-14 08:28:37 +01:00
377 changed files with 7636 additions and 2891 deletions

View File

@@ -5,7 +5,7 @@ data/log/*
data/locks/*
data/proxies/*
data/migrations_template.txt
data/GeoLite2-City.*
data/GeoLite2-City*
data/database.sqlite
data/shlink-tests.db
CHANGELOG.md

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4']
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -21,7 +21,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
extensions: swoole-4.7.1
coverage: none
- run: composer install --no-interaction --prefer-dist
- run: composer cs
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4']
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -39,7 +39,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
extensions: swoole-4.7.1
coverage: none
- run: composer install --no-interaction --prefer-dist
- run: composer stan
@@ -48,7 +48,8 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -57,13 +58,16 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
extensions: swoole-4.7.1
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:unit:ci
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '7.4' }}
if: ${{ matrix.php-version == '8.0' }}
with:
name: coverage-unit
path: |
@@ -74,7 +78,8 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -83,13 +88,16 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
extensions: swoole-4.7.1
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:db:sqlite:ci
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '7.4' }}
if: ${{ matrix.php-version == '8.0' }}
with:
name: coverage-db
path: |
@@ -100,7 +108,8 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -111,16 +120,20 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
extensions: swoole-4.7.1
coverage: none
- run: composer install --no-interaction --prefer-dist
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:db:mysql
db-tests-maria:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -131,16 +144,20 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
extensions: swoole-4.7.1
coverage: none
- run: composer install --no-interaction --prefer-dist
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:db:maria
db-tests-postgres:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -151,16 +168,20 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
extensions: swoole-4.7.1
coverage: none
- run: composer install --no-interaction --prefer-dist
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:db:postgres
db-tests-ms:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -169,13 +190,25 @@ jobs:
- name: Start database server
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms
- name: Use PHP
if: ${{ matrix.php-version == '8.1' }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3, pdo_sqlsrv-5.9.0
extensions: swoole-4.7.1, pdo_sqlsrv-5.10.0beta1
coverage: none
- run: composer install --no-interaction --prefer-dist
- name: Use PHP
if: ${{ matrix.php-version != '8.1' }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.7.1, pdo_sqlsrv-5.9.0
coverage: none
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- name: Create test database
run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
- run: composer test:db:ms
@@ -184,7 +217,8 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0', '8.1']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -195,13 +229,16 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
extensions: swoole-4.7.1
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- run: bin/test/run-api-tests.sh
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '7.4' }}
if: ${{ matrix.php-version == '8.0' }}
with:
name: coverage-api
path: |
@@ -216,7 +253,9 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0', '8.1']
test-group: ['unit', 'db']
continue-on-error: ${{ matrix.php-version == '8.1' }}
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -225,14 +264,17 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
extensions: swoole-4.7.1
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
- if: ${{ matrix.php-version == '8.1' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.1' }}
run: composer install --no-interaction --prefer-dist
- uses: actions/download-artifact@v2
with:
path: build
- run: composer infect:ci
- run: composer infect:ci:${{ matrix.test-group }}
upload-coverage:
needs:
@@ -242,7 +284,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4']
php-version: ['8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
php-version: ['8.0']
swoole: ['yes', 'no']
steps:
- name: Checkout code
@@ -20,7 +20,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
extensions: swoole-4.6.7
- if: ${{ matrix.swoole == 'yes' }}
run: ./build.sh ${GITHUB_REF#refs/tags/v}
- if: ${{ matrix.swoole == 'no' }}
@@ -43,7 +43,6 @@ jobs:
uses: docker://antonyurchenko/git-release:latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ALLOW_TAG_PREFIX: "true"
ALLOW_EMPTY_CHANGELOG: "true"
with:
args: |
@@ -54,7 +53,7 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: [ '7.4', '8.0' ]
php-version: [ '8.0' ]
swoole: [ 'yes', 'no' ]
steps:
- uses: geekyeggo/delete-artifact@v1

3
.gitignore vendored
View File

@@ -6,8 +6,7 @@ composer.phar
vendor/
data/database.sqlite
data/shlink-tests.db
data/GeoLite2-City.mmdb
data/GeoLite2-City.mmdb.*
data/GeoLite2-City.*
docs/swagger-ui*
docs/mercure.html
docker-compose.override.yml

View File

@@ -4,6 +4,224 @@ 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).
## [2.9.0] - 2021-10-10
### Added
* [#1015](https://github.com/shlinkio/shlink/issues/1015) Shlink now accepts configuration via env vars even when not using docker.
The config generated with the installing tool still has precedence over the env vars, so it cannot be combined. Either you use the tool, or use env vars.
* [#1149](https://github.com/shlinkio/shlink/issues/1149) Allowed to set custom defaults for the QR codes.
* [#1112](https://github.com/shlinkio/shlink/issues/1112) Added new option to define if the query string should be forwarded on a per-short URL basis.
The new `forwardQuery=true|false` param can be provided during short URL creation or edition, via REST API or CLI command, allowing to override the default behavior which makes the query string to always be forwarded.
* [#1105](https://github.com/shlinkio/shlink/issues/1105) Added support to define placeholders on not-found redirects, so that the redirected URL receives the originally visited path and/or domain.
Currently, `{DOMAIN}` and `{ORIGINAL_PATH}` placeholders are supported, and they can be used both in the redirected URL's path or query.
When they are used in the query, the values are URL encoded.
* [#1119](https://github.com/shlinkio/shlink/issues/1119) Added support to provide redis sentinel when using redis cache.
* [#1016](https://github.com/shlinkio/shlink/issues/1016) Added new option to send orphan visits to webhooks, via `NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS` env var or installer tool.
The option is disabled by default, as the payload is backwards incompatible. You will need to adapt your webhooks to treat the `shortUrl` property as optional before enabling this option.
* [#1104](https://github.com/shlinkio/shlink/issues/1104) Added ability to disable tracking based on IP addresses.
IP addresses can be provided in the form of fixed addresses, CIDR blocks, or wildcard patterns (192.168.*.*).
### Changed
* [#1142](https://github.com/shlinkio/shlink/issues/1142) Replaced `doctrine/cache` package with `symfony/cache`.
* [#1157](https://github.com/shlinkio/shlink/issues/1157) All routes now support CORS, not only rest ones.
* [#1144](https://github.com/shlinkio/shlink/issues/1144) Added experimental builds under PHP 8.1.
### Deprecated
* [#1164](https://github.com/shlinkio/shlink/issues/1164) Deprecated `SHORT_DOMAIN_HOST` and `SHORT_DOMAIN_SCHEMA` env vars. Use `DEFAULT_DOMAIN` and `USE_HTTPS=true|false` instead.
### Removed
* *Nothing*
### Fixed
* [#1165](https://github.com/shlinkio/shlink/issues/1165) Fixed warning displayed when trying to locate visits and there are none pending.
* [#1172](https://github.com/shlinkio/shlink/pull/1172) Removed unneeded explicitly defined volumes in docker image.
## [2.8.1] - 2021-08-15
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1155](https://github.com/shlinkio/shlink/issues/1155) Fixed numeric query params in long URLs being replaced by `0`.
## [2.8.0] - 2021-08-04
### Added
* [#1089](https://github.com/shlinkio/shlink/issues/1089) Added new `ENABLE_PERIODIC_VISIT_LOCATE` env var to docker image which schedules the `visit:locate` command every hour when provided with value `true`.
* [#1082](https://github.com/shlinkio/shlink/issues/1082) Added support for error correction level on QR codes.
Now, when calling the `GET /{shorCode}/qr-code` URL, you can pass the `errorCorrection` query param with values `L` for Low, `M` for Medium, `Q` for Quartile or `H` for High.
* [#1080](https://github.com/shlinkio/shlink/issues/1080) Added support to redirect to URLs as soon as the path starts with a valid short code, appending the rest of the path to the redirected long URL.
With this, if you have the `https://example.com/abc123` short URL redirecting to `https://www.twitter.com`, a visit to `https://example.com/abc123/shlinkio` will take you to `https://www.twitter.com/shlinkio`.
This behavior needs to be actively opted in, via installer config options or env vars.
* [#943](https://github.com/shlinkio/shlink/issues/943) Added support to define different "not-found" redirects for every domain handled by Shlink.
Shlink will continue to allow defining the default values via env vars or config, but afterwards, you can use the `domain:redirects` command or the `PATCH /domains/redirects` REST endpoint to define specific values for every single domain.
### Changed
* [#1118](https://github.com/shlinkio/shlink/issues/1118) Increased phpstan required level to 8.
* [#1127](https://github.com/shlinkio/shlink/issues/1127) Updated to infection 0.24.
* [#1139](https://github.com/shlinkio/shlink/issues/1139) Updated project dependencies, including base docker image to use PHP 8.0.9 and Alpine 3.14.
### Deprecated
* *Nothing*
### Removed
* [#1046](https://github.com/shlinkio/shlink/issues/1046) Dropped support for PHP 7.4.
### Fixed
* [#1098](https://github.com/shlinkio/shlink/issues/1098) Fixed errors when using Redis for caching, caused by some third party lib bug that was fixed on dependencies update.
## [2.7.3] - 2021-08-02
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1135](https://github.com/shlinkio/shlink/issues/1135) Fixed error when importing short URLs with no visits from another Shlink instance.
* [#1136](https://github.com/shlinkio/shlink/issues/1136) Fixed error when fetching tag/short-url/orphan visits for a page lower than 1.
## [2.7.2] - 2021-07-30
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1128](https://github.com/shlinkio/shlink/issues/1128) Increased memory limit reserved for the docker image, preventing it from crashing on GeoLite db download.
## [2.7.1] - 2021-05-30
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1100](https://github.com/shlinkio/shlink/issues/1100) Fixed Shlink trying to download GeoLite2 db files even when tracking has been disabled.
## [2.7.0] - 2021-05-23
### Added
* [#1044](https://github.com/shlinkio/shlink/issues/1044) Added ability to set names on API keys, which helps to identify them when the list grows.
* [#819](https://github.com/shlinkio/shlink/issues/819) Visits are now always located in real time, even when not using swoole.
The only side effect is that a GeoLite2 db file is now installed when the docker image starts or during shlink installation or update.
Also, when using swoole, the file is now updated **after** tracking a visit, which means it will not apply until the next one.
* [#1059](https://github.com/shlinkio/shlink/issues/1059) Added ability to optionally display author API key and its name when listing short URLs from the command line.
* [#1066](https://github.com/shlinkio/shlink/issues/1066) Added support to import short URLs and their visits from another Shlink instance using its API.
* [#898](https://github.com/shlinkio/shlink/issues/898) Improved tracking granularity, allowing to disable visits tracking completely, or just parts of it.
In order to achieve it, Shlink now supports 4 new tracking-related options, that can be customized via env vars for docker, or via installer:
* `disable_tracking`: If true, visits will not be tracked at all.
* `disable_ip_tracking`: If true, visits will be tracked, but neither the IP address, nor the location will be resolved.
* `disable_referrer_tracking`: If true, the referrer will not be tracked.
* `disable_ua_tracking`: If true, the user agent will not be tracked.
* [#955](https://github.com/shlinkio/shlink/issues/955) Added new option to set short URLs as crawlable, making them be listed in the robots.txt as Allowed.
* [#900](https://github.com/shlinkio/shlink/issues/900) Shlink now tries to detect if the visit is coming from a potential bot or crawler, and allows to exclude those visits from visits lists if desired.
### Changed
* [#1036](https://github.com/shlinkio/shlink/issues/1036) Updated to `happyr/doctrine-specification` 2.0.
* [#1039](https://github.com/shlinkio/shlink/issues/1039) Updated to `endroid/qr-code` 4.0.
* [#1008](https://github.com/shlinkio/shlink/issues/1008) Ensured all logs are sent to the filesystem while running API tests, which helps debugging the reason for tests to fail.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1041](https://github.com/shlinkio/shlink/issues/1041) Ensured the default value for the version while building the docker image is `latest`.
* [#1067](https://github.com/shlinkio/shlink/issues/1067) Fixed exception when persisting multiple short URLs in one batch which include the same new tags/domains. This can potentially happen when importing URLs.
## [2.6.2] - 2021-03-12
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1047](https://github.com/shlinkio/shlink/issues/1047) Fixed error in migrations when doing a fresh installation using PHP8 and MySQL/Mariadb databases.
## [2.6.1] - 2021-02-22
### Added
* *Nothing*
### Changed
* [#1026](https://github.com/shlinkio/shlink/issues/1026) Removed non-inclusive terms from source code.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1024](https://github.com/shlinkio/shlink/issues/1024) Fixed migration that is incorrectly skipped due to the wrong condition being used to check it.
* [#1031](https://github.com/shlinkio/shlink/issues/1031) Fixed shortening of twitter URLs with URL validation enabled.
* [#1034](https://github.com/shlinkio/shlink/issues/1034) Fixed warning displayed when shlink is stopped while running it with swoole.
## [2.6.0] - 2021-02-13
### Added
* [#856](https://github.com/shlinkio/shlink/issues/856) Added PHP 8.0 support.
@@ -22,8 +240,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
This new orphan visits can be consumed in these ways:
* The `https://shlink.io/new-orphan-visit` mercure topic, which gets notified when an orphan visit occurs.
* The `GET /visits/orphan` REST endpoint, which behaves like the short URL visits and tags visits endpoints, but returns only orphan visits.
* The `https://shlink.io/new-orphan-visit` mercure topic, which gets notified when an orphan visit occurs.
* The `GET /visits/orphan` REST endpoint, which behaves like the short URL visits and tags visits endpoints, but returns only orphan visits.
### Changed
* [#977](https://github.com/shlinkio/shlink/issues/977) Migrated from `laminas/laminas-paginator` to `pagerfanta/core` to handle pagination.

View File

@@ -96,11 +96,13 @@ In order to ensure stability and no regressions are introduced while developing
The project provides some tooling to run them against any of the supported database engines.
* **API tests**: These are E2E tests that spin up an instance of the app and test it from the outside, by interacting with the REST API.
* **API tests**: These are E2E tests that spin up an instance of the app with swoole, and test it from the outside by interacting with the REST API.
These are the best tests to catch regressions, and to verify everything behaves as expected.
They use MySQL as the database engine, and include some fixtures that ensure the same data exists at the beginning of the execution.
They use Postgres as the database engine, and include some fixtures that ensure the same data exists at the beginning of the execution.
Since the app instance is run on a process different from the one running the tests, when a test fails it might not be obvious why. To help debugging that, the app will dump all its logs inside `data/log/api-tests`, where you will find the `shlink.log` and `access.log` files.
* **CLI tests**: *TBD. Once included, its purpose will be the same as API tests, but running through the command line*
@@ -118,8 +120,8 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed,
For example, `test:db:postgres`.
* Run `./indocker composer test:api` to run API E2E tests. For these, the MySQL database engine is used.
* Run `./indocker composer infect:test` ti run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/).
* Run `./indocker composer test:api` to run API E2E tests. For these, the Postgres database engine is used.
* Run `./indocker composer infect:test` to run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/).
* Run `./indocker composer ci` to run all previous commands together. This command is run during the project's continuous integration.
* Run `./indocker composer ci:parallel` to do the same as in previous case, but parallelizing non-conflicting tasks as much as possible.

View File

@@ -1,9 +1,10 @@
FROM php:8.0.2-alpine3.13 as base
FROM php:8.0.9-alpine3.14 as base
ARG SHLINK_VERSION=2.5.2
ARG SHLINK_VERSION=latest
ENV SHLINK_VERSION ${SHLINK_VERSION}
ENV SWOOLE_VERSION 4.6.3
ENV SWOOLE_VERSION 4.7.1
ENV PDO_SQLSRV_VERSION 5.9.0
ENV MS_ODBC_SQL_VERSION 17.5.2.2
ENV LC_ALL "C"
WORKDIR /etc/shlink
@@ -30,13 +31,13 @@ RUN \
# Install sqlsrv driver
RUN if [ $(uname -m) == "x86_64" ]; then \
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \
docker-php-ext-enable pdo_sqlsrv && \
apk del .phpize-deps && \
rm msodbcsql17_17.5.1.1-1_amd64.apk ; \
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk ; \
fi
# Install swoole
@@ -67,12 +68,19 @@ RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink
# Expose default swoole port
EXPOSE 8080
# Expose params config dir, since the user is expected to provide custom config from there
VOLUME /etc/shlink/config/params
# Copy config specific for the image
COPY docker/docker-entrypoint.sh docker-entrypoint.sh
COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php
COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/
# Change the ownership of /etc/shlink/data to be writable, then change the user to non-root
# FIXME Disabled for now, as it conflicts with ENABLE_PERIODIC_VISIT_LOCATE, which is used to configure a cron as root.
# Ref: https://github.com/shlinkio/shlink/issues/1132
#RUN chown 1001 /etc/shlink/data
#RUN chown 1001 /etc/shlink/data/locks
#RUN chown 1001 /etc/shlink/data/proxies
#RUN chown 1001 /etc/shlink/data/cache
#RUN chown 1001 /etc/shlink/data/log
#USER 1001
ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"]

View File

@@ -33,7 +33,8 @@ 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 7.4 with JSON, curl, PDO, intl and gd extensions enabled (PHP 8.0 support is coming).
* PHP 8.0
* The next PHP extensions: json, curl, pdo, intl, gd and gmp.
* apcu extension is recommended if you don't plan to use swoole.
* xml extension is required if you want to generate QR codes in svg format.
* MySQL, MariaDB, PostgreSQL, Microsoft SQL Server or SQLite.

View File

@@ -3,5 +3,8 @@
declare(strict_types=1);
$run = require __DIR__ . '/../config/run.php';
$run(true);
use Symfony\Component\Console\Application;
/** @var Application $app */
$app = require __DIR__ . '/../config/cli-app.php';
$app->run();

View File

@@ -3,6 +3,8 @@ export APP_ENV=test
export DB_DRIVER=postgres
export TEST_ENV=api
rm -rf data/log/api-tests
# Try to stop server just in case it hanged in last execution
vendor/bin/laminas mezzio:swoole:stop

View File

@@ -12,67 +12,70 @@
}
],
"require": {
"php": "^7.4 || ^8.0",
"php": "^8.0",
"ext-json": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.0",
"cakephp/chronos": "^2.0",
"cakephp/chronos": "^2.2",
"cocur/slugify": "^4.0",
"doctrine/cache": "^1.9",
"doctrine/migrations": "^3.0.2",
"doctrine/orm": "^2.8",
"endroid/qr-code": "dev-master#0f1613a as 3.10",
"geoip2/geoip2": "^2.9",
"guzzlehttp/guzzle": "^7.0",
"happyr/doctrine-specification": "2.0.x-dev#cb116d3 as 2.0",
"laminas/laminas-config": "^3.3",
"laminas/laminas-config-aggregator": "^1.1",
"laminas/laminas-diactoros": "^2.1.3",
"laminas/laminas-inputfilter": "^2.10",
"laminas/laminas-servicemanager": "^3.6",
"laminas/laminas-stdlib": "^3.2",
"lcobucci/jwt": "^4.0",
"league/uri": "^6.2",
"lstrojny/functional-php": "^1.15",
"mezzio/mezzio": "^3.3",
"mezzio/mezzio-fastroute": "^3.1",
"mezzio/mezzio-problem-details": "^1.3",
"mezzio/mezzio-swoole": "^3.1",
"monolog/monolog": "^2.0",
"doctrine/migrations": "^3.2",
"doctrine/orm": "^2.9",
"endroid/qr-code": "^4.2",
"geoip2/geoip2": "^2.11",
"guzzlehttp/guzzle": "^7.3",
"happyr/doctrine-specification": "^2.0",
"jaybizzle/crawler-detect": "^1.2",
"laminas/laminas-config": "^3.5",
"laminas/laminas-config-aggregator": "^1.5",
"laminas/laminas-diactoros": "^2.6",
"laminas/laminas-inputfilter": "^2.12",
"laminas/laminas-servicemanager": "^3.7",
"laminas/laminas-stdlib": "^3.5",
"lcobucci/jwt": "^4.1",
"league/uri": "^6.4",
"lstrojny/functional-php": "^1.17",
"mezzio/mezzio": "^3.5",
"mezzio/mezzio-fastroute": "^3.2",
"mezzio/mezzio-problem-details": "^1.4",
"mezzio/mezzio-swoole": "^3.3",
"monolog/monolog": "^2.3",
"nikolaposa/monolog-factory": "^3.1",
"ocramius/proxy-manager": "^2.11",
"pagerfanta/core": "^2.5",
"pagerfanta/core": "^2.7",
"php-middleware/request-id": "^4.1",
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.7",
"ramsey/uuid": "^3.9",
"shlinkio/shlink-common": "^3.5",
"shlinkio/shlink-config": "^1.0",
"rlanvin/php-ip": "3.0.0-rc2",
"shlinkio/shlink-common": "^4.0",
"shlinkio/shlink-config": "^1.2",
"shlinkio/shlink-event-dispatcher": "^2.1",
"shlinkio/shlink-importer": "^2.2",
"shlinkio/shlink-installer": "^5.4",
"shlinkio/shlink-ip-geolocation": "^1.5",
"symfony/console": "^5.1",
"symfony/filesystem": "^5.1",
"symfony/lock": "^5.1",
"symfony/mercure": "^0.4.1",
"symfony/process": "^5.1",
"symfony/string": "^5.1"
"shlinkio/shlink-importer": "^2.3.1",
"shlinkio/shlink-installer": "^6.2",
"shlinkio/shlink-ip-geolocation": "^2.0",
"symfony/console": "^5.3",
"symfony/filesystem": "^5.3",
"symfony/lock": "^5.3",
"symfony/mercure": "^0.5.3",
"symfony/process": "^5.3",
"symfony/string": "^5.3"
},
"require-dev": {
"devster/ubench": "^2.1",
"dms/phpunit-arraysubset-asserts": "^0.2.1",
"dms/phpunit-arraysubset-asserts": "^0.3.0",
"eaglewu/swoole-ide-helper": "dev-master",
"infection/infection": "^0.21.0",
"infection/infection": "^0.25.0",
"phpspec/prophecy-phpunit": "^2.0",
"phpstan/phpstan": "^0.12.64",
"phpstan/phpstan": "^0.12.94",
"phpstan/phpstan-doctrine": "^0.12.42",
"phpstan/phpstan-symfony": "^0.12.41",
"phpunit/php-code-coverage": "^9.2",
"phpunit/phpunit": "^9.5",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.1.1",
"shlinkio/shlink-test-utils": "^2.0",
"symfony/var-dumper": "^5.2",
"veewee/composer-run-parallel": "^0.1.0"
"shlinkio/php-coding-standard": "~2.2.0",
"shlinkio/shlink-test-utils": "^2.2",
"symfony/var-dumper": "^5.3",
"veewee/composer-run-parallel": "^1.0"
},
"autoload": {
"psr-4": {
@@ -81,6 +84,7 @@
"Shlinkio\\Shlink\\Core\\": "module/Core/src"
},
"files": [
"config/constants.php",
"module/Core/functions/functions.php"
]
},
@@ -111,7 +115,7 @@
],
"cs": "phpcs",
"cs:fix": "phpcbf",
"stan": "phpstan analyse module/*/src/ module/*/config config docker/config data/migrations --level=6",
"stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/config config docker/config data/migrations --level=8",
"test": [
"@test:unit",
"@test:db",
@@ -124,6 +128,7 @@
],
"test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
"test:unit:ci": "@test:unit --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml",
"test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html",
"test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
"test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
"test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml",
@@ -132,8 +137,7 @@
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
"test:api": "bin/test/run-api-tests.sh",
"test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html",
"infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --skip-initial-tests",
"infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --only-covering-test-cases --skip-initial-tests",
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80",
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json",
"infect:ci": "@parallel infect:ci:unit infect:ci:db",

View File

@@ -7,7 +7,6 @@ return [
'app_options' => [
'name' => 'Shlink',
'version' => '%SHLINK_VERSION%',
'disable_track_param' => null,
],
];

View File

@@ -4,11 +4,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink;
use function Shlinkio\Shlink\Common\env;
use const Shlinkio\Shlink\DEFAULT_DELETE_SHORT_URL_THRESHOLD;
return [
'delete_short_urls' => [
'visits_threshold' => 15,
'check_visits_threshold' => true,
'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', DEFAULT_DELETE_SHORT_URL_THRESHOLD),
],
];

View File

@@ -2,24 +2,52 @@
declare(strict_types=1);
namespace Shlinkio\Shlink\Common;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Happyr\DoctrineSpecification\EntitySpecificationRepository;
use function Functional\contains;
use function Shlinkio\Shlink\Common\env;
return [
return (static function (): array {
$driver = env('DB_DRIVER');
$isMysqlCompatible = contains(['maria', 'mysql'], $driver);
'entity_manager' => [
'orm' => [
'proxies_dir' => 'data/proxies',
'load_mappings_using_functional_style' => true,
'default_repository_classname' => EntitySpecificationRepository::class,
$resolveDriver = static fn () => match ($driver) {
'postgres' => 'pdo_pgsql',
'mssql' => 'pdo_sqlsrv',
default => 'pdo_mysql',
};
$resolveDefaultPort = static fn () => match ($driver) {
'postgres' => '5432',
'mssql' => '1433',
default => '3306',
};
$resolveConnection = static fn () => match (true) {
$driver === null || $driver === 'sqlite' => [
'driver' => 'pdo_sqlite',
'path' => 'data/database.sqlite',
],
'connection' => [
'user' => '',
'password' => '',
'dbname' => 'shlink',
default => [
'driver' => $resolveDriver(),
'dbname' => env('DB_NAME', 'shlink'),
'user' => env('DB_USER'),
'password' => env('DB_PASSWORD'),
'host' => env('DB_HOST', $driver === 'postgres' ? env('DB_UNIX_SOCKET') : null),
'port' => env('DB_PORT', $resolveDefaultPort()),
'unix_socket' => $isMysqlCompatible ? env('DB_UNIX_SOCKET') : null,
'charset' => 'utf8',
],
],
};
];
return [
'entity_manager' => [
'orm' => [
'proxies_dir' => 'data/proxies',
'load_mappings_using_functional_style' => true,
'default_repository_classname' => EntitySpecificationRepository::class,
],
'connection' => $resolveConnection(),
],
];
})();

View File

@@ -2,14 +2,6 @@
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
// When running tests, any mysql-specific option can interfere with other drivers
$driverOptions = env('APP_ENV') === 'test' ? [] : [
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
];
return [
'entity_manager' => [
@@ -18,7 +10,8 @@ return [
'password' => 'root',
'driver' => 'pdo_mysql',
'host' => 'shlink_db',
'driverOptions' => $driverOptions,
'dbname' => 'shlink',
'charset' => 'utf8',
],
],

View File

@@ -2,12 +2,14 @@
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
return [
'geolite2' => [
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
'temp_dir' => __DIR__ . '/../../data',
'license_key' => 'G4Lm0C60yJsnkdPi', // Deprecated. Remove hardcoded license on v3
'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'), // Deprecated. Remove hardcoded license on v3
],
];

View File

@@ -2,7 +2,10 @@
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI;
use Shlinkio\Shlink\Installer\Config\Option;
use Shlinkio\Shlink\Installer\Util\InstallationCommand;
return [
@@ -21,10 +24,10 @@ return [
Option\UrlShortener\ShortDomainSchemaConfigOption::class,
Option\UrlShortener\ValidateUrlConfigOption::class,
Option\Visit\VisitsWebhooksConfigOption::class,
Option\Visit\OrphanVisitsWebhooksConfigOption::class,
Option\Redirect\BaseUrlRedirectConfigOption::class,
Option\Redirect\InvalidShortUrlRedirectConfigOption::class,
Option\Redirect\Regular404RedirectConfigOption::class,
Option\DisableTrackParamConfigOption::class,
Option\Visit\CheckVisitsThresholdConfigOption::class,
Option\Visit\VisitsThresholdConfigOption::class,
Option\BasePathConfigOption::class,
@@ -37,19 +40,33 @@ return [
Option\Mercure\MercureInternalUrlConfigOption::class,
Option\Mercure\MercureJwtSecretConfigOption::class,
Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class,
Option\UrlShortener\IpAnonymizationConfigOption::class,
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
Option\UrlShortener\OrphanVisitsTrackingConfigOption::class,
Option\UrlShortener\AppendExtraPathConfigOption::class,
Option\Tracking\IpAnonymizationConfigOption::class,
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
Option\Tracking\DisableTrackParamConfigOption::class,
Option\Tracking\DisableTrackingFromConfigOption::class,
Option\Tracking\DisableTrackingConfigOption::class,
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,
],
'installation_commands' => [
'db_create_schema' => [
'command' => 'bin/cli db:create',
InstallationCommand::DB_CREATE_SCHEMA => [
'command' => 'bin/cli ' . Command\Db\CreateDatabaseCommand::NAME,
],
'db_migrate' => [
'command' => 'bin/cli db:migrate',
InstallationCommand::DB_MIGRATE => [
'command' => 'bin/cli ' . Command\Db\MigrateDatabaseCommand::NAME,
],
InstallationCommand::GEOLITE_DOWNLOAD_DB => [
'command' => 'bin/cli ' . Command\Visit\DownloadGeoLiteDbCommand::NAME,
],
],
],

View File

@@ -3,12 +3,13 @@
declare(strict_types=1);
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Shlinkio\Shlink\Common\Cache\RedisFactory;
use Shlinkio\Shlink\Common\Lock\RetryLockStoreDelegatorFactory;
use Predis\ClientInterface as PredisClient;
use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory;
use Symfony\Component\Lock;
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
use function Shlinkio\Shlink\Common\env;
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
return [
@@ -24,16 +25,12 @@ return [
LOCAL_LOCK_FACTORY => ConfigAbstractFactory::class,
],
'aliases' => [
// With this config, a user could alias 'lock_store' => 'redis_lock_store' to override the default
'lock_store' => 'local_lock_store',
'lock_store' => env('REDIS_SERVERS') === null ? 'local_lock_store' : 'redis_lock_store',
'redis_lock_store' => Lock\Store\RedisStore::class,
'local_lock_store' => Lock\Store\FlockStore::class,
],
'delegators' => [
Lock\Store\RedisStore::class => [
RetryLockStoreDelegatorFactory::class,
],
Lock\LockFactory::class => [
LoggerAwareDelegatorFactory::class,
],
@@ -42,7 +39,7 @@ return [
ConfigAbstractFactory::class => [
Lock\Store\FlockStore::class => ['config.locks.locks_dir'],
Lock\Store\RedisStore::class => [RedisFactory::SERVICE_NAME],
Lock\Store\RedisStore::class => [PredisClient::class],
Lock\LockFactory::class => ['lock_store'],
LOCAL_LOCK_FACTORY => ['local_lock_store'],
],

View File

@@ -4,33 +4,39 @@ declare(strict_types=1);
use Laminas\ServiceManager\Proxy\LazyServiceFactory;
use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
use Symfony\Component\Mercure\Publisher;
use Symfony\Component\Mercure\PublisherInterface;
use Symfony\Component\Mercure\Hub;
use Symfony\Component\Mercure\HubInterface;
return [
use function Shlinkio\Shlink\Common\env;
'mercure' => [
'public_hub_url' => null,
'internal_hub_url' => null,
'jwt_secret' => null,
'jwt_issuer' => 'Shlink',
],
return (static function (): array {
$publicUrl = env('MERCURE_PUBLIC_HUB_URL');
'dependencies' => [
'delegators' => [
LcobucciJwtProvider::class => [
LazyServiceFactory::class,
return [
'mercure' => [
'public_hub_url' => $publicUrl,
'internal_hub_url' => env('MERCURE_INTERNAL_HUB_URL', $publicUrl),
'jwt_secret' => env('MERCURE_JWT_SECRET'),
'jwt_issuer' => 'Shlink',
],
'dependencies' => [
'delegators' => [
LcobucciJwtProvider::class => [
LazyServiceFactory::class,
],
Hub::class => [
LazyServiceFactory::class,
],
],
Publisher::class => [
LazyServiceFactory::class,
'lazy_services' => [
'class_map' => [
LcobucciJwtProvider::class => LcobucciJwtProvider::class,
Hub::class => HubInterface::class,
],
],
],
'lazy_services' => [
'class_map' => [
LcobucciJwtProvider::class => LcobucciJwtProvider::class,
Publisher::class => PublisherInterface::class,
],
],
],
];
];
})();

View File

@@ -18,12 +18,12 @@ return [
'middleware' => [
ContentLengthMiddleware::class,
ErrorHandler::class,
Rest\Middleware\CrossDomainMiddleware::class,
],
],
'error-handler-rest' => [
'path' => '/rest',
'middleware' => [
Rest\Middleware\CrossDomainMiddleware::class,
RequestIdMiddleware::class,
ProblemDetails\ProblemDetailsMiddleware::class,
],
@@ -68,6 +68,7 @@ return [
// This middleware is in front of tracking actions explicitly. Putting here for orphan visits tracking
IpAddress::class,
Core\ErrorHandler\NotFoundTypeResolverMiddleware::class,
Core\ShortUrl\Middleware\ExtraPathRedirectMiddleware::class,
Core\ErrorHandler\NotFoundTrackerMiddleware::class,
Core\ErrorHandler\NotFoundRedirectHandler::class,
Core\ErrorHandler\NotFoundTemplateHandler::class,

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_MARGIN;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE;
return [
'qr_codes' => [
'size' => (int) env('DEFAULT_QR_CODE_SIZE', DEFAULT_QR_CODE_SIZE),
'margin' => (int) env('DEFAULT_QR_CODE_MARGIN', DEFAULT_QR_CODE_MARGIN),
'format' => env('DEFAULT_QR_CODE_FORMAT', DEFAULT_QR_CODE_FORMAT),
'error_correction' => env('DEFAULT_QR_CODE_ERROR_CORRECTION', DEFAULT_QR_CODE_ERROR_CORRECTION),
],
];

View File

@@ -2,12 +2,23 @@
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME;
use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
return [
'not_found_redirects' => [
'invalid_short_url' => null,
'regular_404' => null,
'base_url' => null,
'invalid_short_url' => env('INVALID_SHORT_URL_REDIRECT_TO'),
'regular_404' => env('REGULAR_404_REDIRECT_TO'),
'base_url' => env('BASE_URL_REDIRECT_TO'),
],
'url_shortener' => [
// TODO Move these options to their own config namespace. Maybe "redirects".
'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
],
];

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
return (static function (): array {
$redisServers = env('REDIS_SERVERS');
return match (true) {
$redisServers === null => [],
default => [
'cache' => [
'redis' => [
'servers' => $redisServers,
'sentinel_service' => env('REDIS_SENTINEL_SERVICE'),
],
],
],
};
})();

View File

@@ -4,10 +4,12 @@ declare(strict_types=1);
use Mezzio\Router\FastRouteRouter;
use function Shlinkio\Shlink\Common\env;
return [
'router' => [
'base_path' => '',
'base_path' => env('BASE_PATH', ''),
'fastroute' => [
FastRouteRouter::CONFIG_CACHE_ENABLED => true,

View File

@@ -2,6 +2,8 @@
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
return [
'mezzio-swoole' => [
@@ -10,11 +12,12 @@ return [
'swoole-http-server' => [
'host' => '0.0.0.0',
'port' => (int) env('PORT', 8080),
'process-name' => 'shlink',
'options' => [
'worker_num' => 16,
'task_worker_num' => 16,
'worker_num' => (int) env('WEB_WORKER_NUM', 16),
'task_worker_num' => (int) env('TASK_WORKER_NUM', 16),
],
],
],

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
return [
'tracking' => [
// Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations
// This applies only if IP address tracking is enabled
'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true),
// Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence
'track_orphan_visits' => (bool) env('TRACK_ORPHAN_VISITS', true),
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence
'disable_track_param' => env('DISABLE_TRACK_PARAM'),
// If true, visits will not be tracked at all
'disable_tracking' => (bool) env('DISABLE_TRACKING', false),
// If true, visits will be tracked, but neither the IP address, nor the location will be resolved
'disable_ip_tracking' => (bool) env('DISABLE_IP_TRACKING', false),
// If true, the referrer will not be tracked
'disable_referrer_tracking' => (bool) env('DISABLE_REFERRER_TRACKING', false),
// If true, the user agent will not be tracked
'disable_ua_tracking' => (bool) env('DISABLE_UA_TRACKING', false),
// A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default
'disable_tracking_from' => env('DISABLE_TRACKING_FROM'),
],
];

View File

@@ -2,25 +2,29 @@
declare(strict_types=1);
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_CACHE_LIFETIME;
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_STATUS_CODE;
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
use function Shlinkio\Shlink\Common\env;
return [
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
'url_shortener' => [
'domain' => [
'schema' => 'https',
'hostname' => '',
return (static function (): array {
$shortCodesLength = (int) env('DEFAULT_SHORT_CODES_LENGTH', DEFAULT_SHORT_CODES_LENGTH);
$shortCodesLength = $shortCodesLength < MIN_SHORT_CODES_LENGTH ? MIN_SHORT_CODES_LENGTH : $shortCodesLength;
$useHttps = env('USE_HTTPS'); // Deprecated. For v3, set this to true by default, instead of null
return [
'url_shortener' => [
'domain' => [
// Deprecated SHORT_DOMAIN_* env vars
'schema' => $useHttps !== null ? (bool) $useHttps : env('SHORT_DOMAIN_SCHEMA', 'http'),
'hostname' => env('DEFAULT_DOMAIN', env('SHORT_DOMAIN_HOST', '')),
],
'validate_url' => (bool) env('VALIDATE_URLS', false), // Deprecated
'default_short_codes_length' => $shortCodesLength,
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
'append_extra_path' => (bool) env('REDIRECT_APPEND_EXTRA_PATH', false),
],
'validate_url' => false, // Deprecated
'anonymize_remote_addr' => true,
'visits_webhooks' => [],
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE,
'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME,
'auto_resolve_titles' => false,
'track_orphan_visits' => true,
],
];
];
})();

View File

@@ -2,13 +2,16 @@
declare(strict_types=1);
$isSwoole = extension_loaded('swoole');
return [
'url_shortener' => [
'domain' => [
'schema' => 'http',
'hostname' => 'localhost:8080',
'hostname' => sprintf('localhost:%s', $isSwoole ? '8080' : '8000'),
],
'auto_resolve_titles' => true,
],
];

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
return (static function (): array {
$webhooks = env('VISITS_WEBHOOKS');
return [
'url_shortener' => [
// TODO Move these options to their own config namespace
'visits_webhooks' => $webhooks === null ? [] : explode(',', $webhooks),
'notify_orphan_visits_to_webhooks' => (bool) env('NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS', false),
],
];
})();

12
config/cli-app.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Application as CliApp;
return (static function () {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/container.php';
return $container->get(CliApp::class);
})();

View File

@@ -4,12 +4,9 @@ declare(strict_types=1);
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Tools\Console\ConsoleRunner;
use Psr\Container\ContainerInterface;
return (function () {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/container.php';
$em = $container->get(EntityManager::class);
return (static function () {
/** @var EntityManager $em */
$em = include __DIR__ . '/entity-manager.php';
return ConsoleRunner::createHelperSet($em);
})();

View File

@@ -8,7 +8,7 @@ use Laminas\ConfigAggregator;
use Laminas\Diactoros;
use Mezzio;
use Mezzio\ProblemDetails;
use Mezzio\Swoole\ConfigProvider as SwooleConfigProvider;
use Mezzio\Swoole;
use function class_exists;
use function Shlinkio\Shlink\Common\env;
@@ -17,7 +17,7 @@ return (new ConfigAggregator\ConfigAggregator([
Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class,
Mezzio\Router\FastRouteRouter\ConfigProvider::class,
class_exists(SwooleConfigProvider::class) ? SwooleConfigProvider::class : new ConfigAggregator\ArrayProvider([]),
class_exists(Swoole\ConfigProvider::class) ? Swoole\ConfigProvider::class : new ConfigAggregator\ArrayProvider([]),
ProblemDetails\ConfigProvider::class,
Diactoros\ConfigProvider::class,
Common\ConfigProvider::class,
@@ -31,6 +31,7 @@ return (new ConfigAggregator\ConfigAggregator([
new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'),
env('APP_ENV') === 'test'
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')
// Deprecated. When the SimplifiedConfigParser is removed, load only generated_config.php here
: new ConfigAggregator\LaminasConfigProvider('config/params/{generated_config.php,*.config.{php,json}}'),
], 'data/cache/app_config.php', [
Core\Config\SimplifiedConfigParser::class,

20
config/constants.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Fig\Http\Message\StatusCodeInterface;
const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15;
const DEFAULT_SHORT_CODES_LENGTH = 5;
const MIN_SHORT_CODES_LENGTH = 4;
const DEFAULT_REDIRECT_STATUS_CODE = StatusCodeInterface::STATUS_FOUND;
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
const CUSTOM_SLUGS_REGEXP = '/[^\pL\pN._~]/u'; // Any unicode letter or number, plus ".", "_" and "~" chars
const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside an html title tag
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';

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
use Laminas\ServiceManager\ServiceManager;
use Symfony\Component\Lock;
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
chdir(dirname(__DIR__));

12
config/entity-manager.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
use Doctrine\ORM\EntityManager;
use Psr\Container\ContainerInterface;
return (static function () {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/container.php';
return $container->get(EntityManager::class);
})();

View File

@@ -4,12 +4,11 @@ declare(strict_types=1);
use Mezzio\Application;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Application as CliApp;
return function (bool $isCli = false): void {
return static function (): void {
/** @var ContainerInterface $container */
$container = include __DIR__ . '/container.php';
$app = $container->get($isCli ? CliApp::class : Application::class);
$app = $container->get(Application::class);
$app->run();
};

View File

@@ -9,7 +9,8 @@ use Laminas\ConfigAggregator\ConfigAggregator;
use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Laminas\Stdlib\Glob;
use PDO;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use PHPUnit\Runner\Version;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Driver\Selector;
@@ -34,30 +35,17 @@ if ($isApiTest) {
$coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter);
}
$buildDbConnection = function (): array {
$buildDbConnection = static function (): array {
$driver = env('DB_DRIVER', 'sqlite');
$isCi = env('CI', false);
$getMysqlHost = fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria');
$getCiMysqlPort = fn (string $driver) => $driver === 'mysql' ? '3307' : '3308';
$getMysqlHost = static fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria');
$getCiMysqlPort = static fn (string $driver) => $driver === 'mysql' ? '3307' : '3308';
$driverConfigMap = [
return match ($driver) {
'sqlite' => [
'driver' => 'pdo_sqlite',
'path' => sys_get_temp_dir() . '/shlink-tests.db',
],
'mysql' => [
'driver' => 'pdo_mysql',
'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver),
'port' => $isCi ? $getCiMysqlPort($driver) : '3306',
'user' => 'root',
'password' => 'root',
'dbname' => 'shlink_test',
'charset' => 'utf8',
'driverOptions' => [
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
],
],
'postgres' => [
'driver' => 'pdo_pgsql',
'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres',
@@ -74,12 +62,30 @@ $buildDbConnection = function (): array {
'password' => 'Passw0rd!',
'dbname' => 'shlink_test',
],
];
$driverConfigMap['maria'] = $driverConfigMap['mysql'];
return $driverConfigMap[$driver] ?? [];
default => [ // mysql and maria
'driver' => 'pdo_mysql',
'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver),
'port' => $isCi ? $getCiMysqlPort($driver) : '3306',
'user' => 'root',
'password' => 'root',
'dbname' => 'shlink_test',
'charset' => 'utf8',
],
};
};
$buildTestLoggerConfig = fn (string $handlerName, string $filename) => [
'handlers' => [
$handlerName => [
'name' => StreamHandler::class,
'params' => [
'level' => Logger::DEBUG,
'stream' => sprintf('data/log/api-tests/%s', $filename),
],
],
],
];
return [
'debug' => true,
@@ -111,7 +117,7 @@ return [
'name' => 'start_collecting_coverage',
'path' => '/api-tests/start-coverage',
'middleware' => middleware(static function () use (&$coverage) {
if ($coverage) {
if ($coverage) { // @phpstan-ignore-line
$coverage->start('API tests');
}
return new EmptyResponse();
@@ -122,7 +128,7 @@ return [
'name' => 'dump_coverage',
'path' => '/api-tests/stop-coverage',
'middleware' => middleware(static function () use (&$coverage) {
if ($coverage) {
if ($coverage) { // @phpstan-ignore-line
$basePath = __DIR__ . '/../../build/coverage-api';
$coverage->stop();
(new PHP())->process($coverage, $basePath . '.cov');
@@ -163,4 +169,9 @@ return [
],
],
'logger' => [
'Shlink' => $buildTestLoggerConfig('shlink_handler', 'shlink.log'),
'Access' => $buildTestLoggerConfig('access_handler', 'access.log'),
],
];

View File

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

View File

@@ -1,8 +1,9 @@
FROM php:8.0.2-fpm-alpine3.13
FROM php:8.0.9-fpm-alpine3.14
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.19
ENV APCU_VERSION 5.1.20
ENV PDO_SQLSRV_VERSION 5.9.0
ENV MS_ODBC_SQL_VERSION 17.5.2.2
RUN apk update
@@ -44,13 +45,13 @@ RUN mkdir -p /usr/src/php/ext/apcu \
&& echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
# Install pcov and sqlsrv driver
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql17_${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} pcov && \
docker-php-ext-enable pdo_sqlsrv pcov && \
apk del .phpize-deps && \
rm msodbcsql17_17.5.1.1-1_amd64.apk
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk
# Install composer
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer

View File

@@ -1,10 +1,11 @@
FROM php:8.0.2-alpine3.13
FROM php:8.0.9-alpine3.14
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.19
ENV PDO_SQLSRV_VERSION 5.9.0
ENV APCU_VERSION 5.1.20
ENV INOTIFY_VERSION 3.0.0
ENV SWOOLE_VERSION 4.6.3
ENV SWOOLE_VERSION 4.7.1
ENV PDO_SQLSRV_VERSION 5.9.0
ENV MS_ODBC_SQL_VERSION 17.5.2.2
RUN apk update
@@ -54,13 +55,13 @@ RUN mkdir -p /usr/src/php/ext/inotify \
&& rm /tmp/inotify.tar.gz
# Install swoole, pcov and mssql driver
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
docker-php-ext-enable swoole pdo_sqlsrv pcov && \
apk del .phpize-deps && \
rm msodbcsql17_17.5.1.1-1_amd64.apk
rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk
# Install composer
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\DBALException;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\Migrations\AbstractMigration;
@@ -18,7 +18,7 @@ class Version20160819142757 extends AbstractMigration
private const SQLITE = 'sqlite';
/**
* @throws DBALException
* @throws Exception
* @throws SchemaException
*/
public function up(Schema $schema): void
@@ -35,7 +35,7 @@ class Version20160819142757 extends AbstractMigration
}
/**
* @throws DBALException
* @throws Exception
*/
public function down(Schema $schema): void
{

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\DBALException;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use PDO;
@@ -16,15 +16,13 @@ use Shlinkio\Shlink\Common\Util\IpAddress;
*/
final class Version20180913205455 extends AbstractMigration
{
/**
*/
public function up(Schema $schema): void
{
// Nothing to create
}
/**
* @throws DBALException
* @throws Exception
*/
public function postUp(Schema $schema): void
{
@@ -59,13 +57,11 @@ final class Version20180913205455 extends AbstractMigration
try {
return (string) IpAddress::fromString($addr)->getAnonymizedCopy();
} catch (InvalidArgumentException $e) {
} catch (InvalidArgumentException) {
return null;
}
}
/**
*/
public function down(Schema $schema): void
{
// Nothing to rollback

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\DBALException;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\DBAL\Schema\Table;
@@ -42,7 +42,7 @@ final class Version20181020060559 extends AbstractMigration
/**
* @throws SchemaException
* @throws DBALException
* @throws Exception
*/
public function postUp(Schema $schema): void
{

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\DBALException;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
@@ -16,7 +16,7 @@ final class Version20200105165647 extends AbstractMigration
private const COLUMNS = ['lat' => 'latitude', 'lon' => 'longitude'];
/**
* @throws DBALException
* @throws Exception
*/
public function preUp(Schema $schema): void
{
@@ -43,7 +43,7 @@ final class Version20200105165647 extends AbstractMigration
}
/**
* @throws DBALException
* @throws Exception
*/
public function up(Schema $schema): void
{
@@ -57,7 +57,7 @@ final class Version20200105165647 extends AbstractMigration
}
/**
* @throws DBALException
* @throws Exception
*/
public function postUp(Schema $schema): void
{
@@ -83,7 +83,7 @@ final class Version20200105165647 extends AbstractMigration
}
/**
* @throws DBALException
* @throws Exception
*/
public function down(Schema $schema): void
{

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\DBALException;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
@@ -16,7 +16,7 @@ final class Version20200106215144 extends AbstractMigration
private const COLUMNS = ['latitude', 'longitude'];
/**
* @throws DBALException
* @throws Exception
*/
public function up(Schema $schema): void
{
@@ -32,7 +32,7 @@ final class Version20200106215144 extends AbstractMigration
}
/**
* @throws DBALException
* @throws Exception
*/
public function down(Schema $schema): void
{

View File

@@ -60,10 +60,7 @@ final class Version20201102113208 extends AbstractMigration
->execute();
}
/**
* @return string|int|null
*/
private function resolveOneApiKeyId(Result $result)
private function resolveOneApiKeyId(Result $result): string|int|null
{
$results = [];
while ($row = $result->fetchAssociative()) {

View File

@@ -33,12 +33,4 @@ final class Version20210202181026 extends AbstractMigration
$shortUrls->dropColumn(self::TITLE);
$shortUrls->dropColumn('title_was_auto_resolved');
}
/**
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
*/
public function isTransactional(): bool
{
return false;
}
}

View File

@@ -15,10 +15,9 @@ final class Version20210207100807 extends AbstractMigration
public function up(Schema $schema): void
{
$visits = $schema->getTable('visits');
$this->skipIf($visits->hasColumn('visited_url'));
$shortUrlId = $visits->getColumn('short_url_id');
$this->skipIf(! $shortUrlId->getNotnull());
$shortUrlId->setNotnull(false);
$visits->addColumn('visited_url', Types::STRING, [
@@ -34,20 +33,11 @@ final class Version20210207100807 extends AbstractMigration
public function down(Schema $schema): void
{
$visits = $schema->getTable('visits');
$this->skipIf(! $visits->hasColumn('visited_url'));
$shortUrlId = $visits->getColumn('short_url_id');
$this->skipIf($shortUrlId->getNotnull());
$shortUrlId->setNotnull(true);
$visits->dropColumn('visited_url');
$visits->dropColumn('type');
}
/**
* @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
*/
public function isTransactional(): bool
{
return false;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20210306165711 extends AbstractMigration
{
private const TABLE = 'api_keys';
private const COLUMN = 'name';
public function up(Schema $schema): void
{
$apiKeys = $schema->getTable(self::TABLE);
$this->skipIf($apiKeys->hasColumn(self::COLUMN));
$apiKeys->addColumn(
self::COLUMN,
Types::STRING,
[
'notnull' => false,
],
);
}
public function down(Schema $schema): void
{
$apiKeys = $schema->getTable(self::TABLE);
$this->skipIf(! $apiKeys->hasColumn(self::COLUMN));
$apiKeys->dropColumn(self::COLUMN);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20210522051601 extends AbstractMigration
{
public function up(Schema $schema): void
{
$shortUrls = $schema->getTable('short_urls');
$this->skipIf($shortUrls->hasColumn('crawlable'));
$shortUrls->addColumn('crawlable', Types::BOOLEAN, ['default' => false]);
}
public function down(Schema $schema): void
{
$shortUrls = $schema->getTable('short_urls');
$this->skipIf(! $shortUrls->hasColumn('crawlable'));
$shortUrls->dropColumn('crawlable');
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20210522124633 extends AbstractMigration
{
private const POTENTIAL_BOT_COLUMN = 'potential_bot';
public function up(Schema $schema): void
{
$visits = $schema->getTable('visits');
$this->skipIf($visits->hasColumn(self::POTENTIAL_BOT_COLUMN));
$visits->addColumn(self::POTENTIAL_BOT_COLUMN, Types::BOOLEAN, ['default' => false]);
}
public function down(Schema $schema): void
{
$visits = $schema->getTable('visits');
$this->skipIf(! $visits->hasColumn(self::POTENTIAL_BOT_COLUMN));
$visits->dropColumn(self::POTENTIAL_BOT_COLUMN);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\Table;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20210720143824 extends AbstractMigration
{
public function up(Schema $schema): void
{
$domainsTable = $schema->getTable('domains');
$this->skipIf($domainsTable->hasColumn('base_url_redirect'));
$this->createRedirectColumn($domainsTable, 'base_url_redirect');
$this->createRedirectColumn($domainsTable, 'regular_not_found_redirect');
$this->createRedirectColumn($domainsTable, 'invalid_short_url_redirect');
}
private function createRedirectColumn(Table $table, string $columnName): void
{
$table->addColumn($columnName, Types::STRING, [
'notnull' => false,
'default' => null,
]);
}
public function down(Schema $schema): void
{
$domainsTable = $schema->getTable('domains');
$this->skipIf(! $domainsTable->hasColumn('base_url_redirect'));
$domainsTable->dropColumn('base_url_redirect');
$domainsTable->dropColumn('regular_not_found_redirect');
$domainsTable->dropColumn('invalid_short_url_redirect');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20211002072605 extends AbstractMigration
{
public function up(Schema $schema): void
{
$shortUrls = $schema->getTable('short_urls');
$this->skipIf($shortUrls->hasColumn('forward_query'));
$shortUrls->addColumn('forward_query', Types::BOOLEAN, ['default' => true]);
}
public function down(Schema $schema): void
{
$shortUrls = $schema->getTable('short_urls');
$this->skipIf(! $shortUrls->hasColumn('forward_query'));
$shortUrls->dropColumn('forward_query');
}
}

View File

@@ -11,8 +11,8 @@ It exposes a shlink instance served with [swoole](https://www.swoole.co.uk/), wh
The most basic way to run Shlink's docker image is by providing these mandatory env vars.
* `SHORT_DOMAIN_HOST`: The custom short domain used for this shlink instance. For example **doma.in**.
* `SHORT_DOMAIN_SCHEMA`: Either **http** or **https**.
* `DEFAULT_DOMAIN`: The default short domain used for this shlink instance. For example **doma.in**.
* `USE_HTTPS`: Either **true** or **false**.
* `GEOLITE_LICENSE_KEY`: Your GeoLite2 license key. [Learn more](https://shlink.io/documentation/geolite-license-key/) about this.
To run shlink on top of a local docker service, and using an internal SQLite database, do the following:
@@ -21,8 +21,8 @@ To run shlink on top of a local docker service, and using an internal SQLite dat
docker run \
--name shlink \
-p 8080:8080 \
-e SHORT_DOMAIN_HOST=doma.in \
-e SHORT_DOMAIN_SCHEMA=https \
-e DEFAULT_DOMAIN=doma.in \
-e USE_HTTPS=true \
-e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 \
shlinkio/shlink:stable
```

View File

@@ -1,3 +1,4 @@
log_errors_max_len=0
zend.assertions=1
assert.exception=1
memory_limit=256M

View File

@@ -7,130 +7,8 @@ namespace Shlinkio\Shlink;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use function explode;
use function Functional\contains;
use function Shlinkio\Shlink\Common\env;
use const Shlinkio\Shlink\Core\DEFAULT_DELETE_SHORT_URL_THRESHOLD;
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_CACHE_LIFETIME;
use const Shlinkio\Shlink\Core\DEFAULT_REDIRECT_STATUS_CODE;
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH;
$helper = new class {
private const DB_DRIVERS_MAP = [
'mysql' => 'pdo_mysql',
'maria' => 'pdo_mysql',
'postgres' => 'pdo_pgsql',
'mssql' => 'pdo_sqlsrv',
];
private const DB_PORTS_MAP = [
'mysql' => '3306',
'maria' => '3306',
'postgres' => '5432',
'mssql' => '1433',
];
public function getDbConfig(): array
{
$driver = env('DB_DRIVER');
$isMysql = contains(['maria', 'mysql'], $driver);
if ($driver === null || $driver === 'sqlite') {
return [
'driver' => 'pdo_sqlite',
'path' => 'data/database.sqlite',
];
}
$driverOptions = ! $isMysql ? [] : [
// 1002 -> PDO::MYSQL_ATTR_INIT_COMMAND
1002 => 'SET NAMES utf8',
// 1000 -> PDO::MYSQL_ATTR_USE_BUFFERED_QUERY
1000 => true,
];
return [
'driver' => self::DB_DRIVERS_MAP[$driver],
'dbname' => env('DB_NAME', 'shlink'),
'user' => env('DB_USER'),
'password' => env('DB_PASSWORD'),
'host' => env('DB_HOST', $driver === 'postgres' ? env('DB_UNIX_SOCKET') : null),
'port' => env('DB_PORT', self::DB_PORTS_MAP[$driver]),
'driverOptions' => $driverOptions,
'unix_socket' => $isMysql ? env('DB_UNIX_SOCKET') : null,
];
}
public function getNotFoundRedirectsConfig(): array
{
return [
'invalid_short_url' => env('INVALID_SHORT_URL_REDIRECT_TO'),
'regular_404' => env('REGULAR_404_REDIRECT_TO'),
'base_url' => env('BASE_URL_REDIRECT_TO'),
];
}
public function getVisitsWebhooks(): array
{
$webhooks = env('VISITS_WEBHOOKS');
return $webhooks === null ? [] : explode(',', $webhooks);
}
public function getRedisConfig(): ?array
{
$redisServers = env('REDIS_SERVERS');
return $redisServers === null ? null : ['servers' => $redisServers];
}
public function getDefaultShortCodesLength(): int
{
$value = (int) env('DEFAULT_SHORT_CODES_LENGTH', DEFAULT_SHORT_CODES_LENGTH);
return $value < MIN_SHORT_CODES_LENGTH ? MIN_SHORT_CODES_LENGTH : $value;
}
public function getMercureConfig(): array
{
$publicUrl = env('MERCURE_PUBLIC_HUB_URL');
return [
'public_hub_url' => $publicUrl,
'internal_hub_url' => env('MERCURE_INTERNAL_HUB_URL', $publicUrl),
'jwt_secret' => env('MERCURE_JWT_SECRET'),
];
}
};
return [
'app_options' => [
'disable_track_param' => env('DISABLE_TRACK_PARAM'),
],
'delete_short_urls' => [
'check_visits_threshold' => true,
'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', DEFAULT_DELETE_SHORT_URL_THRESHOLD),
],
'entity_manager' => [
'connection' => $helper->getDbConfig(),
],
'url_shortener' => [
'domain' => [
'schema' => env('SHORT_DOMAIN_SCHEMA', 'http'),
'hostname' => env('SHORT_DOMAIN_HOST', ''),
],
'validate_url' => (bool) env('VALIDATE_URLS', false),
'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true),
'visits_webhooks' => $helper->getVisitsWebhooks(),
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
'track_orphan_visits' => (bool) env('TRACK_ORPHAN_VISITS', true),
],
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),
'logger' => [
'Shlink' => [
'handlers' => [
@@ -145,34 +23,4 @@ return [
],
],
'dependencies' => [
'aliases' => env('REDIS_SERVERS') === null ? [] : [
'lock_store' => 'redis_lock_store',
],
],
'cache' => [
'redis' => $helper->getRedisConfig(),
],
'router' => [
'base_path' => env('BASE_PATH', ''),
],
'mezzio-swoole' => [
'swoole-http-server' => [
'port' => (int) env('PORT', 8080),
'options' => [
'worker_num' => (int) env('WEB_WORKER_NUM', 16),
'task_worker_num' => (int) env('TASK_WORKER_NUM', 16),
],
],
],
'geolite2' => [
'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'),
],
'mercure' => $helper->getMercureConfig(),
];

View File

@@ -15,6 +15,21 @@ php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies -n -q
echo "Clearing entities cache..."
php vendor/doctrine/orm/bin/doctrine.php orm:clear-cache:metadata -n -q
# Try to download GeoLite2 db file only if the license key env var was defined
if [ ! -z "${GEOLITE_LICENSE_KEY}" ]; then
echo "Downloading GeoLite2 db file..."
php bin/cli visit:download-db -n -q
fi
# Periodicaly run visit:locate every hour
# https://shlink.io/documentation/long-running-tasks/#locate-visits
# set env var "ENABLE_PERIODIC_VISIT_LOCATE=true" to enable
if [ $ENABLE_PERIODIC_VISIT_LOCATE ]; then
echo "Configuring periodic visit locate..."
echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root
/usr/sbin/crond &
fi
# When restarting the container, swoole might think it is already in execution
# This forces the app to be started every second until the exit code is 0
until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done

View File

@@ -0,0 +1,59 @@
# Migrate to a new caching library
* Status: Accepted
* Date: 2021-08-05
## Context and problem statement
Shlink has always used the `doctrine/cache` library to handle anything related with cache.
It was convenient, as it provided several adapters, and it was the library used by other doctrine packages.
However, after the creation of the caching PSRs ([PSR-6 - Cache](https://www.php-fig.org/psr/psr-6) and [PSR-16 - Simple cache](https://www.php-fig.org/psr/psr-16)), most library authors have moved to those interfaces, and the doctrine team has decided to recommend using any other existing package and decommission their own solution.
Also, Shlink needs support for Redis clusters and Redis sentinels, which is not supported by `doctrine/cache` Redis adapters.
## Considered option
After some research, the only packages that seem to support the capabilities required by Shlink and also seem healthy, are these:
* [Symfony cache](https://symfony.com/doc/current/components/cache.html)
* 🟢 PSR-6 compliant: **yes**
* 🟢 PSR-16 compliant: **yes**
* 🟢 APCu support: **yes**
* 🟢 Redis support: **yes**
* 🟢 Redis cluster support: **yes**
* 🟢 Redis sentinel support: **yes**
* 🟢 Can use redis through Predis: **yes**
* 🔴 Individual packages per adapter: **no**
* [Laminas cache](https://docs.laminas.dev/laminas-cache/)
* 🟢 PSR-6 compliant: **yes**
* 🟢 PSR-16 compliant: **yes**
* 🟢 APCu support: **yes**
* 🟢 Redis support: **yes**
* 🟢 Redis cluster support: **yes**
* 🔴 Redis sentinel support: **no**
* 🔴 Can use redis through Predis: **no**
* 🟢 Individual packages per adapter: **yes**
## Decision outcome
Even though Symfony packs all their adapters in a single component, which means we will install some code that will never be used, Laminas relies on the native redis extension for anything related with redis.
That would make Shlink more complex to install, so it seems Symfony's package is the option where it's easier to migrate to.
Also, it's important that the cache component can share the Redis integration (through `Predis`, in this case), as it's also used by other components (the lock component, to name one).
## Pros and Cons of the Options
### Symfony cache
* Good because it supports Redis Sentinel.
* Good because it allows using a external `Predis` instance.
* Bad because it packs all the adapters in a single component.
### Laminas cache
* Good because allows installing only the adapters you are going to use, through separated packages.
* Bad because it requires the php-redis native extension in order to interact with Redis.
* Bad because it does ot seem to support Redis Sentinels.

View File

@@ -2,5 +2,6 @@
Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome.
* [2021-08-05 Migrate to a new caching library](2021-08-05-migrate-to-a-new-caching-library.md)
* [2021-02-07 Track visits to 'base_url', 'invalid_short_url' and 'regular_404'](2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md)
* [2021-01-17 Support restrictions and permissions in API keys](2021-01-17-support-restrictions-and-permissions-in-api-keys.md)

View File

@@ -116,6 +116,15 @@
"domain": {
"type": "string",
"description": "The domain in which the short URL was created. Null if it belongs to default domain."
},
"title": {
"type": "string",
"nullable": true,
"description": "A descriptive title of the short URL."
},
"crawlable": {
"type": "boolean",
"description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt."
}
},
"example": {
@@ -133,7 +142,9 @@
"validUntil": null,
"maxVisits": 100
},
"domain": "example.com"
"domain": "example.com",
"title": "The title",
"crawlable": false
}
},
"ShortUrlMeta": {
@@ -179,6 +190,10 @@
},
"visitLocation": {
"$ref": "#/components/schemas/VisitLocation"
},
"potentialBot": {
"type": "boolean",
"description": "Tells if Shlink thinks this visit comes potentially from a bot or crawler"
}
},
"example": {
@@ -193,7 +208,8 @@
"longitude": -122.0946,
"regionName": "California",
"timezone": "America/Los_Angeles"
}
},
"potentialBot": false
}
},
"OrphanVisit": {
@@ -232,6 +248,7 @@
"regionName": "California",
"timezone": "America/Los_Angeles"
},
"potentialBot": false,
"visitedUrl": "https://doma.in",
"type": "base_url"
}

View File

@@ -0,0 +1,20 @@
{
"type": "object",
"properties": {
"baseUrlRedirect": {
"type": "string",
"nullable": true,
"description": "URL to redirect to when a user hits the domain's base URL"
},
"regular404Redirect": {
"type": "string",
"nullable": true,
"description": "URL to redirect to when a user hits a not found URL other than an invalid short URL"
},
"invalidShortUrlRedirect": {
"type": "string",
"nullable": true,
"description": "URL to redirect to when a user hits an invalid short URL"
}
}
}

View File

@@ -1,5 +1,18 @@
{
"type": "object",
"required": [
"shortCode",
"shortUrl",
"longUrl",
"dateCreated",
"visitsCount",
"tags",
"meta",
"domain",
"title",
"crawlable",
"forwardQuery"
],
"properties": {
"shortCode": {
"type": "string",
@@ -41,6 +54,14 @@
"type": "string",
"nullable": true,
"description": "A descriptive title of the short URL."
},
"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)."
}
}
}

View File

@@ -0,0 +1,48 @@
{
"type": "object",
"properties": {
"longUrl": {
"description": "The long URL this short URL will redirect to",
"type": "string"
},
"validSince": {
"description": "The date (in ISO-8601 format) from which this short code will be valid",
"type": "string",
"nullable": true
},
"validUntil": {
"description": "The date (in ISO-8601 format) until which this short code will be valid",
"type": "string",
"nullable": true
},
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number",
"nullable": true
},
"validateUrl": {
"description": "Tells if the long URL (if provided) should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
"type": "boolean"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "The list of tags to set to the short URL."
},
"title": {
"type": "string",
"description": "A descriptive title of the short URL.",
"nullable": true
},
"crawlable": {
"type": "boolean",
"description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt."
},
"forwardQuery": {
"type": "boolean",
"description": "Tells if the query params should be forwarded from the short URL to the long one, as explained in [the docs](https://shlink.io/documentation/some-features/#query-params-forwarding)."
}
}
}

View File

@@ -17,6 +17,10 @@
},
"visitLocation": {
"$ref": "./VisitLocation.json"
},
"potentialBot": {
"type": "boolean",
"description": "Tells if Shlink thinks this visit comes potentially from a bot or crawler"
}
}
}

View File

@@ -140,7 +140,8 @@
"maxVisits": 100
},
"domain": null,
"title": "Welcome to Steam"
"title": "Welcome to Steam",
"crawlable": false
},
{
"shortCode": "12Kb3",
@@ -157,7 +158,8 @@
"maxVisits": null
},
"domain": null,
"title": null
"title": null,
"crawlable": false
},
{
"shortCode": "123bA",
@@ -172,7 +174,8 @@
"maxVisits": null
},
"domain": "example.com",
"title": null
"title": null,
"crawlable": false
}
],
"pagination": {
@@ -222,59 +225,37 @@
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"longUrl"
],
"properties": {
"longUrl": {
"description": "The URL to parse",
"type": "string"
"allOf": [
{
"$ref": "../definitions/ShortUrlEdition.json"
},
"tags": {
"description": "The URL to parse",
"type": "array",
"items": {
"type": "string"
{
"type": "object",
"required": ["longUrl"],
"properties": {
"customSlug": {
"description": "A unique custom slug to be used instead of the generated short code",
"type": "string"
},
"findIfExists": {
"description": "Will force existing matching URL to be returned if found, instead of creating a new one",
"type": "boolean"
},
"domain": {
"description": "The domain to which the short URL will be attached",
"type": "string"
},
"shortCodeLength": {
"description": "The length for generated short code. It has to be at least 4 and defaults to 5. It will be ignored when customSlug is provided",
"type": "number"
},
"validateUrl": {
"description": "Tells if the long URL should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
"type": "boolean"
}
}
},
"validSince": {
"description": "The date (in ISO-8601 format) from which this short code will be valid",
"type": "string"
},
"validUntil": {
"description": "The date (in ISO-8601 format) until which this short code will be valid",
"type": "string"
},
"customSlug": {
"description": "A unique custom slug to be used instead of the generated short code",
"type": "string"
},
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number"
},
"findIfExists": {
"description": "Will force existing matching URL to be returned if found, instead of creating a new one",
"type": "boolean"
},
"domain": {
"description": "The domain to which the short URL will be attached",
"type": "string"
},
"shortCodeLength": {
"description": "The length for generated short code. It has to be at least 4 and defaults to 5. It will be ignored when customSlug is provided",
"type": "number"
},
"validateUrl": {
"description": "Tells if the long URL should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
"type": "boolean"
},
"title": {
"type": "string",
"description": "A descriptive title of the short URL."
}
}
]
}
}
}
@@ -305,7 +286,9 @@
"validUntil": null,
"maxVisits": 500
},
"domain": null
"domain": null,
"title": null,
"crawlable": false
}
}
},

View File

@@ -74,7 +74,8 @@
"maxVisits": 100
},
"domain": null,
"title": null
"title": null,
"crawlable": false
},
"text/plain": "https://doma.in/abc123"
}

View File

@@ -54,7 +54,8 @@
"maxVisits": 100
},
"domain": null,
"title": null
"title": null,
"crawlable": false
}
}
},
@@ -111,44 +112,7 @@
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"longUrl": {
"description": "The long URL this short URL will redirect to",
"type": "string"
},
"validSince": {
"description": "The date (in ISO-8601 format) from which this short code will be valid",
"type": "string",
"nullable": true
},
"validUntil": {
"description": "The date (in ISO-8601 format) until which this short code will be valid",
"type": "string",
"nullable": true
},
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number",
"nullable": true
},
"validateUrl": {
"description": "Tells if the long URL (if provided) should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
"type": "boolean"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "The list of tags to set to the short URL."
},
"title": {
"type": "string",
"description": "A descriptive title of the short URL.",
"nullable": true
}
}
"$ref": "../definitions/ShortUrlEdition.json"
}
}
}
@@ -184,7 +148,8 @@
"maxVisits": 100
},
"domain": null,
"title": "Shlink - The URL shortener"
"title": "Shlink - The URL shortener",
"crawlable": false
}
}
},

View File

@@ -57,6 +57,16 @@
"schema": {
"type": "number"
}
},
{
"name": "excludeBots",
"in": "query",
"description": "Tells if visits from potential bots should be excluded from the result set",
"required": false,
"schema": {
"type": "string",
"enum": ["true"]
}
}
],
"security": [
@@ -98,7 +108,8 @@
"referer": "https://twitter.com",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
"visitLocation": null
"visitLocation": null,
"potentialBot": false
},
{
"referer": "https://t.co",
@@ -112,13 +123,15 @@
"longitude": -122.0946,
"regionName": "California",
"timezone": "America/Los_Angeles"
}
},
"potentialBot": false
},
{
"referer": null,
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "some_web_crawler/1.4",
"visitLocation": null
"visitLocation": null,
"potentialBot": true
}
],
"pagination": {

View File

@@ -18,7 +18,7 @@
],
"responses": {
"200": {
"description": "The list of tags",
"description": "The list of domains",
"content": {
"application/json": {
"schema": {
@@ -33,13 +33,16 @@
"type": "array",
"items": {
"type": "object",
"required": ["domain", "isDefault"],
"required": ["domain", "isDefault", "redirects"],
"properties": {
"domain": {
"type": "string"
},
"isDefault": {
"type": "boolean"
},
"redirects": {
"$ref": "../definitions/NotFoundRedirects.json"
}
}
}
@@ -56,15 +59,30 @@
"data": [
{
"domain": "example.com",
"isDefault": true
"isDefault": true,
"redirects": {
"baseUrlRedirect": "https://example.com/my-landing-page",
"regular404Redirect": null,
"invalidShortUrlRedirect": "https://example.com/invalid-url"
}
},
{
"domain": "aaa.com",
"isDefault": false
"isDefault": false,
"redirects": {
"baseUrlRedirect": null,
"regular404Redirect": null,
"invalidShortUrlRedirect": null
}
},
{
"domain": "bbb.com",
"isDefault": false
"isDefault": false,
"redirects": {
"baseUrlRedirect": null,
"regular404Redirect": null,
"invalidShortUrlRedirect": "https://example.com/invalid-url"
}
}
]
}

View File

@@ -0,0 +1,124 @@
{
"patch": {
"operationId": "setDomainRedirects",
"tags": [
"Domains"
],
"summary": "Sets domain \"not found\" redirects",
"description": "Sets the URLs that you want a visitor to get redirected to for \"not found\" URLs for a specific domain",
"security": [
{
"ApiKey": []
}
],
"parameters": [
{
"$ref": "../parameters/version.json"
}
],
"requestBody": {
"description": "Request body.",
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"allOf": [
{
"required": ["domain"],
"properties": {
"domain": {
"description": "The domain's authority for which you want to set redirects",
"type": "string"
}
}
},
{
"$ref": "../definitions/NotFoundRedirects.json"
}
]
}
}
}
},
"responses": {
"200": {
"description": "The domain's redirects after the update, when existing redirects have been merged with provided ones.",
"content": {
"application/json": {
"schema": {
"allOf": [
{
"required": ["baseUrlRedirect", "regular404Redirect", "invalidShortUrlRedirect"]
},
{
"$ref": "../definitions/NotFoundRedirects.json"
}
]
}
}
},
"examples": {
"application/json": {
"baseUrlRedirect": "https://example.com/my-landing-page",
"regular404Redirect": null,
"invalidShortUrlRedirect": "https://example.com/invalid-url"
}
}
},
"400": {
"description": "Provided data is invalid.",
"content": {
"application/problem+json": {
"schema": {
"type": "object",
"allOf": [
{
"$ref": "../definitions/Error.json"
},
{
"type": "object",
"required": ["invalidElements"],
"properties": {
"invalidElements": {
"type": "array",
"items": {
"type": "string",
"enum": [
"domain",
"baseUrlRedirect",
"regular404Redirect",
"invalidShortUrlRedirect"
]
}
}
}
}
]
}
}
}
},
"403": {
"description": "Default domain was provided, and it cannot be edited this way.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View File

@@ -54,6 +54,16 @@
"schema": {
"type": "number"
}
},
{
"name": "excludeBots",
"in": "query",
"description": "Tells if visits from potential bots should be excluded from the result set",
"required": false,
"schema": {
"type": "string",
"enum": ["true"]
}
}
],
"security": [
@@ -95,7 +105,8 @@
"referer": "https://twitter.com",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
"visitLocation": null
"visitLocation": null,
"potentialBot": false
},
{
"referer": "https://t.co",
@@ -109,13 +120,15 @@
"longitude": -122.0946,
"regionName": "California",
"timezone": "America/Los_Angeles"
}
},
"potentialBot": false
},
{
"referer": null,
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "some_web_crawler/1.4",
"visitLocation": null
"visitLocation": null,
"potentialBot": true
}
],
"pagination": {

View File

@@ -45,6 +45,16 @@
"schema": {
"type": "number"
}
},
{
"name": "excludeBots",
"in": "query",
"description": "Tells if visits from potential bots should be excluded from the result set",
"required": false,
"schema": {
"type": "string",
"enum": ["true"]
}
}
],
"security": [
@@ -87,6 +97,7 @@
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
"visitLocation": null,
"potentialBot": false,
"visitedUrl": "https://doma.in",
"type": "base_url"
},
@@ -103,6 +114,7 @@
"regionName": "California",
"timezone": "America/Los_Angeles"
},
"potentialBot": false,
"visitedUrl": "https://doma.in/foo",
"type": "invalid_short_url"
},
@@ -111,6 +123,7 @@
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "some_web_crawler/1.4",
"visitLocation": null,
"potentialBot": true,
"visitedUrl": "https://doma.in/foo/bar/baz",
"type": "regular_404"
}

View File

@@ -5,7 +5,7 @@
"URL Shortener"
],
"summary": "Short URL QR code",
"description": "Generates a QR code image pointing to a short URL",
"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": [
{
"name": "shortCode",
@@ -35,10 +35,8 @@
"required": false,
"schema": {
"type": "string",
"enum": [
"png",
"svg"
]
"enum": ["png", "svg"],
"default": "png"
}
},
{
@@ -51,6 +49,17 @@
"minimum": 0,
"default": 0
}
},
{
"name": "errorCorrection",
"in": "query",
"description": "The error correction level to apply to the 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"
}
}
],
"responses": {

View File

@@ -1,5 +1,5 @@
{
"openapi": "3.0.0",
"openapi": "3.0.3",
"info": {
"title": "Shlink",
"description": "Shlink, the self-hosted URL shortener",
@@ -102,6 +102,9 @@
"/rest/v{version}/domains": {
"$ref": "paths/v2_domains.json"
},
"/rest/v{version}/domains/redirects": {
"$ref": "paths/v2_domains_redirects.json"
},
"/rest/v{version}/mercure-info": {
"$ref": "paths/v2_mercure-info.json"

View File

@@ -15,6 +15,7 @@ return [
Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class,
Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class,
Command\Visit\DownloadGeoLiteDbCommand::NAME => Command\Visit\DownloadGeoLiteDbCommand::class,
Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class,
Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,
@@ -26,6 +27,7 @@ return [
Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class,
Command\Domain\ListDomainsCommand::NAME => Command\Domain\ListDomainsCommand::class,
Command\Domain\DomainRedirectsCommand::NAME => Command\Domain\DomainRedirectsCommand::class,
Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class,
Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class,

View File

@@ -10,6 +10,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
@@ -23,7 +24,7 @@ use Symfony\Component\Console as SymfonyCli;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Process\PhpExecutableFinder;
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
return [
@@ -44,6 +45,7 @@ return [
Command\ShortUrl\GetVisitsCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class,
Command\Visit\DownloadGeoLiteDbCommand::class => ConfigAbstractFactory::class,
Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class,
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
@@ -59,11 +61,17 @@ return [
Command\Db\MigrateDatabaseCommand::class => ConfigAbstractFactory::class,
Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class,
Command\Domain\DomainRedirectsCommand::class => ConfigAbstractFactory::class,
],
],
ConfigAbstractFactory::class => [
Util\GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY],
Util\GeolocationDbUpdater::class => [
DbUpdater::class,
Reader::class,
LOCAL_LOCK_FACTORY,
TrackingOptions::class,
],
Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class],
ApiKey\RoleResolver::class => [DomainService::class],
@@ -80,11 +88,11 @@ return [
Command\ShortUrl\GetVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
Command\Visit\DownloadGeoLiteDbCommand::class => [Util\GeolocationDbUpdater::class],
Command\Visit\LocateVisitsCommand::class => [
Visit\VisitLocator::class,
IpLocationResolverInterface::class,
LockFactory::class,
Util\GeolocationDbUpdater::class,
],
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
@@ -97,6 +105,7 @@ return [
Command\Tag\DeleteTagsCommand::class => [TagService::class],
Command\Domain\ListDomainsCommand::class => [DomainService::class],
Command\Domain\DomainRedirectsCommand::class => [DomainService::class],
Command\Db\CreateDatabaseCommand::class => [
LockFactory::class,

View File

@@ -8,13 +8,12 @@ use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Symfony\Component\Console\Input\InputInterface;
use function is_string;
class RoleResolver implements RoleResolverInterface
{
private DomainServiceInterface $domainService;
public function __construct(DomainServiceInterface $domainService)
public function __construct(private DomainServiceInterface $domainService)
{
$this->domainService = $domainService;
}
public function determineRoles(InputInterface $input): array
@@ -26,7 +25,7 @@ class RoleResolver implements RoleResolverInterface
if ($author) {
$roleDefinitions[] = RoleDefinition::forAuthoredShortUrls();
}
if ($domainAuthority !== null) {
if (is_string($domainAuthority)) {
$domain = $this->domainService->getOrCreate($domainAuthority);
$roleDefinitions[] = RoleDefinition::forDomain($domain);
}

View File

@@ -19,12 +19,9 @@ class DisableKeyCommand extends Command
{
public const NAME = 'api-key:disable';
private ApiKeyServiceInterface $apiKeyService;
public function __construct(ApiKeyServiceInterface $apiKeyService)
public function __construct(private ApiKeyServiceInterface $apiKeyService)
{
parent::__construct();
$this->apiKeyService = $apiKeyService;
}
protected function configure(): void

View File

@@ -23,14 +23,11 @@ class GenerateKeyCommand extends BaseCommand
{
public const NAME = 'api-key:generate';
private ApiKeyServiceInterface $apiKeyService;
private RoleResolverInterface $roleResolver;
public function __construct(ApiKeyServiceInterface $apiKeyService, RoleResolverInterface $roleResolver)
{
public function __construct(
private ApiKeyServiceInterface $apiKeyService,
private RoleResolverInterface $roleResolver,
) {
parent::__construct();
$this->apiKeyService = $apiKeyService;
$this->roleResolver = $roleResolver;
}
protected function configure(): void
@@ -42,6 +39,10 @@ class GenerateKeyCommand extends BaseCommand
<info>%command.full_name%</info>
You can optionally set its name for tracking purposes with <comment>--name</comment> or <comment>-m</comment>:
<info>%command.full_name% --name Alice</info>
You can optionally set its expiration date with <comment>--expiration-date</comment> or <comment>-e</comment>:
<info>%command.full_name% --expiration-date 2020-01-01</info>
@@ -56,6 +57,12 @@ class GenerateKeyCommand extends BaseCommand
$this
->setName(self::NAME)
->setDescription('Generates a new valid API key.')
->addOption(
'name',
'm',
InputOption::VALUE_REQUIRED,
'The name by which this API key will be known.',
)
->addOptionWithDeprecatedFallback(
'expiration-date',
'e',
@@ -82,6 +89,7 @@ class GenerateKeyCommand extends BaseCommand
$expirationDate = $this->getOptionWithDeprecatedFallback($input, 'expiration-date');
$apiKey = $this->apiKeyService->create(
isset($expirationDate) ? Chronos::parse($expirationDate) : null,
$input->getOption('name'),
...$this->roleResolver->determineRoles($input),
);
@@ -89,7 +97,7 @@ class GenerateKeyCommand extends BaseCommand
$io->success(sprintf('Generated API key: "%s"', $apiKey->toString()));
if (! $apiKey->isAdmin()) {
ShlinkTable::fromOutput($io)->render(
ShlinkTable::default($io)->render(
['Role name', 'Role metadata'],
$apiKey->mapRoles(fn (string $name, array $meta) => [$name, arrayToString($meta, 0)]),
null,

View File

@@ -27,12 +27,9 @@ class ListKeysCommand extends BaseCommand
public const NAME = 'api-key:list';
private ApiKeyServiceInterface $apiKeyService;
public function __construct(ApiKeyServiceInterface $apiKeyService)
public function __construct(private ApiKeyServiceInterface $apiKeyService)
{
parent::__construct();
$this->apiKeyService = $apiKeyService;
}
protected function configure(): void
@@ -57,11 +54,11 @@ class ListKeysCommand extends BaseCommand
$messagePattern = $this->determineMessagePattern($apiKey);
// Set columns for this row
$rowData = [sprintf($messagePattern, $apiKey)];
$rowData = [sprintf($messagePattern, $apiKey), sprintf($messagePattern, $apiKey->name() ?? '-')];
if (! $enabledOnly) {
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
}
$rowData[] = $expiration !== null ? $expiration->toAtomString() : '-';
$rowData[] = $expiration?->toAtomString() ?? '-';
$rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles(
fn (string $roleName, array $meta) =>
empty($meta)
@@ -72,12 +69,14 @@ class ListKeysCommand extends BaseCommand
return $rowData;
});
ShlinkTable::fromOutput($output)->render(array_filter([
ShlinkTable::withRowSeparators($output)->render(array_filter([
'Key',
'Name',
! $enabledOnly ? 'Is enabled' : null,
'Expiration date',
'Roles',
]), $rows);
return ExitCodes::EXIT_SUCCESS;
}

View File

@@ -12,40 +12,36 @@ use function Shlinkio\Shlink\Core\kebabCaseToCamelCase;
use function sprintf;
use function str_contains;
/** @deprecated */
abstract class BaseCommand extends Command
{
/**
* @param mixed|null $default
* @param string|string[]|bool|null $default
*/
protected function addOptionWithDeprecatedFallback(
string $name,
?string $shortcut = null,
?int $mode = null,
string $description = '',
$default = null
bool|string|array|null $default = null,
): self {
$this->addOption($name, $shortcut, $mode, $description, $default);
if (str_contains($name, '-')) {
$camelCaseName = kebabCaseToCamelCase($name);
$this->addOption($camelCaseName, null, $mode, sprintf('[DEPRECATED] Same as "%s".', $name), $default);
$this->addOption($camelCaseName, null, $mode, sprintf('[DEPRECATED] Alias for "%s".', $name), $default);
}
return $this;
}
/**
* @return bool|string|string[]|null
*/
protected function getOptionWithDeprecatedFallback(InputInterface $input, string $name)
// @phpstan-ignore-next-line
protected function getOptionWithDeprecatedFallback(InputInterface $input, string $name) // phpcs:ignore
{
$rawInput = method_exists($input, '__toString') ? $input->__toString() : '';
$camelCaseName = kebabCaseToCamelCase($name);
$resolvedOptionName = str_contains($rawInput, $camelCaseName) ? $camelCaseName : $name;
if (str_contains($rawInput, $camelCaseName)) {
return $input->getOption($camelCaseName);
}
return $input->getOption($name);
return $input->getOption($resolvedOptionName);
}
}

View File

@@ -13,16 +13,14 @@ use Symfony\Component\Process\PhpExecutableFinder;
abstract class AbstractDatabaseCommand extends AbstractLockedCommand
{
private ProcessRunnerInterface $processRunner;
private string $phpBinary;
public function __construct(
LockFactory $locker,
ProcessRunnerInterface $processRunner,
PhpExecutableFinder $phpFinder
private ProcessRunnerInterface $processRunner,
PhpExecutableFinder $phpFinder,
) {
parent::__construct($locker);
$this->processRunner = $processRunner;
$this->phpBinary = $phpFinder->find(false) ?: 'php';
}
@@ -34,6 +32,6 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand
protected function getLockConfig(): LockedCommandConfig
{
return LockedCommandConfig::blocking($this->getName());
return LockedCommandConfig::blocking($this->getName() ?? static::class);
}
}

View File

@@ -21,19 +21,14 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
public const DOCTRINE_SCRIPT = 'vendor/doctrine/orm/bin/doctrine.php';
public const DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create';
private Connection $regularConn;
private Connection $noDbNameConn;
public function __construct(
LockFactory $locker,
ProcessRunnerInterface $processRunner,
PhpExecutableFinder $phpFinder,
Connection $conn,
Connection $noDbNameConn
private Connection $regularConn,
private Connection $noDbNameConn,
) {
parent::__construct($locker, $processRunner, $phpFinder);
$this->regularConn = $conn;
$this->noDbNameConn = $noDbNameConn;
}
protected function configure(): void

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
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 Functional\filter;
use function Functional\invoke;
use function sprintf;
use function str_contains;
class DomainRedirectsCommand extends Command
{
public const NAME = 'domain:redirects';
public function __construct(private 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 */
$domain = $input->getArgument('domain');
if ($domain !== null) {
return;
}
$io = new SymfonyStyle($input, $output);
$askNewDomain = static fn () => $io->ask('Domain authority for which you want to set specific redirects');
/** @var string[] $availableDomains */
$availableDomains = invoke(
filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault()),
'toString',
);
if (empty($availableDomains)) {
$input->setArgument('domain', $askNewDomain());
return;
}
$selectedOption = $io->choice(
'Select the domain to configure',
[...$availableDomains, '<options=bold>New domain</>'],
);
$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');
$domain = $this->domainService->findByAuthority($domainAuthority);
$ask = static function (string $message, ?string $current) use ($io): ?string {
if ($current === null) {
return $io->ask(sprintf('%s (Leave empty for no redirect)', $message));
}
$choice = $io->choice($message, [
sprintf('Keep current one: [%s]', $current),
'Set new redirect URL',
'Remove redirect',
]);
return match ($choice) {
'Set new redirect URL' => $io->ask('New redirect URL'),
'Remove redirect' => null,
default => $current,
};
};
$this->domainService->configureNotFoundRedirects($domainAuthority, NotFoundRedirects::withRedirects(
$ask(
'URL to redirect to when a user hits this domain\'s base URL',
$domain?->baseUrlRedirect(),
),
$ask(
'URL to redirect to when a user hits a not found URL other than an invalid short URL',
$domain?->regular404Redirect(),
),
$ask(
'URL to redirect to when a user hits an invalid short URL',
$domain?->invalidShortUrlRedirect(),
),
));
$io->success(sprintf('"Not found" redirects properly set for "%s"', $domainAuthority));
return ExitCodes::EXIT_SUCCESS;
}
}

View File

@@ -6,10 +6,12 @@ namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
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\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use function Functional\map;
@@ -18,30 +20,58 @@ class ListDomainsCommand extends Command
{
public const NAME = 'domain:list';
private DomainServiceInterface $domainService;
public function __construct(DomainServiceInterface $domainService)
public function __construct(private DomainServiceInterface $domainService)
{
parent::__construct();
$this->domainService = $domainService;
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('List all domains that have been ever used for some short URL');
->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
{
$domains = $this->domainService->listDomains();
$showRedirects = $input->getOption('show-redirects');
$commonFields = ['Domain', 'Is default'];
$table = $showRedirects ? ShlinkTable::withRowSeparators($output) : ShlinkTable::default($output);
ShlinkTable::fromOutput($output)->render(
['Domain', 'Is default'],
map($domains, fn (DomainItem $domain) => [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No']),
$table->render(
$showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields,
map($domains, function (DomainItem $domain) use ($showRedirects) {
$commonValues = [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No'];
return $showRedirects
? [
...$commonValues,
$this->notFoundRedirectsToString($domain->notFoundRedirectConfig()),
]
: $commonValues;
}),
);
return ExitCodes::EXIT_SUCCESS;
}
private function notFoundRedirectsToString(NotFoundRedirectConfigInterface $config): string
{
$baseUrl = $config->baseUrlRedirect() ?? 'N/A';
$regular404 = $config->regular404Redirect() ?? 'N/A';
$invalidShortUrl = $config->invalidShortUrlRedirect() ?? 'N/A';
return <<<EOL
* Base URL: {$baseUrl}
* Regular 404: {$regular404}
* Invalid short URL: {$invalidShortUrl}
EOL;
}
}

View File

@@ -21,12 +21,9 @@ class DeleteShortUrlCommand extends Command
{
public const NAME = 'short-url:delete';
private DeleteShortUrlServiceInterface $deleteShortUrlService;
public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService)
public function __construct(private DeleteShortUrlServiceInterface $deleteShortUrlService)
{
parent::__construct();
$this->deleteShortUrlService = $deleteShortUrlService;
}
protected function configure(): void

View File

@@ -30,19 +30,12 @@ class GenerateShortUrlCommand extends BaseCommand
{
public const NAME = 'short-url:generate';
private UrlShortenerInterface $urlShortener;
private ShortUrlStringifierInterface $stringifier;
private int $defaultShortCodeLength;
public function __construct(
UrlShortenerInterface $urlShortener,
ShortUrlStringifierInterface $stringifier,
int $defaultShortCodeLength
private UrlShortenerInterface $urlShortener,
private ShortUrlStringifierInterface $stringifier,
private int $defaultShortCodeLength,
) {
parent::__construct();
$this->urlShortener = $urlShortener;
$this->stringifier = $stringifier;
$this->defaultShortCodeLength = $defaultShortCodeLength;
}
protected function configure(): void
@@ -111,7 +104,19 @@ class GenerateShortUrlCommand extends BaseCommand
'no-validate-url',
null,
InputOption::VALUE_NONE,
'Forces the long URL to not be validated, regardless what is globally configured.',
'[DEPRECATED] Forces the long URL to not be validated, regardless what is globally configured.',
)
->addOption(
'crawlable',
'r',
InputOption::VALUE_NONE,
'Tells if this URL will be included as "Allow" in Shlink\'s robots.txt.',
)
->addOption(
'no-forward-query',
'w',
InputOption::VALUE_NONE,
'Disables the forwarding of the query string to the long URL, when the new short URL is visited.',
);
}
@@ -163,6 +168,8 @@ class GenerateShortUrlCommand extends BaseCommand
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
ShortUrlInputFilter::VALIDATE_URL => $doValidateUrl,
ShortUrlInputFilter::TAGS => $tags,
ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'),
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
]));
$io->writeln([

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
@@ -21,17 +20,15 @@ use Symfony\Component\Console\Style\SymfonyStyle;
use function Functional\map;
use function Functional\select_keys;
use function Shlinkio\Shlink\Common\buildDateRange;
use function sprintf;
class GetVisitsCommand extends AbstractWithDateRangeCommand
{
public const NAME = 'short-url:visits';
private VisitsStatsHelperInterface $visitsHelper;
public function __construct(VisitsStatsHelperInterface $visitsHelper)
public function __construct(private VisitsStatsHelperInterface $visitsHelper)
{
$this->visitsHelper = $visitsHelper;
parent::__construct();
}
@@ -76,7 +73,7 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
$paginator = $this->visitsHelper->visitsForShortUrl(
$identifier,
new VisitsParams(new DateRange($startDate, $endDate)),
new VisitsParams(buildDateRange($startDate, $endDate)),
);
$rows = map($paginator->getCurrentPageResults(), function (Visit $visit) {
@@ -84,7 +81,7 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
$rowData['country'] = ($visit->getVisitLocation() ?? new UnknownVisitLocation())->getCountryName();
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
});
ShlinkTable::fromOutput($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
ShlinkTable::default($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
return ExitCodes::EXIT_SUCCESS;
}

View File

@@ -10,6 +10,7 @@ use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
@@ -19,6 +20,7 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_keys;
use function array_pad;
use function explode;
use function Functional\map;
@@ -30,27 +32,12 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
use PagerfantaUtilsTrait;
public const NAME = 'short-url:list';
private const COLUMNS_TO_SHOW = [
'shortCode',
'title',
'shortUrl',
'longUrl',
'dateCreated',
'visitsCount',
];
private const COLUMNS_TO_SHOW_WITH_TAGS = [
...self::COLUMNS_TO_SHOW,
'tags',
];
private ShortUrlServiceInterface $shortUrlService;
private DataTransformerInterface $transformer;
public function __construct(ShortUrlServiceInterface $shortUrlService, DataTransformerInterface $transformer)
{
public function __construct(
private ShortUrlServiceInterface $shortUrlService,
private DataTransformerInterface $transformer,
) {
parent::__construct();
$this->shortUrlService = $shortUrlService;
$this->transformer = $transformer;
}
protected function doConfigure(): void
@@ -90,6 +77,18 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
InputOption::VALUE_NONE,
'Whether to display the tags or not.',
)
->addOption(
'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(
'all',
'a',
@@ -117,18 +116,18 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
$searchTerm = $this->getOptionWithDeprecatedFallback($input, 'search-term');
$tags = $input->getOption('tags');
$tags = ! empty($tags) ? explode(',', $tags) : [];
$showTags = $this->getOptionWithDeprecatedFallback($input, 'show-tags');
$all = $input->getOption('all');
$startDate = $this->getStartDateOption($input, $output);
$endDate = $this->getEndDateOption($input, $output);
$orderBy = $this->processOrderBy($input);
$columnsMap = $this->resolveColumnsMap($input);
$data = [
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
ShortUrlsParamsInputFilter::TAGS => $tags,
ShortUrlsOrdering::ORDER_BY => $orderBy,
ShortUrlsParamsInputFilter::START_DATE => $startDate !== null ? $startDate->toAtomString() : null,
ShortUrlsParamsInputFilter::END_DATE => $endDate !== null ? $endDate->toAtomString() : null,
ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(),
ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(),
];
if ($all) {
@@ -137,7 +136,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
do {
$data[ShortUrlsParamsInputFilter::PAGE] = $page;
$result = $this->renderPage($output, $showTags, ShortUrlsParams::fromRawData($data), $all);
$result = $this->renderPage($output, $columnsMap, ShortUrlsParams::fromRawData($data), $all);
$page++;
$continue = $result->hasNextPage() && $io->confirm(
@@ -152,32 +151,26 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
return ExitCodes::EXIT_SUCCESS;
}
private function renderPage(OutputInterface $output, bool $showTags, ShortUrlsParams $params, bool $all): Paginator
{
$result = $this->shortUrlService->listShortUrls($params);
private function renderPage(
OutputInterface $output,
array $columnsMap,
ShortUrlsParams $params,
bool $all,
): Paginator {
$shortUrls = $this->shortUrlService->listShortUrls($params);
$headers = ['Short code', 'Title', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
if ($showTags) {
$headers[] = 'Tags';
}
$rows = map($shortUrls, function (ShortUrl $shortUrl) use ($columnsMap) {
$rawShortUrl = $this->transformer->transform($shortUrl);
return map($columnsMap, fn (callable $call) => $call($rawShortUrl, $shortUrl));
});
$rows = [];
foreach ($result as $row) {
$columnsToShow = $showTags ? self::COLUMNS_TO_SHOW_WITH_TAGS : self::COLUMNS_TO_SHOW;
$shortUrl = $this->transformer->transform($row);
if ($showTags) {
$shortUrl['tags'] = implode(', ', $shortUrl['tags']);
}
ShlinkTable::default($output)->render(
array_keys($columnsMap),
$rows,
$all ? null : $this->formatCurrentPageMessage($shortUrls, 'Page %s of %s'),
);
$rows[] = map($columnsToShow, fn (string $prop) => $shortUrl[$prop]);
}
ShlinkTable::fromOutput($output)->render($headers, $rows, $all ? null : $this->formatCurrentPageMessage(
$result,
'Page %s of %s',
));
return $result;
return $shortUrls;
}
private function processOrderBy(InputInterface $input): ?string
@@ -190,4 +183,30 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
[$field, $dir] = array_pad(explode(',', $orderBy), 2, null);
return $dir === null ? $field : sprintf('%s-%s', $field, $dir);
}
private function resolveColumnsMap(InputInterface $input): array
{
$pickProp = static fn (string $prop): callable => static fn (array $shortUrl) => $shortUrl[$prop];
$columnsMap = [
'Short Code' => $pickProp('shortCode'),
'Title' => $pickProp('title'),
'Short URL' => $pickProp('shortUrl'),
'Long URL' => $pickProp('longUrl'),
'Date created' => $pickProp('dateCreated'),
'Visits count' => $pickProp('visitsCount'),
];
if ($this->getOptionWithDeprecatedFallback($input, 'show-tags')) {
$columnsMap['Tags'] = static fn (array $shortUrl): string => implode(', ', $shortUrl['tags']);
}
if ($input->getOption('show-api-key')) {
$columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string =>
(string) $shortUrl->authorApiKey();
}
if ($input->getOption('show-api-key-name')) {
$columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): ?string =>
$shortUrl->authorApiKey()?->name();
}
return $columnsMap;
}
}

View File

@@ -21,12 +21,9 @@ class ResolveUrlCommand extends Command
{
public const NAME = 'short-url:parse';
private ShortUrlResolverInterface $urlResolver;
public function __construct(ShortUrlResolverInterface $urlResolver)
public function __construct(private ShortUrlResolverInterface $urlResolver)
{
parent::__construct();
$this->urlResolver = $urlResolver;
}
protected function configure(): void

View File

@@ -17,12 +17,9 @@ class CreateTagCommand extends Command
{
public const NAME = 'tag:create';
private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService)
public function __construct(private TagServiceInterface $tagService)
{
parent::__construct();
$this->tagService = $tagService;
}
protected function configure(): void

View File

@@ -16,12 +16,9 @@ class DeleteTagsCommand extends Command
{
public const NAME = 'tag:delete';
private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService)
public function __construct(private TagServiceInterface $tagService)
{
parent::__construct();
$this->tagService = $tagService;
}
protected function configure(): void

View File

@@ -18,12 +18,9 @@ class ListTagsCommand extends Command
{
public const NAME = 'tag:list';
private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService)
public function __construct(private TagServiceInterface $tagService)
{
parent::__construct();
$this->tagService = $tagService;
}
protected function configure(): void
@@ -35,7 +32,7 @@ class ListTagsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
ShlinkTable::fromOutput($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
ShlinkTable::default($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
return ExitCodes::EXIT_SUCCESS;
}

View File

@@ -19,12 +19,9 @@ class RenameTagCommand extends Command
{
public const NAME = 'tag:rename';
private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService)
public function __construct(private TagServiceInterface $tagService)
{
parent::__construct();
$this->tagService = $tagService;
}
protected function configure(): void

View File

@@ -14,12 +14,9 @@ use function sprintf;
abstract class AbstractLockedCommand extends Command
{
private LockFactory $locker;
public function __construct(LockFactory $locker)
public function __construct(private LockFactory $locker)
{
parent::__construct();
$this->locker = $locker;
}
final protected function execute(InputInterface $input, OutputInterface $output): ?int

View File

@@ -11,6 +11,7 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use function is_string;
use function sprintf;
abstract class AbstractWithDateRangeCommand extends BaseCommand
@@ -49,7 +50,7 @@ abstract class AbstractWithDateRangeCommand extends BaseCommand
private function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos
{
$value = $this->getOptionWithDeprecatedFallback($input, $key);
if (empty($value)) {
if (empty($value) || ! is_string($value)) {
return null;
}
@@ -63,7 +64,7 @@ abstract class AbstractWithDateRangeCommand extends BaseCommand
));
if ($output->isVeryVerbose()) {
$this->getApplication()->renderThrowable($e, $output);
$this->getApplication()?->renderThrowable($e, $output);
}
return null;

View File

@@ -8,15 +8,11 @@ final class LockedCommandConfig
{
public const DEFAULT_TTL = 600.0; // 10 minutes
private string $lockName;
private bool $isBlocking;
private float $ttl;
private function __construct(string $lockName, bool $isBlocking, float $ttl = self::DEFAULT_TTL)
{
$this->lockName = $lockName;
$this->isBlocking = $isBlocking;
$this->ttl = $ttl;
private function __construct(
private string $lockName,
private bool $isBlocking,
private float $ttl = self::DEFAULT_TTL,
) {
}
public static function blocking(string $lockName): self

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
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
{
public const NAME = 'visit:download-db';
private ?ProgressBar $progressBar = null;
public function __construct(private GeolocationDbUpdaterInterface $dbUpdater)
{
parent::__construct();
}
protected function configure(): void
{
$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);
try {
$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);
});
if ($this->progressBar === null) {
$io->info('GeoLite2 db file is up to date.');
} else {
$this->progressBar->finish();
$io->success('GeoLite2 db file properly downloaded.');
}
return ExitCodes::EXIT_SUCCESS;
} catch (GeolocationDbUpdateFailedException $e) {
$olderDbExists = $e->olderDbExists();
if ($olderDbExists) {
$io->warning(
'GeoLite2 db file update failed. Visits will continue to be located with the old version.',
);
} else {
$io->error('GeoLite2 db file download failed. It will not be possible to locate visits.');
}
if ($io->isVerbose()) {
$this->getApplication()?->renderThrowable($e, $io);
}
return $olderDbExists ? ExitCodes::EXIT_WARNING : ExitCodes::EXIT_FAILURE;
}
}
}

View File

@@ -6,9 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
@@ -19,7 +17,6 @@ use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
@@ -33,30 +30,23 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
{
public const NAME = 'visit:locate';
private VisitLocatorInterface $visitLocator;
private IpLocationResolverInterface $ipLocationResolver;
private GeolocationDbUpdaterInterface $dbUpdater;
private SymfonyStyle $io;
private ?ProgressBar $progressBar = null;
public function __construct(
VisitLocatorInterface $visitLocator,
IpLocationResolverInterface $ipLocationResolver,
private VisitLocatorInterface $visitLocator,
private IpLocationResolverInterface $ipLocationResolver,
LockFactory $locker,
GeolocationDbUpdaterInterface $dbUpdater
) {
parent::__construct($locker);
$this->visitLocator = $visitLocator;
$this->ipLocationResolver = $ipLocationResolver;
$this->dbUpdater = $dbUpdater;
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Resolves visits origin locations.')
->setDescription(
'Resolves visits origin locations. It implicitly downloads/updates the GeoLite2 db file if needed.',
)
->addOption(
'retry',
'r',
@@ -90,12 +80,12 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
);
}
if ($all && $retry && ! $this->warnAndVerifyContinue()) {
if ($all && $retry && ! $this->warnAndVerifyContinue($input)) {
throw new RuntimeException('Execution aborted');
}
}
private function warnAndVerifyContinue(): bool
private function warnAndVerifyContinue(InputInterface $input): bool
{
$this->io->warning([
'You are about to process the location of all existing visits your short URLs received.',
@@ -113,7 +103,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
$all = $retry && $input->getOption('all');
try {
$this->checkDbUpdate();
$this->checkDbUpdate($input);
if ($all) {
$this->visitLocator->locateAllVisits($this);
@@ -128,8 +118,8 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
return ExitCodes::EXIT_SUCCESS;
} catch (Throwable $e) {
$this->io->error($e->getMessage());
if ($e instanceof Throwable && $this->io->isVerbose()) {
$this->getApplication()->renderThrowable($e, $this->io);
if ($this->io->isVerbose()) {
$this->getApplication()?->renderThrowable($e, $this->io);
}
return ExitCodes::EXIT_FAILURE;
@@ -149,7 +139,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
throw IpCannotBeLocatedException::forEmptyAddress();
}
$ipAddr = $visit->getRemoteAddr();
$ipAddr = $visit->getRemoteAddr() ?? '';
$this->io->write(sprintf('Processing IP <fg=blue>%s</>', $ipAddr));
if ($ipAddr === IpAddress::LOCALHOST) {
$this->io->writeln(' [<comment>Ignored localhost address</comment>]');
@@ -161,7 +151,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
} catch (WrongIpException $e) {
$this->io->writeln(' [<fg=red>An error occurred while locating IP. Skipped</>]');
if ($this->io->isVerbose()) {
$this->getApplication()->renderThrowable($e, $this->io);
$this->getApplication()?->renderThrowable($e, $this->io);
}
throw IpCannotBeLocatedException::forError($e);
@@ -176,38 +166,23 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
$this->io->writeln($message);
}
private function checkDbUpdate(): void
private function checkDbUpdate(InputInterface $input): void
{
try {
$this->dbUpdater->checkDbUpdate(function (bool $olderDbExists): void {
$this->io->writeln(
sprintf('<fg=blue>%s GeoLite2 database...</>', $olderDbExists ? 'Updating' : 'Downloading'),
);
$this->progressBar = new ProgressBar($this->io);
}, function (int $total, int $downloaded): void {
$this->progressBar->setMaxSteps($total);
$this->progressBar->setProgress($downloaded);
});
$cliApp = $this->getApplication();
if ($cliApp === null) {
return;
}
if ($this->progressBar !== null) {
$this->progressBar->finish();
$this->io->newLine();
}
} catch (GeolocationDbUpdateFailedException $e) {
if (! $e->olderDbExists()) {
$this->io->error('GeoLite2 database download failed. It is not possible to locate visits.');
throw $e;
}
$downloadDbCommand = $cliApp->find(DownloadGeoLiteDbCommand::NAME);
$exitCode = $downloadDbCommand->run($input, $this->io);
$this->io->newLine();
$this->io->writeln(
'<fg=yellow;options=bold>[Warning] GeoLite2 database update failed. Proceeding with old version.</>',
);
if ($exitCode === ExitCodes::EXIT_FAILURE) {
throw new RuntimeException('It is not possible to locate visits without a GeoLite2 db file.');
}
}
protected function getLockConfig(): LockedCommandConfig
{
return LockedCommandConfig::nonBlocking($this->getName());
return LockedCommandConfig::nonBlocking(self::NAME);
}
}

View File

@@ -13,6 +13,11 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
{
private bool $olderDbExists;
private function __construct(string $message, int $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
public static function withOlderDb(?Throwable $prev = null): self
{
$e = new self(
@@ -37,10 +42,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
return $e;
}
/**
* @param mixed $buildEpoch
*/
public static function withInvalidEpochInOldDb($buildEpoch): self
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.',

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