Compare commits

..

328 Commits

Author SHA1 Message Date
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
Alejandro Celaya
3d99fc1708 Merge pull request #1023 from shlinkio/develop
Release 2.6.0
2021-02-13 18:04:09 +01:00
Alejandro Celaya
656346bd04 Ensured mezzio-swoole config provider is dynamically loaded 2021-02-13 17:48:03 +01:00
Alejandro Celaya
6b5217ece2 Added v2.6.0 to changelog 2021-02-13 15:33:56 +01:00
Alejandro Celaya
0a2b388f6b Updated to stable shlink-installer 5.4 2021-02-13 14:57:15 +01:00
Alejandro Celaya
25b3de84ec Fixed pattern to resolve release artifacts 2021-02-13 14:33:36 +01:00
Alejandro Celaya
5c4e348078 Ensured repo si cloned durin publish workflow 2021-02-13 14:18:49 +01:00
Alejandro Celaya
2ac84ac8c4 Ensured generated dist files do not conflict 2021-02-13 14:12:38 +01:00
Alejandro Celaya
f0249346b0 Fixed version numbers 2021-02-13 14:05:31 +01:00
Alejandro Celaya
86651d7992 Merge pull request #1022 from acelaya-forks/feature/mutiple-dist-files
Feature/mutiple dist files
2021-02-13 14:04:42 +01:00
Alejandro Celaya
5cd5fb0071 Updated changelog 2021-02-13 13:49:53 +01:00
Alejandro Celaya
e3bf046c30 Documented new system with multiple dist files 2021-02-13 13:44:52 +01:00
Alejandro Celaya
d9af0a5547 Improved publish-release workflow to generate files for all supported PHP versions and with/without swoole 2021-02-13 13:29:38 +01:00
Alejandro Celaya
ede7551856 Updated build script so that it allows building a dist file for non-swoole envs 2021-02-13 12:56:41 +01:00
Alejandro Celaya
a2030b6c27 Updated to shlink-event-dispatcher 2.1 2021-02-13 11:39:51 +01:00
Alejandro Celaya
9a951589dc Updated year in license 2021-02-13 09:38:34 +01:00
Alejandro Celaya
c766cfad89 Updated to shlink-common 3.5 2021-02-12 23:40:29 +01:00
Alejandro Celaya
bd25572e08 Merge pull request #1021 from acelaya-forks/feature/migrate-command-timeout
Feature/migrate command timeout
2021-02-12 23:35:46 +01:00
Alejandro Celaya
4e00c950cc Created ProcessRunnerTest 2021-02-12 23:23:34 +01:00
Alejandro Celaya
d932f0a204 Increased timeout on db commands to 10 minutes 2021-02-12 22:59:40 +01:00
Alejandro Celaya
08507272ed Merge pull request #1019 from acelaya-forks/feature/simplified-content-length
Removed mezzio-helpers and used ContentLengthMiddleware from shlink-c…
2021-02-12 09:55:20 +01:00
Alejandro Celaya
9c48e6578d Removed mezzio-helpers and used ContentLengthMiddleware from shlink-common 2021-02-12 09:24:13 +01:00
Alejandro Celaya
db6c83eefd Merge pull request #1017 from acelaya-forks/feature/not-found-tracking
Feature/not found tracking
2021-02-11 22:55:08 +01:00
Alejandro Celaya
cc68cb944f Updated changelog 2021-02-11 22:43:23 +01:00
Alejandro Celaya
a0d8d237d7 Gitignored helper file 2021-02-11 22:23:30 +01:00
Alejandro Celaya
7d6d8e3a68 Added support to publish orphan visits in mercure 2021-02-11 22:12:38 +01:00
Alejandro Celaya
cc42f037c7 Merge branch 'develop' into feature/not-found-tracking 2021-02-11 13:53:21 +01:00
Alejandro Celaya
f4623ed028 Merge branch 'develop' of github.com:shlinkio/shlink into develop 2021-02-11 13:52:58 +01:00
Alejandro Celaya
bec467c703 Fixed issue with swoole 4.6.3 2021-02-11 13:52:36 +01:00
Alejandro Celaya
bd09b1571a Updated shlink-installer with support for orphan visits tracking option 2021-02-10 20:42:42 +01:00
Alejandro Celaya
3ed6953d0b Merge branch 'develop' of github.com:shlinkio/shlink into feature/not-found-tracking 2021-02-10 20:26:33 +01:00
Alejandro Celaya
2fc6fb0a9a Added option to disable orphan visitstracking 2021-02-10 20:09:25 +01:00
Alejandro Celaya
4b73bd907e Updated changelog 2021-02-10 08:23:29 +01:00
Alejandro Celaya
a18486cc2e Created OrphanVisits API test 2021-02-09 23:56:46 +01:00
Alejandro Celaya
82f4e22f69 Created OrphanVisitsActionTest 2021-02-09 23:41:51 +01:00
Alejandro Celaya
3497165ebd Created OrphanVisitsPaginatorAdapterTest 2021-02-09 23:34:29 +01:00
Alejandro Celaya
d5794a3dcb Created OrphanVisitDataTransformerTest 2021-02-09 23:09:42 +01:00
Alejandro Celaya
bd9ec53e7b Added test for VisitsStatsHelper::orphanVisits 2021-02-09 23:09:42 +01:00
Alejandro Celaya
5d98316c4e Created new REST API action to list orphan visits 2021-02-09 23:09:42 +01:00
Alejandro Celaya
dcf2526aad Documented swagger for new orphan visits endpoint 2021-02-09 23:09:42 +01:00
Alejandro Celaya
85dd023c0e Created methods to get orphan visits lists 2021-02-09 23:09:42 +01:00
Alejandro Celaya
1fbcb44136 Enhanced VisitsTrackerTest 2021-02-09 23:09:42 +01:00
Alejandro Celaya
ab9042db24 Ensured orphan visits are located ASAP when using swoole 2021-02-09 23:09:42 +01:00
Alejandro Celaya
b01487ac91 Ensured IP address is resolved when tracking orphan visits 2021-02-09 23:09:42 +01:00
Alejandro Celaya
5278d7668c Added orphan visits count to visits stats endpoint 2021-02-09 23:09:42 +01:00
Alejandro Celaya
f7215fc2c5 Documented ADR decision outcome 2021-02-09 23:09:42 +01:00
Alejandro Celaya
d2e0413a48 Added NotFoundTrackerMiddlewareTest 2021-02-09 23:09:42 +01:00
Alejandro Celaya
0e165bc7e0 Created NotFoundTypeResolverMiddlewareTest 2021-02-09 23:09:42 +01:00
Alejandro Celaya
55e7f7ccb0 Improved VisitRepository tests 2021-02-09 23:09:42 +01:00
Alejandro Celaya
15061d3e0d Created new middlewares to track not found visits 2021-02-09 23:09:42 +01:00
Alejandro Celaya
36be44e7b5 Moved VisitsTracker service to Visit namespace 2021-02-09 23:09:42 +01:00
Alejandro Celaya
1b4e62b823 Separated methods to track visits and list visits 2021-02-09 23:09:42 +01:00
Alejandro Celaya
12b07bb0ac Created named constructors for Visit entity and added tracking of the visited URL 2021-02-09 23:09:42 +01:00
Alejandro Celaya
f5666c9451 Added new columns for extra tracking in visits table 2021-02-09 23:09:42 +01:00
Alejandro Celaya
23cffce861 Updated Visit entity so that the short URL is nullable 2021-02-09 23:09:42 +01:00
Alejandro Celaya
a1fb44f2a6 Added ADR for not-found visits tracking 2021-02-09 23:09:42 +01:00
Alejandro Celaya
4d5dd8c8de Merge pull request #1012 from acelaya-forks/feature/swoole-update
Updated to swoole 4.6.3
2021-02-09 23:08:42 +01:00
Alejandro Celaya
1c492881e1 Updated to swoole 4.6.3 2021-02-09 22:55:30 +01:00
Alejandro Celaya
d310c53cce Merge pull request #1007 from acelaya-forks/feature/php-8.0.2
Updated docker images to PHP 8.0.2
2021-02-07 10:17:51 +01:00
Alejandro Celaya
2289eebd91 Updated docker images to PHP 8.0.2 2021-02-07 09:24:01 +01:00
Alejandro Celaya
e259bd62ab Merge pull request #1006 from acelaya-forks/feature/qr-margin
Feature/qr margin
2021-02-07 08:49:10 +01:00
Alejandro Celaya
9f512705fa Documented margin param on QR code endpoint 2021-02-07 08:35:52 +01:00
Alejandro Celaya
383fde488b Added support to define the margin when generating the QR codes 2021-02-07 08:32:12 +01:00
Alejandro Celaya
b54350674c Merge pull request #1005 from acelaya-forks/feature/fix-string-epoch
Feature/fix string epoch
2021-02-06 22:23:09 +01:00
Alejandro Celaya
1e2b88496c Updated changelog 2021-02-06 21:51:05 +01:00
Alejandro Celaya
919b567d46 Added tests covering new logic to parse GeolLite 2 build epoch param 2021-02-06 21:49:49 +01:00
Alejandro Celaya
da65c05c4f Added double check when parsing build epoch from the GeoLite db file in case it is not an integer 2021-02-06 21:38:09 +01:00
Alejandro Celaya
2f8ca6cf11 Merge pull request #1004 from acelaya-forks/feature/import-csv
Feature/import csv
2021-02-06 21:04:23 +01:00
Alejandro Celaya
7121ff340a Updated changelog 2021-02-06 20:47:26 +01:00
Alejandro Celaya
37f4d18d34 Updated to shlink-importer v2.2 2021-02-06 20:45:45 +01:00
Alejandro Celaya
a8b424003c Merge pull request #1003 from acelaya-forks/feature/title
Feature/title
2021-02-05 18:54:22 +01:00
Alejandro Celaya
de4e677f18 Fixed database started for API tests in GitHub workflow 2021-02-05 18:33:36 +01:00
Alejandro Celaya
bc632fd644 Updated changelog 2021-02-05 18:26:22 +01:00
Alejandro Celaya
d386e1405c Ensure request is not performed if both title resolution and URL validation are disabled 2021-02-05 18:22:54 +01:00
Alejandro Celaya
608742c2e2 Added helper service to avoid code duplication when resolving short URLs titles 2021-02-05 17:59:34 +01:00
Alejandro Celaya
71e91a541f Allowed to resolve title during short URL edition if it has to 2021-02-04 23:02:26 +01:00
Alejandro Celaya
ed18f10b94 Added support to order short URLs by title 2021-02-04 22:07:54 +01:00
Alejandro Celaya
4330a09793 Removed use of deprecated approach for ordering in ListShort 2021-02-04 21:33:26 +01:00
Alejandro Celaya
16873201e9 Added support to search short URLs by title 2021-02-04 21:27:16 +01:00
Alejandro Celaya
2640c7b43c Updated to a shlink-importer version that supports titles 2021-02-04 15:24:27 +01:00
Alejandro Celaya
7824dddef7 Added tracking to tell if short URL titles were autogenerated or not 2021-02-03 19:22:47 +01:00
Alejandro Celaya
7192480751 Update installer version 2021-02-03 18:26:50 +01:00
Alejandro Celaya
1da66f272c Added AUTO_RESOLVE_TITLES env var for the docker image 2021-02-03 13:41:37 +01:00
Alejandro Celaya
0ef1e347e7 Enhanced UrlShortenerTest 2021-02-03 13:28:51 +01:00
Alejandro Celaya
bfba05c863 Enhanced UrlValidatorTest 2021-02-03 11:53:08 +01:00
Alejandro Celaya
71f85350da Fixed regex to parse title from URL to consider possible attributes 2021-02-03 11:28:40 +01:00
Alejandro Celaya
8b54098299 Added option to automatically resolve url titles 2021-02-03 11:07:47 +01:00
Alejandro Celaya
356e68ca3e Documented new title prop in swagger docs 2021-02-02 21:20:09 +01:00
Alejandro Celaya
430c407106 Added support for an optional title field in short URLs 2021-02-02 21:20:09 +01:00
Alejandro Celaya
31a7212a71 Improvements in CONTRIBUTING doc 2021-02-02 21:19:38 +01:00
Alejandro Celaya
36a172308a Merge pull request #998 from acelaya-forks/feature/fix-base-path-with-domain
Feature/fix base path with domain
2021-02-01 23:32:16 +01:00
Alejandro Celaya
e20df481a4 Updated changelog 2021-02-01 23:20:48 +01:00
Alejandro Celaya
8fa0c95f5a Ensured base path is honored when stringifying short URLs with a custom domain 2021-02-01 23:18:52 +01:00
Alejandro Celaya
4b4a859722 Created ShortUrlStringifierTest 2021-02-01 23:18:52 +01:00
Alejandro Celaya
9cddedcdba Extracted logic to stringify ShortUrls to its own service 2021-02-01 23:18:52 +01:00
Alejandro Celaya
01aebd90d5 Added 988 link in changelog 2021-02-01 10:45:31 +01:00
Alejandro Celaya
c00105607c Merge pull request #997 from Roy-Orbison/patch-1
Allow serving of 0-byte, real files
2021-02-01 10:36:49 +01:00
Roy-Orbison
79ff12a1b0 Allow serving of 0-byte, real files
Essential for many HTTP challenges for domain verification, SSL cert issuance, etc.
2021-02-01 14:47:11 +10:30
Alejandro Celaya
e30c9c86ff Merge pull request #995 from acelaya-forks/feature/improve-url-relations
Feature/improve url relations
2021-01-31 16:26:50 +01:00
Alejandro Celaya
c61e1e1c0e Updated EditShortUrlAction so that it returns the parsed short URL instead of an empty response 2021-01-31 13:21:23 +01:00
Alejandro Celaya
85bc5ce595 Moved transformer to constructor in some actions, to avoid creating it over and over 2021-01-31 13:12:56 +01:00
Alejandro Celaya
ef12e90ae7 Removed non-used deprecated method and added missing tests 2021-01-31 13:05:21 +01:00
Alejandro Celaya
6b0f6e4541 Updated changelog 2021-01-31 12:27:35 +01:00
Alejandro Celaya
cdfd14e63f Deprecated action and endpoint to edit short URL tags 2021-01-31 12:24:26 +01:00
Alejandro Celaya
977058d219 Updated short URL edition so that it supports editing tags 2021-01-31 12:12:21 +01:00
Alejandro Celaya
c58fa586e1 Removed use of deprecated methods in DB tests 2021-01-31 11:51:00 +01:00
Alejandro Celaya
1cd6fdeede Centralized logic to normalize tag names and removed references to deprecated setTags method in unit tests 2021-01-31 11:09:00 +01:00
Alejandro Celaya
09f25d78b7 Refactored API tests fixtures to avoid using deprecated methods 2021-01-31 11:01:38 +01:00
Alejandro Celaya
82091c7951 Added logic to resolve tags during short URL creation through ShortUrlRelationResolver 2021-01-31 10:53:18 +01:00
Alejandro Celaya
1081211439 Merge pull request #994 from acelaya-forks/feature/input-filter-improvements
Renamed ShortUrlInputFilter and added named constructors to it
2021-01-31 08:18:17 +01:00
Alejandro Celaya
7e90fd45a7 Renamed ShortUrlInputFilter and added named constructors to it 2021-01-31 07:47:58 +01:00
Alejandro Celaya
08f4a424e6 Merge pull request #993 from acelaya-forks/feature/short-url-meta-refactoring
Feature/short url meta refactoring
2021-01-30 23:26:49 +01:00
Alejandro Celaya
063ee9c195 Inlcuded tags as part of the ShortUrlMeta 2021-01-30 19:17:12 +01:00
Alejandro Celaya
3f2bd657e1 Used input factory methods from shlink-common when possible 2021-01-30 18:58:39 +01:00
Alejandro Celaya
903ef8e249 Normalized some filtering 2021-01-30 18:24:14 +01:00
Alejandro Celaya
07b12fac3c Refactored short URL creation so that the long URL is part of the ShortUrlMeta 2021-01-30 14:18:44 +01:00
Alejandro Celaya
56a2253535 Merge pull request #992 from acelaya-forks/feature/kebab-case-cli
Feature/kebab case cli
2021-01-30 11:37:24 +01:00
Alejandro Celaya
752ded2f80 Changed to kebab-case for CLI flags in command tests 2021-01-30 11:25:20 +01:00
Alejandro Celaya
248d5e2fe5 Updated changelog 2021-01-30 11:19:21 +01:00
Alejandro Celaya
158e981970 Deprecated camelCase options in rest of CLI commands 2021-01-30 11:17:13 +01:00
Alejandro Celaya
96d07c4b4e Deprecated camelCase options in some CLI commands 2021-01-30 10:54:11 +01:00
Alejandro Celaya
28afb8944f Merge pull request #991 from acelaya-forks/feature/php8-dockers
Feature/php8 dockers
2021-01-30 10:09:31 +01:00
Alejandro Celaya
0d59ebfe55 Recovered ARG to ENV in Dockerfile 2021-01-30 10:08:33 +01:00
Alejandro Celaya
bc38ecf6de Fixed image which checks if Dockerfile changed by making sure it fetches more commits 2021-01-30 09:54:47 +01:00
Alejandro Celaya
755a52b78e Updated official docker image to PHP 8 2021-01-30 09:45:47 +01:00
Alejandro Celaya
4c008f1672 Updated dev docker images to PHP 8 2021-01-30 09:31:08 +01:00
Alejandro Celaya
eb268fb856 Updated changelog 2021-01-24 23:26:28 +01:00
Alejandro Celaya
b0e390ced1 Merge pull request #985 from acelaya-forks/feature/php8-deps
Feature/php8 deps
2021-01-24 23:25:43 +01:00
Alejandro Celaya
741e8f625c No longer allow errors on any step during CI 2021-01-24 23:09:46 +01:00
Alejandro Celaya
17eb6dc4ce Updated remaining dependencies without PHP 8 support 2021-01-24 23:00:10 +01:00
Alejandro Celaya
db997fe6f5 Do not allow ignoring platform reqs anymore during CI 2021-01-24 22:59:19 +01:00
Alejandro Celaya
3b1fc2a27d Updated link to PHPUnit's xsd to use local one 2021-01-24 22:56:43 +01:00
Alejandro Celaya
0cbd965010 Fixed merge conflicts 2021-01-24 14:21:21 +01:00
Alejandro Celaya
f3c3979eec Merge pull request #984 from shlinkio/release/2.5.2
Release/2.5.2
2021-01-24 14:17:51 +01:00
Alejandro Celaya
bf26f5baa1 Added v2.5.2 to changelog 2021-01-24 14:05:09 +01:00
Alejandro Celaya
164462d536 Merge pull request #983 from acelaya-forks/feature/cors-allowed-methods
Feature/cors allowed methods
2021-01-24 14:04:23 +01:00
Alejandro Celaya
239af85dd4 Updated changelog 2021-01-24 13:51:29 +01:00
Alejandro Celaya
f585cfe02e Fixed CrossDomainMiddleware not inferring proper allowed methods 2021-01-24 13:49:57 +01:00
Alejandro Celaya
ef54caab85 Merge pull request #982 from acelaya-forks/feature/roles-adr
Feature/roles adr
2021-01-24 12:34:38 +01:00
Alejandro Celaya
aaaa3010ab Updated changelog 2021-01-24 12:32:19 +01:00
Alejandro Celaya
cfdf866c3f Added architectural decision record for the API key roles 2021-01-24 12:31:08 +01:00
Alejandro Celaya
2a1a386b9c Created ADR for API key roles 2021-01-24 10:56:15 +01:00
Alejandro Celaya
a4de8cee7d Merge pull request #981 from acelaya-forks/feature/cors-fix
Feature/cors fix
2021-01-24 09:54:14 +01:00
Alejandro Celaya
a9d6c463ed Updated changelog 2021-01-24 09:30:21 +01:00
Alejandro Celaya
b8a725d60c Added missing itemsPerPage param for short URLs endpoint to swagger docs 2021-01-24 09:27:40 +01:00
Alejandro Celaya
927fb51313 Removed Action sufix from API tests 2021-01-24 09:25:36 +01:00
Alejandro Celaya
76aa6502db Changed value returned in Access-Control-Allow-Origin so that it is always set to '*' 2021-01-24 09:22:46 +01:00
Alejandro Celaya
7d908b6545 Merge pull request #978 from acelaya-forks/feature/pagerfanta
Feature/pagerfanta
2021-01-23 14:55:55 +01:00
Alejandro Celaya
83a29d6ed0 Updated changelog 2021-01-23 14:38:58 +01:00
Alejandro Celaya
55ddc4ae75 Replaced laminas-paginator with pagerfanta 2021-01-23 14:37:34 +01:00
Alejandro Celaya
088e361228 Merge pull request #976 from acelaya-forks/feature/fix-qr-php8
Added package fixing PHP 8 error
2021-01-23 09:40:56 +01:00
Alejandro Celaya
80012b8ee8 Do not allow unit tests to fail 2021-01-23 06:16:04 +01:00
Alejandro Celaya
a61235a5d1 Removed dependency on acelaya/qrcode-detector-decoder 2021-01-23 06:07:16 +01:00
Alejandro Celaya
823242a6c2 Updated endroid 2021-01-23 06:01:12 +01:00
Alejandro Celaya
0670a4dc3c Added package fixing PHP 8 error 2021-01-23 05:46:15 +01:00
Alejandro Celaya
f57303f8c0 Merge pull request #974 from shlinkio/develop
Release 2.5.1
2021-01-21 20:10:57 +01:00
Alejandro Celaya
2eff9929d8 Merge pull request #973 from acelaya-forks/feature/inline-creation-fix
Feature/inline creation fix
2021-01-21 19:59:49 +01:00
Alejandro Celaya
92d7dc2595 Added v2.5.1 to changelog 2021-01-21 19:44:56 +01:00
Alejandro Celaya
4a5cc9a986 Added API test for single-step short URL creation action 2021-01-21 19:43:34 +01:00
Alejandro Celaya
da9896a28b Fixed single step shortening endpoint 2021-01-21 19:26:19 +01:00
Alejandro Celaya
b5b3a50bb2 Added missing mention to xml extension
Closes #970
2021-01-19 15:42:14 +01:00
Alejandro Celaya
ea99b88c44 Merge pull request #969 from acelaya-forks/feature/fix-role-name-length
Feature/fix role name length
2021-01-18 17:34:49 +01:00
Alejandro Celaya
45d162e71a Updated roleName col length in entity metadata definition 2021-01-18 17:22:09 +01:00
Alejandro Celaya
8132113ed9 Updated changelog 2021-01-18 17:16:38 +01:00
Alejandro Celaya
eef49478fc Fixed migrations so that api_key_roles index does not fail 2021-01-18 17:14:46 +01:00
Alejandro Celaya
60cdd8b198 Merge pull request #967 from shlinkio/develop
Release 2.5.0
2021-01-17 20:14:52 +01:00
Alejandro Celaya
47d86b58a3 Added v2.5.0 to CHANGELOG 2021-01-17 20:00:02 +01:00
Alejandro Celaya
e6663aeb20 Merge pull request #964 from acelaya-forks/feature/docs-improvements
Feature/docs improvements
2021-01-17 17:57:44 +01:00
Alejandro Celaya
b321af6d03 Updated changelog 2021-01-17 17:42:02 +01:00
Alejandro Celaya
78038b3141 Simplified docker image docs, linking to the website for anything other than the very basics 2021-01-17 17:40:47 +01:00
Alejandro Celaya
89fd782dd3 Simplified README, linking to the website for advanced info 2021-01-17 17:26:51 +01:00
Alejandro Celaya
37c68c39b0 Updated to stable shlink-common 2021-01-17 16:48:28 +01:00
Alejandro Celaya
1309290a2f Merge pull request #963 from acelaya-forks/feature/mezzio-swoole-3
Feature/mezzio swoole 3
2021-01-17 13:31:04 +01:00
Alejandro Celaya
3e2701f136 Updated how to copy mezzio helper script to dist file 2021-01-17 13:03:44 +01:00
Alejandro Celaya
5ad1a12457 Updated changelog 2021-01-17 11:43:21 +01:00
Alejandro Celaya
2e8f5202d0 Moved event objects to a sub-namespace inside Core\EventDispatcher 2021-01-17 11:42:35 +01:00
Alejandro Celaya
6b6d751d54 Updated to shlinkio/shlink-event-dispatcher 2 2021-01-17 11:40:30 +01:00
Alejandro Celaya
a9704c6e2f Improved mezzio-swoole helper script to ensure it only applies to mezzio:swoole commands 2021-01-14 20:23:44 +01:00
Alejandro Celaya
e3ff447152 Updated to mezzio-swoole 3 2021-01-14 20:19:38 +01:00
Alejandro Celaya
c5fc8fbf00 Simplified database tests by updating to shlinkio/shlink-test-utils 2 2021-01-13 20:21:24 +01:00
Alejandro Celaya
da9e9df4ba Merge pull request #960 from acelaya-forks/feature/api-roles-cli
Feature/api roles cli
2021-01-11 20:35:48 +01:00
Alejandro Celaya
1c75519f9b Displayed 'Admin' as default role in API keys list 2021-01-11 20:23:28 +01:00
Alejandro Celaya
fca19f265b Removed duplicated lines in GenerateKeyCommand 2021-01-11 20:14:18 +01:00
Alejandro Celaya
75dab92225 Improved tests covering ListKeysCommand 2021-01-11 17:01:01 +01:00
Alejandro Celaya
9e9d213f20 Added roles info to api key generation and api key list 2021-01-11 16:32:59 +01:00
Alejandro Celaya
c49a0ca040 Added list of roles to print after an API is generated 2021-01-11 15:20:26 +01:00
Alejandro Celaya
1f2e16184c Extracted function to render arrays from inside ValidationException 2021-01-10 20:28:52 +01:00
Alejandro Celaya
7a19b8765d Created RoleResolverTest 2021-01-10 20:24:13 +01:00
Alejandro Celaya
a639a4eb94 Added role capabilities to api-key:generate command 2021-01-10 20:14:06 +01:00
Alejandro Celaya
c9ff2b3834 Updated services required to initialize API keys with roles 2021-01-10 20:05:14 +01:00
Alejandro Celaya
95e51665b1 Merge pull request #958 from acelaya-forks/feature/api-key-permissions
Feature/api key permissions
2021-01-10 11:25:29 +01:00
Alejandro Celaya
91da241434 Updated changelog 2021-01-10 11:12:22 +01:00
Alejandro Celaya
5bec9f5b65 Extended swagger docs with errors on delete/rename tags 2021-01-10 11:07:17 +01:00
Alejandro Celaya
34bb023b7d Created API tests to cover deletion and renaming of tags with non-admin API keys 2021-01-10 10:28:00 +01:00
Alejandro Celaya
2be0050f3d Improved tag list api test to cover different API key cases 2021-01-10 10:17:27 +01:00
Alejandro Celaya
ff1af82ffd Improved tag visits api test to cover different API key cases 2021-01-10 10:00:00 +01:00
Alejandro Celaya
13cc70e6d4 Added more tags to more fixture short URLs in API keys 2021-01-10 09:54:19 +01:00
Alejandro Celaya
fa5934b8b6 Improved global visits api test to cover different API key cases 2021-01-10 09:36:10 +01:00
Alejandro Celaya
c8eb956778 Improved list domains api test to cover different API key cases 2021-01-10 09:32:19 +01:00
Alejandro Celaya
5283ee2c6b Moved common data provider for core unit tests to trait 2021-01-10 09:31:51 +01:00
Alejandro Celaya
c56d56d38c Added api tests to cover implicit domain when creating short URLs with proper API key 2021-01-10 09:09:56 +01:00
Alejandro Celaya
ea05259bbe Improved api tests where a short URL needs to be resolved, covering cases where API key lacks permissions 2021-01-10 09:02:05 +01:00
Alejandro Celaya
f17873b527 Added api tests for short URLs lists using API keys with permissions 2021-01-10 08:49:31 +01:00
Alejandro Celaya
f827186c77 Updated API test fixtures to include API keys with roles 2021-01-10 08:40:32 +01:00
Alejandro Celaya
380915948b Improved TagRepositoryTest 2021-01-09 18:00:08 +01:00
Alejandro Celaya
14eeb91c58 Added db test for VisitRepository::countVisits 2021-01-09 17:54:04 +01:00
Alejandro Celaya
01dceca9ef Enhanced ShorturlRepository::findOneMatching test to cover ApiKey use cases 2021-01-09 14:39:19 +01:00
Alejandro Celaya
ba32366b0b Added tagExists to TagRepositoryTest 2021-01-09 13:44:47 +01:00
Alejandro Celaya
bef1b13a33 Enhanced DomainRepositoryTest covering API key permissions 2021-01-09 13:16:33 +01:00
Alejandro Celaya
caa1ae0de8 Added all missing unit tests covering API key permissions 2021-01-09 12:38:06 +01:00
Alejandro Celaya
b0c4582f3f Used EntitySpecificationRepository as default entity repository 2021-01-09 10:56:02 +01:00
Alejandro Celaya
a8b68f07b5 Ensured delete/rename tags cannot be done with non-admin API keys 2021-01-06 17:31:49 +01:00
Alejandro Celaya
b5710f87e2 Created value object to wrap the renaming of a tag 2021-01-06 13:11:28 +01:00
Alejandro Celaya
041f231ff2 Implemented mechanism to add/remove roles from API keys 2021-01-06 10:59:08 +01:00
Alejandro Celaya
01b3c504f8 Ensured fixed commit for happyr/doctrine-specification is installed, until a stable v2.0 is released 2021-01-05 19:32:18 +01:00
Alejandro Celaya
f821dea06c Fixed typo on fixture 2021-01-05 19:29:42 +01:00
Alejandro Celaya
4b67d41362 Applied API role specs to short URL creation 2021-01-04 20:15:42 +01:00
Alejandro Celaya
19834f6715 Applied API role specs to domains list 2021-01-04 15:55:59 +01:00
Alejandro Celaya
262a06f624 Renamed method to be more consistent to what it actually does 2021-01-04 15:16:51 +01:00
Alejandro Celaya
a01e0ba337 Changed logic to list domains to centralize conditions in service 2021-01-04 15:02:37 +01:00
Alejandro Celaya
364be2420b Applied API role specs to short URL creation when findIfExists is provided 2021-01-04 13:54:38 +01:00
Alejandro Celaya
29cdfaed39 Changed ShortUrlMeta so that it expects an ApiKey instance instead of the key as string 2021-01-04 13:32:44 +01:00
Alejandro Celaya
24f7fb9c4f Applied API role specs to tags list without stats 2021-01-04 12:44:29 +01:00
Alejandro Celaya
68c601a5a8 Applied API role specs to global visits 2021-01-04 11:27:55 +01:00
Alejandro Celaya
8aa6bdb934 Applied API role specs to tag visits 2021-01-04 11:14:28 +01:00
Alejandro Celaya
4a1e7b761a Applied API role specs to short URL visits 2021-01-03 17:48:32 +01:00
Alejandro Celaya
25ee9b5daf Applied API role specs to single short URL tags edition 2021-01-03 16:50:47 +01:00
Alejandro Celaya
fff10ebee4 Applied API role specs to single short URL edition 2021-01-03 16:41:44 +01:00
Alejandro Celaya
65797b61a0 Applied API role specs to single short URL deletion 2021-01-03 14:03:10 +01:00
Alejandro Celaya
3e565d3830 Removed unnecesary if statements 2021-01-03 13:52:08 +01:00
Alejandro Celaya
dc08286a72 Applied API role specs to single short URL resolution 2021-01-03 13:33:07 +01:00
Alejandro Celaya
940383646b Applied API role specs to short URLs list 2021-01-03 13:05:21 +01:00
Alejandro Celaya
6e1d6ab795 Changed point in which specs are applied for tags list 2021-01-03 12:00:25 +01:00
Alejandro Celaya
df53e6c6f2 Created specs for API key roles 2021-01-02 20:08:49 +01:00
Alejandro Celaya
7e6882960e Added a system to set roles to API keys 2021-01-02 19:35:16 +01:00
Alejandro Celaya
ecf22ae4b6 Added happyr/doctrine-specification to support dunamically applying specs to queries 2021-01-02 17:14:42 +01:00
Alejandro Celaya
90551ff3bc Added used API key to request 2021-01-02 10:34:35 +01:00
Alejandro Celaya
598f2d8622 Merge pull request #950 from acelaya-forks/feature/run-parallel
Feature/run parallel
2021-01-01 11:32:21 +01:00
Alejandro Celaya
f3b4e94def Documented missing composer commands 2021-01-01 11:19:57 +01:00
Alejandro Celaya
6eb3dae8c3 Added dependency on composer parallel to speed-up dev commnds 2021-01-01 11:13:51 +01:00
Alejandro Celaya
09029dff37 Merge pull request #948 from acelaya-forks/feature/cors-improvements
Feature/cors improvements
2020-12-31 15:54:31 +01:00
Alejandro Celaya
9e7f2aea0d Updated changelog 2020-12-31 15:42:00 +01:00
Alejandro Celaya
850a5b412c Removed Access-Control-Expose-Headers header from CrossDomainM;iddleware, as it's actually not correct 2020-12-31 15:41:02 +01:00
Alejandro Celaya
84331135f7 Created API tests for CORS 2020-12-31 13:28:06 +01:00
Alejandro Celaya
202a7327d3 Updated more deps to increase PHP 8 compatibility 2020-12-24 10:37:07 +01:00
Alejandro Celaya
f42e2d87b3 Small update in docker docs 2020-12-22 16:12:39 +01:00
Alejandro Celaya
22124aced7 Updated more dependencies for PHP 8 compatibility 2020-12-22 09:34:58 +01:00
Alejandro Celaya
40676f2167 Removed scrutinizer coverage 2020-12-19 10:37:28 +01:00
Alejandro Celaya
d7b4720327 Merge pull request #936 from acelaya-forks/feature/php8-on-mutation
Added PHP 8 on mutation tests
2020-12-19 10:36:53 +01:00
Alejandro Celaya
3a4a2e4483 Replaced scrutinizer with codecov 2020-12-19 10:25:19 +01:00
Alejandro Celaya
71a83aa384 Added PHP 8 on mutation tests 2020-12-19 10:04:00 +01:00
Alejandro Celaya
291393eeeb Fixed branch for build badge 2020-12-13 18:07:13 +01:00
Alejandro Celaya
ea06c369b0 Merge pull request #933 from acelaya-forks/feature/ci-github-action
Feature/ci GitHub action
2020-12-13 17:56:50 +01:00
Alejandro Celaya
625c870417 Added step to build docker image, and deleted travis config file 2020-12-13 17:45:48 +01:00
Alejandro Celaya
a9e9f89799 Ensured code is cloned before using ocular to upload code coverage to scrutinizer during ci workflow 2020-12-13 17:31:22 +01:00
Alejandro Celaya
f2210ca0cb Added coverage driver to upload coverage job 2020-12-13 17:23:58 +01:00
Alejandro Celaya
1a42ca9239 Added missing dependency between upload coverage job and test jobs 2020-12-13 17:17:16 +01:00
Alejandro Celaya
53726bc679 Added steps to upload code coverage and delete artifacts to ci workflow 2020-12-13 13:34:22 +01:00
Alejandro Celaya
d8a7f3e08c Added mutation-tests step in ci workflow 2020-12-13 13:11:41 +01:00
Alejandro Celaya
ac5a22a3d0 Added static analysis and generation of code coverage artifacts 2020-12-13 12:59:06 +01:00
Alejandro Celaya
5dc2c1640a Added command to create mssql database for tests 2020-12-13 12:47:17 +01:00
Alejandro Celaya
7fe7354a27 Ensured mssql odbc installation is done as super user 2020-12-13 12:38:12 +01:00
Alejandro Celaya
ac85b913c2 Added other database test envs to ci workflow 2020-12-13 12:31:34 +01:00
Alejandro Celaya
0e58d1a242 Added pcov as code coverage driver in github action 2020-12-13 11:37:45 +01:00
Alejandro Celaya
5040f5b177 Changed condition to determine if tests are run in CI 2020-12-13 11:07:37 +01:00
Alejandro Celaya
77deb9c111 Created first version of the ci workflow 2020-12-13 10:44:02 +01:00
Alejandro Celaya
74bafefa68 Merge pull request #931 from acelaya-forks/feature/installer-update-option
Feature/installer update option
2020-12-11 22:00:27 +01:00
Alejandro Celaya
d564404bfe Updated changelog 2020-12-11 21:43:43 +01:00
Alejandro Celaya
b2658073b3 Created script to update config options 2020-12-11 21:42:40 +01:00
Alejandro Celaya
63bd95a123 Merge pull request #928 from acelaya-forks/feature/php8-support
Feature/php8 support
2020-12-06 12:00:45 +01:00
Alejandro Celaya
40105d7aaf Updated to latest swoole and pdo_sqlsrv extensions 2020-12-06 11:41:27 +01:00
Alejandro Celaya
c78991761f Fixed quotes in travis config 2020-12-06 11:29:23 +01:00
Alejandro Celaya
b7a0d319b3 Updated more dependencies to support PHP8 2020-12-04 18:50:00 +01:00
Alejandro Celaya
55bfa9776a Updated to shlinkio/shlink-event-dispatcher 1.6 2020-12-03 23:25:27 +01:00
Alejandro Celaya
d3a4ed607c Replaced --ignore-platform-reqs by --ignore-platform-req=php when running build on PHP 8 2020-12-03 22:27:25 +01:00
Alejandro Celaya
8c79619ff2 Updated to PHP8 compatible versions of symfony/mercure and pugx/shortid-php 2020-12-03 22:26:33 +01:00
Alejandro Celaya
6bedca4ee6 Added more tests covering unicode in custom slugs 2020-12-02 18:45:57 +01:00
Alejandro Celaya
9857f105ec Merge pull request #926 from acelaya-forks/feature/custom-slug-unicode
Feature/custom slug unicode
2020-12-02 12:12:31 +01:00
Alejandro Celaya
7ac1c32ad6 Fixed typo 2020-12-02 12:02:49 +01:00
Alejandro Celaya
6e9fa6553d Updated changelog 2020-12-02 12:01:35 +01:00
Alejandro Celaya
55ea8a6912 #896 Added support for unicode characters in custom slugs 2020-12-02 12:00:47 +01:00
Alejandro Celaya
179ddc5bd7 Merge pull request #925 from acelaya-forks/feature/db-socket-connection
Feature/db socket connection
2020-11-29 20:08:51 +01:00
Alejandro Celaya
bfd886604e Updated changelog 2020-11-29 19:50:39 +01:00
Alejandro Celaya
f34033aa9c Documented how to provide the unix socket to connect to mysql, maria and postgres databases 2020-11-29 19:46:34 +01:00
Alejandro Celaya
e54745b250 #833 Enabled unix socket option during installation 2020-11-29 14:01:26 +01:00
Alejandro Celaya
1975a35837 Updated to lcobucci/json 4.0 stable 2020-11-29 12:54:22 +01:00
Alejandro Celaya
5db66dcf0e Merge pull request #923 from acelaya-forks/feature/qr-codes-query-size
Feature/qr codes query size
2020-11-27 18:00:01 +01:00
Alejandro Celaya
cfdf2f9480 #917 Updated changelog 2020-11-27 17:50:09 +01:00
Alejandro Celaya
c13adb04ef #917 Documented QR endpoint with query size and path size 2020-11-27 17:47:52 +01:00
Alejandro Celaya
4f1ab977a1 #917 Added tests covering the different ways to provide sizes to the QR codes 2020-11-27 17:42:33 +01:00
Alejandro Celaya
fe59a5ad86 #917 Fixed cast to int on QR code action 2020-11-27 17:16:54 +01:00
Alejandro Celaya
a72dc16d85 #917 2020-11-27 17:05:13 +01:00
Alejandro Celaya
74108a19e5 Merge pull request #915 from acelaya-forks/feature/remove-plates
Feature/remove plates
2020-11-22 18:42:19 +01:00
Alejandro Celaya
abe0fc16df #912 Updated changelog 2020-11-22 18:13:12 +01:00
Alejandro Celaya
39bda5113b #912 Fixed unit tests 2020-11-22 18:11:31 +01:00
Alejandro Celaya
49ea5cc78b #912 Removed dependency on league/plates 2020-11-22 18:03:27 +01:00
Alejandro Celaya
8acde332b2 Merge pull request #914 from acelaya-forks/feature/mercure-10-compat
Feature/mercure 10 compat
2020-11-22 16:41:26 +01:00
Alejandro Celaya
600f7a7388 #869 Updated changelog 2020-11-22 16:27:24 +01:00
Alejandro Celaya
fd007ea4a9 #869 Updated dependencies to support mercure 0.10 2020-11-22 16:26:17 +01:00
Alejandro Celaya
d945c28a75 Merge pull request #911 from shlinkio/develop
Release 2.4.2
2020-11-22 11:01:00 +01:00
Alejandro Celaya
b66922b3d5 Ensured lcobucci/jwt stays in alpha 2020-11-22 10:44:13 +01:00
Alejandro Celaya
7d981434e1 Merge pull request #910 from acelaya-forks/feature/swoole-bug
Feature/swoole bug
2020-11-22 10:41:10 +01:00
Alejandro Celaya
c672d35b4a #827 Updated changelog 2020-11-22 10:26:18 +01:00
Alejandro Celaya
6259c73b33 #827 Fixed swoole config getting loaded on non-swoole contexts when running CLI command first 2020-11-22 10:24:06 +01:00
Alejandro Celaya
e4b00e832a Merge pull request #909 from acelaya-forks/feature/geolite-temp-dir
Feature/geolite temp dir
2020-11-21 12:48:28 +01:00
Alejandro Celaya
a452aeaf7e #899 Updated changelog 2020-11-21 12:38:14 +01:00
Alejandro Celaya
6e83b90028 #899 Changed temp directory in which geolite DB files are downloaded 2020-11-21 12:36:30 +01:00
Alejandro Celaya
45ffdce312 Merge pull request #908 from acelaya-forks/feature/domains-list
Feature/domains list
2020-11-21 09:46:16 +01:00
Alejandro Celaya
5485efc9ae #901 Fixed condition type 2020-11-21 08:51:30 +01:00
Alejandro Celaya
850360dd2b #901 Updated changelog 2020-11-21 08:45:57 +01:00
Alejandro Celaya
8d3ceaf462 #901 Ensured only domains in use are returned to lists 2020-11-21 08:44:28 +01:00
Alejandro Celaya
bb6c5de697 Merge pull request #907 from acelaya-forks/feature/missing-swagger-info
Feature/missing swagger info
2020-11-21 08:18:14 +01:00
Alejandro Celaya
ca4c1b00dc #904 Updated changelog 2020-11-21 08:16:22 +01:00
Alejandro Celaya
dda6d30c12 #904 Explicitly added missing Domains and Integrations tags to swagger docs 2020-11-21 08:13:29 +01:00
335 changed files with 8659 additions and 3397 deletions

View File

@@ -17,7 +17,8 @@ indocker
docker-*
phpstan.neon
php*xml*
infection.json
infection*
**/test*
build*
**/.*
bin/helper

1
.gitattributes vendored
View File

@@ -10,7 +10,6 @@
.gitattributes export-ignore
.gitignore export-ignore
.phpstorm.meta.php export-ignore
.scrutinizer.yml export-ignore
.travis.yml export-ignore
build.sh export-ignore
CHANGELOG.md export-ignore

295
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,295 @@
name: Continuous integration
on:
pull_request: null
push:
branches:
- main
- develop
jobs:
lint:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
coverage: none
- run: composer install --no-interaction --prefer-dist
- run: composer cs
static-analysis:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
coverage: none
- run: composer install --no-interaction --prefer-dist
- run: composer stan
unit-tests:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
- run: composer test:unit:ci
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '7.4' }}
with:
name: coverage-unit
path: |
build/coverage-unit
build/coverage-unit.cov
db-tests-sqlite:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
- run: composer test:db:sqlite:ci
- uses: actions/upload-artifact@v2
if: ${{ matrix.php-version == '7.4' }}
with:
name: coverage-db
path: |
build/coverage-db
build/coverage-db.cov
db-tests-mysql:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Start database server
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
coverage: none
- 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']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Start database server
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_maria
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
coverage: none
- 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']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Start database server
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
coverage: none
- 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']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Install MSSQL ODBC
run: sudo ./data/infra/ci/install-ms-odbc.sh
- name: Start database server
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3, pdo_sqlsrv-5.9.0
coverage: none
- 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
api-tests:
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Start database server
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
coverage: pcov
ini-values: pcov.directory=module
- 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' }}
with:
name: coverage-api
path: |
build/coverage-api
build/coverage-api.cov
mutation-tests:
needs:
- unit-tests
- db-tests-sqlite
- api-tests
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.6.3
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
- uses: actions/download-artifact@v2
with:
path: build
- run: composer infect:ci
upload-coverage:
needs:
- unit-tests
- db-tests-sqlite
- api-tests
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
coverage: pcov
ini-values: pcov.directory=module
- uses: actions/download-artifact@v2
with:
path: build
- run: mv build/coverage-unit/coverage-unit.cov build/coverage-unit.cov
- run: mv build/coverage-db/coverage-db.cov build/coverage-db.cov
- run: mv build/coverage-api/coverage-api.cov build/coverage-api.cov
- run: wget https://phar.phpunit.de/phpcov-8.2.0.phar
- run: php phpcov-8.2.0.phar merge build --clover build/clover.xml
- name: Publish coverage
uses: codecov/codecov-action@v1
with:
file: ./build/clover.xml
delete-artifacts:
needs:
- mutation-tests
- upload-coverage
runs-on: ubuntu-20.04
steps:
- uses: geekyeggo/delete-artifact@v1
with:
name: |
coverage-unit
coverage-db
coverage-api
build-docker-image:
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2
with:
fetch-depth: 100
- uses: marceloprado/has-changed-path@v1
id: changed-dockerfile
with:
paths: ./Dockerfile
- if: ${{ steps.changed-dockerfile.outputs.changed == 'true' }}
run: docker build -t shlink-docker-image:temp .
- if: ${{ steps.changed-dockerfile.outputs.changed != 'true' }}
run: echo "Dockerfile didn't change. Skipped"

View File

@@ -7,18 +7,38 @@ on:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
swoole: ['yes', 'no']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use PHP 7.4
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
php-version: '7.4' # Publish release with lowest supported PHP version
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.5.5
- name: Generate release assets
extensions: swoole-4.6.3
- if: ${{ matrix.swoole == 'yes' }}
run: ./build.sh ${GITHUB_REF#refs/tags/v}
- if: ${{ matrix.swoole == 'no' }}
run: ./build.sh ${GITHUB_REF#refs/tags/v} --no-swoole
- uses: actions/upload-artifact@v2
with:
name: dist-files-${{ matrix.php-version }}-${{ matrix.swoole }}
path: build
publish:
needs: ['build']
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v2
- uses: actions/download-artifact@v2
with:
path: build
- name: Publish release with assets
uses: docker://antonyurchenko/git-release:latest
env:
@@ -27,4 +47,16 @@ jobs:
ALLOW_EMPTY_CHANGELOG: "true"
with:
args: |
build/shlink_*_dist.zip
build/*/shlink*_dist.zip
delete-artifacts:
needs: ['publish']
runs-on: ubuntu-20.04
strategy:
matrix:
php-version: [ '7.4', '8.0' ]
swoole: [ 'yes', 'no' ]
steps:
- uses: geekyeggo/delete-artifact@v1
with:
name: dist-files-${{ matrix.php-version }}-${{ matrix.swoole }}

1
.gitignore vendored
View File

@@ -9,5 +9,6 @@ data/shlink-tests.db
data/GeoLite2-City.mmdb
data/GeoLite2-City.mmdb.*
docs/swagger-ui*
docs/mercure.html
docker-compose.override.yml
.phpunit.result.cache

View File

@@ -1,16 +0,0 @@
tools:
external_code_coverage:
timeout: 600
checks:
php:
code_rating: true
duplication: true
build:
dependencies:
override:
- composer install --no-interaction --no-scripts --ignore-platform-reqs
nodes:
analysis:
tests:
override:
- php-scrutinizer-run

View File

@@ -1,56 +0,0 @@
dist: bionic
language: php
branches:
only:
- /.*/
services:
- docker
cache:
directories:
- $HOME/.composer/cache/files
jobs:
fast_finish: true
allow_failures:
- php: 'nightly'
include:
- name: "CI - 8.0"
php: 'nightly'
env:
- COMPOSER_FLAGS='--ignore-platform-reqs'
- name: "CI - 7.4"
php: '7.4'
env:
- COMPOSER_FLAGS=''
before_install:
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
- phpenv config-rm xdebug.ini || return 0
- sudo ./data/infra/ci/install-ms-odbc.sh
- docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms shlink_db shlink_db_postgres shlink_db_maria
- yes | pecl install pdo_sqlsrv-5.9.0preview1 swoole-4.5.5 pcov
install:
- composer self-update
- composer install --no-interaction --prefer-dist $COMPOSER_FLAGS
before_script:
- docker-compose exec shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
- mkdir build
- export DOCKERFILE_CHANGED=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/main} --name-only | grep Dockerfile)
script:
- composer ci
- bin/test/run-api-tests.sh
- if [[ ! -z "${DOCKERFILE_CHANGED}" && "${TRAVIS_PHP_VERSION}" == "7.4" ]]; then docker build -t shlink-docker-image:temp . ; fi
after_success:
- rm -f build/clover.xml
- wget https://phar.phpunit.de/phpcov-7.0.2.phar
- php phpcov-7.0.2.phar merge build --clover build/clover.xml
- wget https://scrutinizer-ci.com/ocular.phar
- php ocular.phar code-coverage:upload --format=php-clover build/clover.xml

View File

@@ -4,6 +4,164 @@ 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.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.
* [#941](https://github.com/shlinkio/shlink/issues/941) Added support to provide a title for every short URL.
The title can also be automatically resolved from the long URL, when no title was explicitly provided, but this option needs to be opted in.
* [#913](https://github.com/shlinkio/shlink/issues/913) Added support to import short URLs from a standard CSV file.
The file requires the `Long URL` and `Short code` columns, and it also accepts the optional `title`, `domain` and `tags` columns.
* [#1000](https://github.com/shlinkio/shlink/issues/1000) Added support to provide a `margin` query param when generating some URL's QR code.
* [#675](https://github.com/shlinkio/shlink/issues/675) Added ability to track visits to the base URL, invalid short URLs or any other "not found" URL, as known as orphan visits.
This behavior is enabled by default, but you can opt out via env vars or config options.
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.
### Changed
* [#977](https://github.com/shlinkio/shlink/issues/977) Migrated from `laminas/laminas-paginator` to `pagerfanta/core` to handle pagination.
* [#986](https://github.com/shlinkio/shlink/issues/986) Updated official docker image to use PHP 8.
* [#1010](https://github.com/shlinkio/shlink/issues/1010) Increased timeout for database commands to 10 minutes.
* [#874](https://github.com/shlinkio/shlink/issues/874) Changed how dist files are generated. Now there will be two for every supported PHP version, with and without support for swoole.
The dist files will have been built under the same PHP version they are meant to be run under, ensuring resolved dependencies are the proper ones.
### Deprecated
* [#959](https://github.com/shlinkio/shlink/issues/959) Deprecated all command flags using camelCase format (like `--expirationDate`), adding kebab-case replacements for all of them (like `--expiration-date`).
All the existing camelCase flags will continue working for now, but will be removed in Shlink 3.0.0
* [#862](https://github.com/shlinkio/shlink/issues/862) Deprecated the endpoint to edit tags for a short URL (`PUT /short-urls/{shortCode}/tags`).
The short URL edition endpoint (`PATCH /short-urls/{shortCode}`) now supports setting the tags too. Use it instead.
### Removed
* *Nothing*
### Fixed
* [#988](https://github.com/shlinkio/shlink/issues/988) Fixed serving zero-byte static files in apache and apache-compatible web servers.
* [#990](https://github.com/shlinkio/shlink/issues/990) Fixed short URLs not properly composed in REST API endpoints when both custom domain and custom base path are used.
* [#1002](https://github.com/shlinkio/shlink/issues/1002) Fixed weird behavior in which GeoLite2 metadata's `buildEpoch` is parsed as string instead of int.
* [#851](https://github.com/shlinkio/shlink/issues/851) Fixed error when trying to schedule swoole tasks in ARM architectures (like raspberry).
## [2.5.2] - 2021-01-24
### Added
* [#965](https://github.com/shlinkio/shlink/issues/965) Added docs section for Architectural Decision Records, including the one for API key roles.
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#979](https://github.com/shlinkio/shlink/issues/979) Added missing `itemsPerPage` query param to swagger docs for short RULs list.
* [#980](https://github.com/shlinkio/shlink/issues/980) Fixed value used for `Access-Control-Allow-Origin`, that could not work as expected when including an IP address.
* [#947](https://github.com/shlinkio/shlink/issues/947) Fixed incorrect value returned in `Access-Control-Allow-Methods` header, which always contained all methods.
## [2.5.1] - 2021-01-21
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#968](https://github.com/shlinkio/shlink/issues/968) Fixed index error in MariaDB while updating to v2.5.0.
* [#972](https://github.com/shlinkio/shlink/issues/972) Fixed 500 error when calling single-step short URL creation endpoint.
## [2.5.0] - 2021-01-17
### Added
* [#795](https://github.com/shlinkio/shlink/issues/795) and [#882](https://github.com/shlinkio/shlink/issues/882) Added new roles system to API keys.
API keys can have any combinations of these two roles now, allowing to limit their interactions:
* Can interact only with short URLs created with that API key.
* Can interact only with short URLs for a specific domain.
* [#833](https://github.com/shlinkio/shlink/issues/833) Added support to connect through unix socket when using an external MySQL, MariaDB or Postgres database.
It can be provided during the installation, or as the `DB_UNIX_SOCKET` env var for the docker image.
* [#869](https://github.com/shlinkio/shlink/issues/869) Added support for Mercure Hub 0.10.
* [#896](https://github.com/shlinkio/shlink/issues/896) Added support for unicode characters in custom slugs.
* [#930](https://github.com/shlinkio/shlink/issues/930) Added new `bin/set-option` script that allows changing individual configuration options on existing shlink instances.
* [#877](https://github.com/shlinkio/shlink/issues/877) Improved API tests on CORS, and "refined" middleware handling it.
### Changed
* [#912](https://github.com/shlinkio/shlink/issues/912) Changed error templates to be plain html files, removing the dependency on `league/plates` package.
* [#875](https://github.com/shlinkio/shlink/issues/875) Updated to `mezzio/mezzio-swoole` v3.1.
* [#952](https://github.com/shlinkio/shlink/issues/952) Simplified in-project docs, by keeping only the basics and linking to the websites docs for anything else.
### Deprecated
* [#917](https://github.com/shlinkio/shlink/issues/917) Deprecated `/{shortCode}/qr-code/{size}` URL, in favor of providing the size in the query instead, `/{shortCode}/qr-code?size={size}`.
* [#924](https://github.com/shlinkio/shlink/issues/924) Deprecated mechanism to provide config options to the docker image through volumes. Use the env vars instead as a direct replacement.
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [2.4.2] - 2020-11-22
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#904](https://github.com/shlinkio/shlink/issues/904) Explicitly added missing "Domains" and "Integrations" tags to swagger docs.
* [#901](https://github.com/shlinkio/shlink/issues/901) Ensured domains which are not in use on any short URL are not returned on the list of domains.
* [#899](https://github.com/shlinkio/shlink/issues/899) Avoided filesystem errors produced while downloading geolite DB files on several shlink instances that share the same filesystem.
* [#827](https://github.com/shlinkio/shlink/issues/827) Fixed swoole config getting loaded in config cache if a console command is run before any web execution, when swoole extension is enabled, making subsequent non-swoole web requests fail.
## [2.4.1] - 2020-11-10
### Added
* *Nothing*

View File

@@ -33,7 +33,7 @@ Then you will have to follow these steps:
Once you finish this, you will have the project exposed in ports `8000` through nginx+php-fpm and `8080` through swoole.
> Note: The `indocker` shell script is a helper used to run commands inside the main docker container.
> Note: The `indocker` shell script is a helper tool used to run commands inside the main docker container.
## Project structure
@@ -46,27 +46,28 @@ This is a simplified version of the project structure:
```
shlink
├── bin
   ├── cli
   ├── install
   └── update
├── cli
├── install
└── update
├── config
   ├── autoload
   ├── params
   ├── config.php
   └── container.php
├── autoload
├── params
├── config.php
└── container.php
├── data
   ├── cache
   ├── locks
   ├── log
   ├── migrations
   └── proxies
├── cache
├── locks
├── log
├── migrations
└── proxies
├── docs
   ├── async-api
   └── swagger
├── adr
├── async-api
│ └── swagger
├── module
   ├── CLI
   ├── Core
   └── Rest
├── CLI
├── Core
└── Rest
├── public
├── composer.json
└── README.md
@@ -77,7 +78,7 @@ The purposes of every folder are:
* `bin`: It contains the CLI tools. The `cli` one is the main entry point to run shlink from the command line, while `install` and `update` are helper tools used to install and update shlink when not using the docker image.
* `config`: Contains application-wide configurations, which are later merged with the ones provided by every module.
* `data`: Common runtime-generated git-ignored assets, like logs, caches, etc.
* `docs`: Any project documentation is stored here, like API spec definitions.
* `docs`: Any project documentation is stored here, like API spec definitions or architectural decision records.
* `module`: Contains a subfolder for every module in the project. Modules contain the source code, tests and configurations for every context in the project.
* `public`: Few assets (like `favicon.ico` or `robots.txt`) and the web entry point are stored here. This web entry point is not used when serving the app with swoole.
@@ -87,9 +88,9 @@ In order to ensure stability and no regressions are introduced while developing
* **Unit tests**: These are the simplest to run, and usually test individual pieces of code, replacing any external dependency by mocks.
The code coverage of unit tests is pretty high, and only entity repositories are excluded because of their nature.
The code coverage of unit tests is pretty high, and only components which work closer to the database, like entity repositories, are excluded because of their nature.
* **Database tests**: These are integration tests that run against a real database, and only cover entity repositories.
* **Database tests**: These are integration tests that run against a real database, and only cover components which work closer to the database.
Its purpose is to verify all the database queries behave as expected and return what's expected.
@@ -97,7 +98,7 @@ In order to ensure stability and no regressions are introduced while developing
* **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.
These are the best tests to catch regressions, and to verify everything interacts as expected.
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.
@@ -113,13 +114,14 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed,
* Run `./indocker composer test:unit` to run the unit tests.
* Run `./indocker composer test:db` to run the database integration tests.
This command runs the same test suite against all supported database engines. If you just want to run one of them, you can add one of `:sqlite`, `:mysql`, `:maria`, `:postgres`, `:mssql` at the end of the command.
This command runs the same test suite against all supported database engines in parallel. If you just want to run one of them, you can add one of `:sqlite`, `:mysql`, `:maria`, `:postgres`, `:mssql` at the end of the command.
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 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.
> Note: Due to some limitations in the tooling used by shlink, the testing databases need to exist beforehand, both for db and api tests (except sqlite).
>
@@ -129,8 +131,18 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed,
## Pull request process
In order to provide pull requests to this project, you should always start by creating a new branch, where you will make all desired changes.
**Important!**: Before starting to work on a pull request, make sure you always [open an issue](https://github.com/shlinkio/shlink/issues/new/choose) first.
This is important because any contribution needs to be discussed first. Maybe there's someone else already working on something similar, or there are other considerations to have in mind.
Once everything is clear, to provide a pull request to this project, you should always start by creating a new branch, where you will make all desired changes.
The base branch should always be `develop`, and the target branch for the pull request should also be `develop`.
Before your branch can be merged, all the checks described in [Running code checks](#running-code-checks) have to be passing. You can verify that manually by running `./indocker composer ci`, or wait for the build to be run automatically after the pull request is created.
Before your branch can be merged, all the checks described in [Running code checks](#running-code-checks) have to be passing. You can verify that manually by running `./indocker composer ci:parallel`, or wait for the build to be run automatically after the pull request is created.
## Architectural Decision Records
The project includes logs for some architectural decisions, using the [adr](https://adr.github.io/) proposal.
If you are curious or want to understand why something has been built in some specific way, [take a look at them](docs/adr).

View File

@@ -1,8 +1,9 @@
FROM php:7.4.11-alpine3.12 as base
FROM php:8.0.2-alpine3.13 as base
ARG SHLINK_VERSION=2.4.0
ARG SHLINK_VERSION=2.5.2
ENV SHLINK_VERSION ${SHLINK_VERSION}
ENV SWOOLE_VERSION 4.5.5
ENV SWOOLE_VERSION 4.6.3
ENV PDO_SQLSRV_VERSION 5.9.0
ENV LC_ALL "C"
WORKDIR /etc/shlink
@@ -32,7 +33,7 @@ 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 && \
apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
pecl install pdo_sqlsrv && \
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 ; \

View File

@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2016-2020 Alejandro Celaya
Copyright (c) 2016-2021 Alejandro Celaya
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

310
README.md
View File

@@ -1,44 +1,42 @@
![Shlink](https://raw.githubusercontent.com/shlinkio/shlink.io/main/public/images/shlink-hero.png)
[![Build Status](https://img.shields.io/travis/com/shlinkio/shlink.svg?style=flat-square)](https://travis-ci.com/shlinkio/shlink)
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/)
[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/)
[![Build Status](https://img.shields.io/github/workflow/status/shlinkio/shlink/Continuous%20integration/develop?logo=github&style=flat-square)](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Continuous+integration%22)
[![Code Coverage](https://img.shields.io/codecov/c/gh/shlinkio/shlink/develop?style=flat-square)](https://app.codecov.io/gh/shlinkio/shlink)
[![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
[![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/main/LICENSE)
[![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate)
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own custom domain.
> This document references Shlink 2.x. If you are using an older version and want to upgrade, follow the [UPGRADE](UPGRADE.md) doc.
> If you are trying to find out how to run the project in development mode or how to provide contributions, read the [CONTRIBUTING](CONTRIBUTING.md) doc.
## Table of Contents
- [Installation](#installation)
- [Full documentation](#full-documentation)
- [Docker image](#docker-image)
- [Self hosted](#self-hosted)
- [Download](#download)
- [Configure](#configure)
- [Serve](#serve)
- [Bonus](#bonus)
- [Update to new version](#update-to-new-version)
- [Using a docker image](#using-a-docker-image)
- [Using shlink](#using-shlink)
- [Shlink CLI Help](#shlink-cli-help)
- [Multiple domains](#multiple-domains)
- [Management](#management)
- [Visits](#visits)
- [Special redirects](#special-redirects)
- [Contributing](#contributing)
## Installation
## Full documentation
> These are the steps needed to install Shlink if you plan to manually host it.
>
> Alternatively, you can use the official docker image. If that's your intention, jump directly to [Using a docker image](#using-a-docker-image)
This document contains the very basics to get started with Shlink. If you want to learn everything you can do with it, visit the [full searchable documentation](https://shlink.io/documentation/).
## Docker image
Starting with version 1.15.0, an official docker image is provided. You can learn how to use it by reading [the docs](https://shlink.io/documentation/install-docker-image/).
The idea is that you can just generate a container using the image and provide the custom config via env vars.
## Self hosted
First, make sure the host where you are going to run shlink fulfills these requirements:
* PHP 7.4 or greater with JSON, curl, PDO, intl and gd extensions enabled.
* PHP 7.4 or 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.
* The web server of your choice with PHP integration (Apache or Nginx recommended).
@@ -50,7 +48,7 @@ In order to run Shlink, you will need a built version of the project. There are
The easiest way to install shlink is by using one of the pre-bundled distributable packages.
Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink_x.x.x_dist.zip` file you will find there.
Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink*_dist.zip` file that suits your needs. You will find one for every supported PHP version and with/without swoole integration.
Finally, decompress the file in the location of your choice.
@@ -60,11 +58,11 @@ In order to run Shlink, you will need a built version of the project. There are
* Clone the project with git (`git clone https://github.com/shlinkio/shlink.git`), or download it by clicking the **Clone or download** green button.
* Download the [Composer](https://getcomposer.org/download/) PHP package manager inside the project folder.
* Run `./build.sh 1.0.0`, replacing the version with the version number you are going to build (the version number is only used for the generated dist file).
* Run `./build.sh 1.0.0`, replacing the version with the version number you are going to build (the version number is used as part of the generated dist file name, and to set the value returned when running `shlink -V` from the command line).
After that, you will have a `shlink_x.x.x_dist.zip` dist file inside the `build` directory, that you need to decompress in the location fo your choice.
After that, you will have a dist file inside the `build` directory, that you need to decompress in the location of your choice.
> This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by [travis](https://travis-ci.com/shlinkio/shlink), attaching the generated dist file to it.
> This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by a [GitHub workflow](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Publish+release%22), attaching the generated dist file to it.
### Configure
@@ -75,162 +73,6 @@ Despite how you built the project, you now need to configure it, by following th
* Setup the application by running the `bin/install` script. It is a command line tool that will guide you through the installation process. **Take into account that this tool has to be run directly on the server where you plan to host Shlink. Do not run it before uploading/moving it there.**
* Generate your first API key by running `bin/cli api-key:generate`. You will need the key in order to interact with shlink's API.
### Serve
Once Shlink is configured, you need to expose it to the web, either by using a traditional web server + fast CGI approach, or by using a [swoole](https://www.swoole.co.uk/) non-blocking server.
* **Using a web server:**
For example, assuming your domain is doma.in and shlink is in the `/path/to/shlink` folder, these would be the basic configurations for Nginx and Apache.
*Nginx:*
```nginx
server {
server_name doma.in;
listen 80;
root /path/to/shlink/public;
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
}
location ~ /\.ht {
deny all;
}
}
```
*Apache:*
```apache
<VirtualHost *:80>
ServerName doma.in
DocumentRoot "/path/to/shlink/public"
<Directory "/path/to/shlink/public">
Options FollowSymLinks Includes ExecCGI
AllowOverride all
Order allow,deny
Allow from all
</Directory>
</VirtualHost>
```
* **Using swoole:**
First you need to install the swoole PHP extension with [pecl](https://pecl.php.net/package/swoole), `pecl install swoole`.
Once installed, it's actually pretty easy to get shlink up and running with swoole. Run `./vendor/bin/mezzio-swoole start -d` and you will get shlink running on port 8080.
However, by doing it this way, you are loosing all the access logs, and the service won't be automatically run if the server has to be restarted.
For that reason, you should create a daemon script, in `/etc/init.d/shlink_swoole`, like this one, replacing `/path/to/shlink` by the path to your shlink installation:
```bash
#!/bin/bash
### BEGIN INIT INFO
# Provides: shlink_swoole
# Required-Start: $local_fs $network $named $time $syslog
# Required-Stop: $local_fs $network $named $time $syslog
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Description: Shlink non-blocking server with swoole
### END INIT INFO
SCRIPT=/path/to/shlink/vendor/bin/mezzio-swoole\ start
RUNAS=root
PIDFILE=/var/run/shlink_swoole.pid
LOGDIR=/var/log/shlink
LOGFILE=${LOGDIR}/shlink_swoole.log
start() {
if [[ -f "$PIDFILE" ]] && kill -0 $(cat "$PIDFILE"); then
echo 'Shlink with swoole already running' >&2
return 1
fi
echo 'Starting shlink with swoole' >&2
mkdir -p "$LOGDIR"
touch "$LOGFILE"
local CMD="$SCRIPT &> \"$LOGFILE\" & echo \$!"
su -c "$CMD" $RUNAS > "$PIDFILE"
echo 'Shlink started' >&2
}
stop() {
if [[ ! -f "$PIDFILE" ]] || ! kill -0 $(cat "$PIDFILE"); then
echo 'Shlink with swoole not running' >&2
return 1
fi
echo 'Stopping shlink with swoole' >&2
kill -15 $(cat "$PIDFILE") && rm -f "$PIDFILE"
echo 'Shlink stopped' >&2
}
case "$1" in
start)
start
;;
stop)
stop
;;
restart)
stop
start
;;
*)
echo "Usage: $0 {start|stop|restart}"
esac
```
Then run these commands to enable the service and start it:
* `sudo chmod +x /etc/init.d/shlink_swoole`
* `sudo update-rc.d shlink_swoole defaults`
* `sudo update-rc.d shlink_swoole enable`
* `/etc/init.d/shlink_swoole start`
Now again, you can access shlink on port 8080, but this time the service will be automatically run at system start-up, and all access logs will be written in `/var/log/shlink/shlink_swoole.log` (you will probably want to [rotate those logs](https://www.digitalocean.com/community/tutorials/how-to-manage-logfiles-with-logrotate-on-ubuntu-16-04). You can find an example logrotate config file [here](data/infra/examples/shlink-daemon-logrotate.conf)).
Finally access to [https://app.shlink.io](https://app.shlink.io) and configure your server to start creating short URLs.
### Bonus
Geo-locating visits to your short links is a time-consuming task. When serving Shlink with swoole, the geo-location task is automatically run asynchronously just after a visit to a short URL happens.
However, if you are not serving Shlink with swoole, you will have to schedule the geo-location task to be run regularly in the background (for example, using cron jobs):
The command you need to run is `/path/to/shlink/bin/cli visit:locate`, and you can optionally provide the `-q` flag to remove any output and avoid your cron logs to be polluted.
## Update to new version
When a new Shlink version is available, you don't need to repeat the entire process. Instead, follow these steps:
1. Rename your existing Shlink directory to something else (ie. `shlink` ---> `shlink-old`).
2. Download and extract the new version of Shlink, and set the directory name to that of the old version (ie. `shlink`).
3. Run the `bin/update` script in the new version's directory to migrate your configuration over. You will be asked to provide the path to the old instance (ie. `shlink-old`).
4. If you are using shlink with swoole, restart the service by running `/etc/init.d/shlink_swoole restart`.
The `bin/update` will use the location from previous shlink version to import the configuration. It will then update the database and generate some assets shlink needs to work.
**Important!** It is recommended that you don't skip any version when using this process. The update tool gets better on every version, but older versions might make assumptions.
## Using a docker image
Starting with version 1.15.0, an official docker image is provided. You can learn how to use it by reading [the docs](docker/README.md).
The idea is that you can just generate a container using the image and provide custom config via env vars.
## Using shlink
Once shlink is installed, there are two main ways to interact with it:
@@ -243,109 +85,13 @@ Once shlink is installed, there are two main ways to interact with it:
* **The REST API**. The complete docs on how to use the API can be found [here](https://shlink.io/documentation/api-docs), and a sandbox which also documents every endpoint can be found in the [API Spec](https://api-spec.shlink.io/) portal.
However, you probably don't want to consume the raw API yourself. That's why a nice [web client](https://github.com/shlinkio/shlink-web-client) is provided that can be directly used from [https://app.shlink.io](https://app.shlink.io), or you can host it yourself too.
However, you probably don't want to consume the raw API yourself. That's why a nice [web client](https://github.com/shlinkio/shlink-web-client) is provided that can be directly used from [https://app.shlink.io](https://app.shlink.io), or hosted by yourself.
Both the API and CLI allow you to do the same operations, except for API key management, which can be done from the command line interface only.
### Shlink CLI Help
## Contributing
```
Usage:
command [options] [arguments]
Options:
-h, --help Display this help message
-q, --quiet Do not output any message
-V, --version Display this application version
--ansi Force ANSI output
--no-ansi Disable ANSI output
-n, --no-interaction Do not ask any interactive question
-v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
Available commands:
help Displays help for a command
list Lists commands
api-key
api-key:disable Disables an API key.
api-key:generate Generates a new valid API key.
api-key:list Lists all the available API keys.
db
db:create Creates the database needed for shlink to work. It will do nothing if the database already exists
db:migrate Runs database migrations, which will ensure the shlink database is up to date.
short-url
short-url:delete Deletes a short URL
short-url:generate Generates a short URL for provided long URL and returns it
short-url:list List all short URLs
short-url:parse Returns the long URL behind a short code
short-url:visits Returns the detailed visits information for provided short code
tag
tag:create Creates one or more tags.
tag:delete Deletes one or more tags.
tag:list Lists existing tags.
tag:rename Renames one existing tag.
visit
visit:locate Resolves visits origin locations.
```
## Multiple domains
While in many cases you will just have one short domain and you'll want all your short URLs to be served from it, there are some cases in which you might want to have multiple short domains served from the same Shlink instance.
If that's the case, you need to understand how Shlink will behave when managing your short URLs or any of them is visited.
### Management
When you create a short URL it is possible to optionally pass a `domain` param. If you don't pass it, the short URL will be created for the default domain (the one provided during Shlink's installation or in the `SHORT_DOMAIN_HOST` env var when using the docker image).
However, if you pass it, the short URL will be "linked" to that domain.
> Note that, if the default domain is passed, Shlink will ignore it and will behave as if no `domain` param was provided.
The main benefit of being able to pass the domain is that Shlink will allow the same custom slug to be used in multiple short URLs, as long as the domain is different (like `example.com/my-compaign`, `another.com/my-compaign` and `foo.com/my-compaign`).
Then, each short URL will be tracked separately and you will be able to define specific tags and metadata for each one of them.
However, this has a side effect. When you try to interact with an existing short URL (editing tags, editing meta, resolving it or deleting it), either from the REST API or the CLI tool, you will have to provide the domain appropriately.
Let's imagine this situation. Shlink's default domain is `example.com`, and you have the next short URLs:
* `https://example.com/abc123` -> a regular short URL where no domain was provided.
* `https://example.com/my-campaign` -> a regular short URL where no domain was provided, but it has a custom slug.
* `https://another.com/my-campaign` -> a short URL where the `another.com` domain was provided, and it has a custom slug.
* `https://another.com/def456` -> a short URL where the `another.com` domain was provided.
These are some of the results you will get when trying to interact with them, depending on the params you provide:
* Providing just the `abc123` short code -> the first URL will be matched.
* Providing just the `my-campaign` short code -> the second URL will be matched, since you did not specify a domain, therefor, Shlink looks for the one with the short code/slug `my-campaign` which is also linked to default domain (or not linked to any domain, to be more accurate).
* Providing the `my-campaign` short code and the `another.com` domain -> The third one will be matched.
* Providing just the `def456` short code -> Shlink will fail/not find any short URL, since there's none with the short code `def456` linked to default domain.
* Providing the `def456` short code and the `another.com` domain -> The fourth short URL will be matched.
* Providing any short code and the `foo.com` domain -> Again, no short URL will be found, as there's none linked to `foo.com` domain.
### Visits
Before adding support for multiple domains, you could point as many domains as you wanted to Shlink, and they would have always worked for existing short codes/slugs.
In order to keep backwards compatibility, Shlink's behavior when a short URL is visited is slightly different, getting to fallback in some cases.
Let's continue with previous example, and also consider we have three domains that will resolve to our Shlink instance, which are `example.com`, `another.com` and `foo.com`.
With that in mind, this is how Shlink will behave when the next short URLs are visited:
* `https://another.com/abc123` -> There was no short URL specifically defined for domain `another.com` and short code `abc123`, but it exists for default domain (`example.com`), so it will fall back to it and redirect to where `example.com/abc123` is configured to redirect.
* `https://example.com/def456` -> The fall-back does not happen from default domain to specific ones, only the other way around (like in previous case). Because of that, this one will result in a not-found URL, even though the `def456` short code exists for `another.com` domain.
* `https://foo.com/abc123` -> This will also fall-back to `example.com/abc123`, like in the first case.
* `https://another.com/non-existing` -> The combination of `another.com` domain with the `non-existing` slug does not exist, so Shlink will try to fall-back to the same but for default domain (`example.com`). However, since that combination does not exist either, it will result in a not-found URL.
* Any other short URL visited exactly as it was configured will, of course, resolve as expected.
### Special redirects
It is currently possible to configure some special redirects when the base domain is visited, a URL does not match, or an invalid/disabled short URL is visited.
Those are configured during Shlink's installation or via env vars when using the docker image.
Currently those are all shared for all domains serving the same Shlink instance, but the plan is to update that and allow specific ones for every existing domain.
If you are trying to find out how to run the project in development mode or how to provide contributions, read the [CONTRIBUTING](CONTRIBUTING.md) doc.
---

51
bin/helper/mezzio-swoole Executable file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env php
<?php
/**
* @deprecated To be removed with Shlink 3.0.0
* This script is provided to keep backwards compatibility on how to run shlink with swoole while being still able to
* update to mezzio/mezzio-swoole 3.x
*/
declare(strict_types=1);
namespace Mezzio\Swoole\Command;
use Laminas\ServiceManager\ServiceManager;
use PackageVersions\Versions;
use Symfony\Component\Console\Application as CommandLine;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\CommandLoader\ContainerCommandLoader;
use function explode;
use function Functional\filter;
use function str_starts_with;
use function strstr;
/** @var ServiceManager $container */
$container = require __DIR__ . '/../../config/container.php';
$version = strstr(Versions::getVersion('mezzio/mezzio-swoole'), '@', true);
$commandsPrefix = 'mezzio:swoole:';
$commands = filter(
$container->get('config')['laminas-cli']['commands'] ?? [],
fn ($c, string $command) => str_starts_with($command, $commandsPrefix),
);
$registeredCommands = [];
foreach ($commands as $newName => $commandServiceName) {
[, $oldName] = explode($commandsPrefix, $newName);
$registeredCommands[$oldName] = $commandServiceName;
$container->addDelegator($commandServiceName, static function ($c, $n, callable $factory) use ($oldName) {
/** @var Command $command */
$command = $factory();
$command->setAliases([$oldName]);
return $command;
});
}
$commandLine = new CommandLine('Mezzio web server', $version);
$commandLine->setAutoExit(true);
$commandLine->setCommandLoader(new ContainerCommandLoader($container, $registeredCommands));
$commandLine->run();

14
bin/set-option Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Shlinkio\Shlink\Installer\Command\SetOptionCommand;
use function chdir;
use function dirname;
chdir(dirname(__DIR__));
[,, $installer] = require __DIR__ . '/../vendor/shlinkio/shlink-installer/bin/run.php';
$installer(SetOptionCommand::NAME);

View File

@@ -1,19 +1,19 @@
#!/usr/bin/env sh
export APP_ENV=test
export DB_DRIVER=mysql
export DB_DRIVER=postgres
export TEST_ENV=api
# Try to stop server just in case it hanged in last execution
vendor/bin/mezzio-swoole stop
vendor/bin/laminas mezzio:swoole:stop
echo 'Starting server...'
vendor/bin/mezzio-swoole start -d
vendor/bin/laminas mezzio:swoole:start -d
sleep 2
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always --log-junit=build/coverage-api/junit.xml $*
testsExitCode=$?
vendor/bin/mezzio-swoole stop
vendor/bin/laminas mezzio:swoole:stop
# Exit this script with the same code as the tests. If tests failed, this script has to fail
exit $testsExitCode

View File

@@ -1,32 +1,45 @@
#!/usr/bin/env bash
set -e
if [[ "$#" -ne 1 ]]; then
if [ "$#" -lt 1 ] || [ "$#" -gt 2 ] || ([ "$#" == 2 ] && [ "$2" != "--no-swoole" ]); then
echo "Usage:" >&2
echo " $0 {version}" >&2
echo " $0 {version} [--no-swoole]" >&2
exit 1
fi
version=$1
builtcontent="./build/shlink_${version}_dist"
noSwoole=$2
phpVersion=$(php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;')
[[ $noSwoole ]] && swooleSuffix="" || swooleSuffix="_swoole"
distId="shlink${version}_php${phpVersion}${swooleSuffix}_dist"
builtContent="./build/${distId}"
projectdir=$(pwd)
[[ -f ./composer.phar ]] && composerBin='./composer.phar' || composerBin='composer'
# Copy project content to temp dir
echo 'Copying project files...'
rm -rf "${builtcontent}"
mkdir -p "${builtcontent}"
rsync -av * "${builtcontent}" \
rm -rf "${builtContent}"
mkdir -p "${builtContent}"
rsync -av * "${builtContent}" \
--exclude=*docker* \
--exclude=Dockerfile \
--include=.htaccess \
--exclude-from=./.dockerignore
cd "${builtcontent}"
cd "${builtContent}"
# Install dependencies
echo "Installing dependencies with $composerBin..."
composerFlags="--optimize-autoloader --no-progress --no-interaction"
${composerBin} self-update
${composerBin} install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction
${composerBin} install --no-dev --prefer-dist $composerFlags
if [[ $noSwoole ]]; then
# If generating a dist not for swoole, uninstall mezzio-swoole
${composerBin} remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev $composerFlags
else
# Copy mezzio helper script to vendor (deprecated - Remove with Shlink 3.0.0)
cp "${projectdir}/bin/helper/mezzio-swoole" "./vendor/bin"
fi
# Delete development files
echo 'Deleting dev files...'
@@ -38,9 +51,9 @@ sed -i "s/%SHLINK_VERSION%/${version}/g" config/autoload/app_options.global.php
# Compressing file
echo 'Compressing files...'
cd "${projectdir}"/build
rm -f ./shlink_${version}_dist.zip
zip -ry ./shlink_${version}_dist.zip ./shlink_${version}_dist
rm -f ./${distId}.zip
zip -ry ./${distId}.zip ./${distId}
cd "${projectdir}"
rm -rf "${builtcontent}"
rm -rf "${builtContent}"
echo 'Done!'

View File

@@ -12,69 +12,67 @@
}
],
"require": {
"php": "^7.4",
"php": "^7.4 || ^8.0",
"ext-json": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^1.0",
"akrabat/ip-address-middleware": "^2.0",
"cakephp/chronos": "^2.0",
"cocur/slugify": "^4.0",
"doctrine/cache": "^1.9",
"doctrine/dbal": "^2.10",
"doctrine/migrations": "^3.0.1",
"doctrine/orm": "^2.7",
"endroid/qr-code": "^3.6",
"doctrine/migrations": "^3.0.2",
"doctrine/orm": "2.8.1 || ^2.8.3",
"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-paginator": "^2.8",
"laminas/laminas-servicemanager": "^3.4",
"laminas/laminas-servicemanager": "^3.6",
"laminas/laminas-stdlib": "^3.2",
"lcobucci/jwt": "^4.0@alpha",
"lcobucci/jwt": "^4.0",
"league/uri": "^6.2",
"lstrojny/functional-php": "^1.9",
"mezzio/mezzio": "^3.2",
"mezzio/mezzio-fastroute": "^3.0",
"mezzio/mezzio-helpers": "^5.3",
"mezzio/mezzio-platesrenderer": "^2.1",
"mezzio/mezzio-problem-details": "^1.1",
"mezzio/mezzio-swoole": "^2.6.4",
"lstrojny/functional-php": "^1.15",
"mezzio/mezzio": "^3.3",
"mezzio/mezzio-fastroute": "^3.1",
"mezzio/mezzio-problem-details": "^1.3",
"mezzio/mezzio-swoole": "^3.3",
"monolog/monolog": "^2.0",
"nikolaposa/monolog-factory": "^3.0",
"ocramius/proxy-manager": "^2.7.0",
"phly/phly-event-dispatcher": "^1.0",
"php-middleware/request-id": "^4.0",
"nikolaposa/monolog-factory": "^3.1",
"ocramius/proxy-manager": "^2.11",
"pagerfanta/core": "^2.5",
"php-middleware/request-id": "^4.1",
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.5",
"pugx/shortid-php": "^0.7",
"ramsey/uuid": "^3.9",
"shlinkio/shlink-common": "^3.3.0",
"shlinkio/shlink-common": "^3.5",
"shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^1.4",
"shlinkio/shlink-importer": "^2.0.1",
"shlinkio/shlink-installer": "^5.1.0",
"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.3.0",
"symfony/mercure": "^0.4.1",
"symfony/process": "^5.1",
"symfony/string": "^5.1"
},
"require-dev": {
"devster/ubench": "^2.0",
"dms/phpunit-arraysubset-asserts": "^0.2.0",
"devster/ubench": "^2.1",
"dms/phpunit-arraysubset-asserts": "^0.2.1",
"eaglewu/swoole-ide-helper": "dev-master",
"infection/infection": "^0.20.0",
"infection/infection": "^0.21.0",
"phpspec/prophecy-phpunit": "^2.0",
"phpstan/phpstan": "^0.12.52",
"phpstan/phpstan": "^0.12.64",
"phpunit/php-code-coverage": "^9.2",
"phpunit/phpunit": "^9.4",
"phpunit/phpunit": "^9.5",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.1.1",
"shlinkio/shlink-test-utils": "^1.5",
"symfony/var-dumper": "^5.1"
"shlinkio/shlink-test-utils": "^2.0",
"symfony/var-dumper": "^5.2",
"veewee/composer-run-parallel": "^0.1.0"
},
"autoload": {
"psr-4": {
@@ -107,6 +105,10 @@
"@test:ci",
"@infect:ci"
],
"ci:parallel": [
"@parallel cs stan test:unit:ci test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
"@parallel test:api infect:ci:unit infect:ci:db"
],
"cs": "phpcs",
"cs:fix": "phpcbf",
"stan": "phpstan analyse module/*/src/ module/*/config config docker/config data/migrations --level=6",
@@ -117,17 +119,12 @@
],
"test:ci": [
"@test:unit:ci",
"@test:db"
"@test:db",
"@test:api"
],
"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:db": [
"@test:db:sqlite:ci",
"@test:db:mysql",
"@test:db:maria",
"@test:db:postgres",
"@test:db:ms"
],
"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",
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
@@ -136,21 +133,19 @@
"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": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered",
"infect:ci:base": "@infect --skip-initial-tests",
"infect:ci": [
"@infect:ci:base --coverage=build/coverage-unit",
"@infect:ci:base --coverage=build/coverage-db --test-framework-options=--configuration=phpunit-db.xml"
],
"infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --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",
"infect:test": [
"@test:unit:ci",
"@test:db:sqlite:ci",
"@parallel test:unit:ci test:db:sqlite:ci",
"@infect:ci"
],
"clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php"
},
"scripts-descriptions": {
"ci": "<fg=blue;options=bold>Alias for \"cs\", \"stan\", \"test:ci\" and \"infect:ci\"</>",
"ci:parallel": "<fg=blue;options=bold>Same as \"ci\", but parallelizing tasks as much as possible</>",
"cs": "<fg=blue;options=bold>Checks coding styles</>",
"cs:fix": "<fg=blue;options=bold>Fixes coding styles, when possible</>",
"stan": "<fg=blue;options=bold>Inspects code with phpstan</>",
@@ -160,14 +155,17 @@
"test:unit:ci": "<fg=blue;options=bold>Runs unit test suites, generating all needed reports and logs for CI envs</>",
"test:db": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL, MariaDB, PostgreSQL and MsSQL</>",
"test:db:sqlite": "<fg=blue;options=bold>Runs database test suites on a SQLite database</>",
"test:db:sqlite:ci": "<fg=blue;options=bold>Runs database test suites on a SQLite database, generating all needed reports and logs for CI envs</>",
"test:db:mysql": "<fg=blue;options=bold>Runs database test suites on a MySQL database</>",
"test:db:maria": "<fg=blue;options=bold>Runs database test suites on a MariaDB database</>",
"test:db:postgres": "<fg=blue;options=bold>Runs database test suites on a PostgreSQL database</>",
"test:db:ms": "<fg=blue;options=bold>Runs database test suites on a Miscrosoft SQL Server database</>",
"test:api": "<fg=blue;options=bold>Runs API test suites</>",
"test:unit:pretty": "<fg=blue;options=bold>Runs unit test suites and generates an HTML code coverage report</>",
"infect": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing</>",
"infect:ci": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing with existing reports and logs</>",
"infect:test": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing</>",
"infect:ci": "<fg=blue;options=bold>Checks unit and db tests quality applying mutation testing with existing reports and logs</>",
"infect:ci:unit": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing with existing reports and logs</>",
"infect:ci:db": "<fg=blue;options=bold>Checks db tests quality applying mutation testing with existing reports and logs</>",
"infect:test": "<fg=blue;options=bold>Runs unit and db tests, then checks tests quality applying mutation testing</>",
"clean:dev": "<fg=blue;options=bold>Deletes artifacts which are gitignored and could affect dev env</>"
},
"config": {

View File

@@ -7,6 +7,9 @@ use Laminas\ConfigAggregator\ConfigAggregator;
return [
'debug' => false,
ConfigAggregator::ENABLE_CACHE => true,
// Disabling config cache for cli, ensures it's never used for swoole and also that console commands don't generate
// a cache file that's then used by non-swoole web executions
ConfigAggregator::ENABLE_CACHE => PHP_SAPI !== 'cli',
];

View File

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

View File

@@ -4,12 +4,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common;
use Happyr\DoctrineSpecification\EntitySpecificationRepository;
return [
'entity_manager' => [
'orm' => [
'proxies_dir' => 'data/proxies',
'load_mappings_using_functional_style' => true,
'default_repository_classname' => EntitySpecificationRepository::class,
],
'connection' => [
'user' => '',

View File

@@ -6,8 +6,8 @@ return [
'geolite2' => [
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
'temp_dir' => sys_get_temp_dir(),
'license_key' => 'G4Lm0C60yJsnkdPi',
'temp_dir' => __DIR__ . '/../../data',
'license_key' => 'G4Lm0C60yJsnkdPi', // Deprecated. Remove hardcoded license on v3
],
];

View File

@@ -14,6 +14,7 @@ return [
Option\Database\DatabasePortConfigOption::class,
Option\Database\DatabaseUserConfigOption::class,
Option\Database\DatabasePasswordConfigOption::class,
Option\Database\DatabaseUnixSocketConfigOption::class,
Option\Database\DatabaseSqlitePathConfigOption::class,
Option\Database\DatabaseMySqlOptionsConfigOption::class,
Option\UrlShortener\ShortDomainHostConfigOption::class,
@@ -39,6 +40,8 @@ return [
Option\UrlShortener\IpAnonymizationConfigOption::class,
Option\UrlShortener\RedirectStatusCodeConfigOption::class,
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
Option\UrlShortener\AutoResolveTitlesConfigOption::class,
Option\UrlShortener\OrphanVisitsTrackingConfigOption::class,
],
'installation_commands' => [

View File

@@ -5,17 +5,18 @@ declare(strict_types=1);
namespace Shlinkio\Shlink;
use Laminas\Stratigility\Middleware\ErrorHandler;
use Mezzio\Helper;
use Mezzio\ProblemDetails;
use Mezzio\Router;
use PhpMiddleware\RequestId\RequestIdMiddleware;
use RKA\Middleware\IpAddress;
use Shlinkio\Shlink\Common\Middleware\ContentLengthMiddleware;
return [
'middleware_pipeline' => [
'error-handler' => [
'middleware' => [
Helper\ContentLengthMiddleware::class,
ContentLengthMiddleware::class,
ErrorHandler::class,
],
],
@@ -64,6 +65,10 @@ return [
],
'not-found' => [
'middleware' => [
// This middleware is in front of tracking actions explicitly. Putting here for orphan visits tracking
IpAddress::class,
Core\ErrorHandler\NotFoundTypeResolverMiddleware::class,
Core\ErrorHandler\NotFoundTrackerMiddleware::class,
Core\ErrorHandler\NotFoundRedirectHandler::class,
Core\ErrorHandler\NotFoundTemplateHandler::class,
],

View File

@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
return [
'templates' => [
'extension' => 'phtml',
],
'plates' => [
'extensions' => [
// extension service names or instances
],
],
];

View File

@@ -13,12 +13,14 @@ return [
'schema' => 'https',
'hostname' => '',
],
'validate_url' => 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

@@ -8,15 +8,16 @@ use Laminas\ConfigAggregator;
use Laminas\Diactoros;
use Mezzio;
use Mezzio\ProblemDetails;
use Mezzio\Swoole\ConfigProvider as SwooleConfigProvider;
use function class_exists;
use function Shlinkio\Shlink\Common\env;
return (new ConfigAggregator\ConfigAggregator([
Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class,
Mezzio\Router\FastRouteRouter\ConfigProvider::class,
Mezzio\Plates\ConfigProvider::class,
Mezzio\Swoole\ConfigProvider::class,
class_exists(SwooleConfigProvider::class) ? SwooleConfigProvider::class : new ConfigAggregator\ArrayProvider([]),
ProblemDetails\ConfigProvider::class,
Diactoros\ConfigProvider::class,
Common\ConfigProvider::class,

View File

@@ -36,7 +36,7 @@ if ($isApiTest) {
$buildDbConnection = function (): array {
$driver = env('DB_DRIVER', 'sqlite');
$isCi = env('TRAVIS', false);
$isCi = env('CI', false);
$getMysqlHost = fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria');
$getCiMysqlPort = fn (string $driver) => $driver === 'mysql' ? '3307' : '3308';

View File

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

View File

@@ -8,7 +8,7 @@
# Description: Shlink non-blocking server with swoole
### END INIT INFO
SCRIPT=/path/to/shlink/vendor/bin/mezzio-swoole\ start
SCRIPT=/path/to/shlink/vendor/bin/laminas\ mezzio:swoole:start
RUNAS=root
PIDFILE=/var/run/shlink_swoole.pid

View File

@@ -1,8 +1,8 @@
FROM php:7.4.11-fpm-alpine3.12
FROM php:8.0.2-fpm-alpine3.13
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.18
ENV APCU_BC_VERSION 1.0.5
ENV APCU_VERSION 5.1.19
ENV PDO_SQLSRV_VERSION 5.9.0
RUN apk update
@@ -35,33 +35,19 @@ RUN docker-php-ext-install gmp
# Install APCu extension
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu\
&& tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1
# configure and install
RUN docker-php-ext-configure apcu\
&& docker-php-ext-install apcu
# cleanup
RUN rm /tmp/apcu.tar.gz
# Install APCu-BC extension
ADD https://pecl.php.net/get/apcu_bc-$APCU_BC_VERSION.tgz /tmp/apcu_bc.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu-bc\
&& tar xf /tmp/apcu_bc.tar.gz -C /usr/src/php/ext/apcu-bc --strip-components=1
# configure and install
RUN docker-php-ext-configure apcu-bc\
&& docker-php-ext-install apcu-bc
# cleanup
RUN rm /tmp/apcu_bc.tar.gz
# Load APCU.ini before APC.ini
RUN rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini
RUN echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
RUN mkdir -p /usr/src/php/ext/apcu \
&& tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \
&& docker-php-ext-configure apcu \
&& docker-php-ext-install apcu \
&& rm /tmp/apcu.tar.gz \
&& rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \
&& echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
# Install 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 && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install pdo_sqlsrv pcov && \
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

View File

@@ -1,10 +1,10 @@
FROM php:7.4.11-alpine3.12
FROM php:8.0.2-alpine3.13
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.18
ENV APCU_BC_VERSION 1.0.5
ENV INOTIFY_VERSION 2.0.0
ENV SWOOLE_VERSION 4.5.5
ENV APCU_VERSION 5.1.19
ENV PDO_SQLSRV_VERSION 5.9.0
ENV INOTIFY_VERSION 3.0.0
ENV SWOOLE_VERSION 4.6.3
RUN apk update
@@ -37,43 +37,27 @@ RUN docker-php-ext-install gmp
# Install APCu extension
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu\
&& tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1
# configure and install
RUN docker-php-ext-configure apcu\
&& docker-php-ext-install apcu
# cleanup
RUN rm /tmp/apcu.tar.gz
# Install APCu-BC extension
ADD https://pecl.php.net/get/apcu_bc-$APCU_BC_VERSION.tgz /tmp/apcu_bc.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu-bc\
&& tar xf /tmp/apcu_bc.tar.gz -C /usr/src/php/ext/apcu-bc --strip-components=1
# configure and install
RUN docker-php-ext-configure apcu-bc\
&& docker-php-ext-install apcu-bc
# cleanup
RUN rm /tmp/apcu_bc.tar.gz
# Load APCU.ini before APC.ini
RUN rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini
RUN echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
RUN mkdir -p /usr/src/php/ext/apcu \
&& tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \
&& docker-php-ext-configure apcu \
&& docker-php-ext-install apcu \
&& rm /tmp/apcu.tar.gz \
&& rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \
&& echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
# Install inotify extension
ADD https://pecl.php.net/get/inotify-$INOTIFY_VERSION.tgz /tmp/inotify.tar.gz
RUN mkdir -p /usr/src/php/ext/inotify\
&& tar xf /tmp/inotify.tar.gz -C /usr/src/php/ext/inotify --strip-components=1
# configure and install
RUN docker-php-ext-configure inotify\
&& docker-php-ext-install inotify
# cleanup
RUN rm /tmp/inotify.tar.gz
RUN mkdir -p /usr/src/php/ext/inotify \
&& tar xf /tmp/inotify.tar.gz -C /usr/src/php/ext/inotify --strip-components=1 \
&& docker-php-ext-configure inotify \
&& docker-php-ext-install 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 && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv pcov && \
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
@@ -95,4 +79,4 @@ CMD \
if [[ ! -d "./vendor" ]]; then /usr/local/bin/composer install ; 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/mezzio-swoole start; do sleep 1 ; done
until php ./vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done

View File

@@ -58,7 +58,7 @@ final class Version20180913205455 extends AbstractMigration
}
try {
return (string) IpAddress::fromString($addr)->getObfuscatedCopy();
return (string) IpAddress::fromString($addr)->getAnonymizedCopy();
} catch (InvalidArgumentException $e) {
return null;
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20210102174433 extends AbstractMigration
{
private const TABLE_NAME = 'api_key_roles';
public function up(Schema $schema): void
{
$this->skipIf($schema->hasTable(self::TABLE_NAME));
$table = $schema->createTable(self::TABLE_NAME);
$table->addColumn('id', Types::BIGINT, [
'unsigned' => true,
'autoincrement' => true,
'notnull' => true,
]);
$table->setPrimaryKey(['id']);
$table->addColumn('role_name', Types::STRING, [
'length' => 255,
'notnull' => true,
]);
$table->addColumn('meta', Types::JSON, [
'notnull' => true,
]);
$table->addColumn('api_key_id', Types::BIGINT, [
'unsigned' => true,
'notnull' => true,
]);
$table->addForeignKeyConstraint('api_keys', ['api_key_id'], ['id'], [
'onDelete' => 'CASCADE',
'onUpdate' => 'RESTRICT',
]);
$table->addUniqueIndex(['role_name', 'api_key_id'], 'UQ_role_plus_api_key');
}
public function down(Schema $schema): void
{
$this->skipIf(! $schema->hasTable(self::TABLE_NAME));
$schema->getTable(self::TABLE_NAME)->dropIndex('UQ_role_plus_api_key');
$schema->dropTable(self::TABLE_NAME);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20210118153932 extends AbstractMigration
{
public function up(Schema $schema): void
{
// Prev migration used to set the length to 256, which made some set-ups crash
// It has been updated to 255, and this migration ensures whoever managed to run the prev one, gets the value
// also updated to 255
$rolesTable = $schema->getTable('api_key_roles');
$nameColumn = $rolesTable->getColumn('role_name');
$nameColumn->setLength(255);
}
public function down(Schema $schema): void
{
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20210202181026 extends AbstractMigration
{
private const TITLE = 'title';
public function up(Schema $schema): void
{
$shortUrls = $schema->getTable('short_urls');
$this->skipIf($shortUrls->hasColumn(self::TITLE));
$shortUrls->addColumn(self::TITLE, Types::STRING, [
'notnull' => false,
'length' => 512,
]);
$shortUrls->addColumn('title_was_auto_resolved', Types::BOOLEAN, [
'default' => false,
]);
}
public function down(Schema $schema): void
{
$shortUrls = $schema->getTable('short_urls');
$this->skipIf(! $shortUrls->hasColumn(self::TITLE));
$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

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\Visitor;
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');
$shortUrlId->setNotnull(false);
$visits->addColumn('visited_url', Types::STRING, [
'length' => Visitor::VISITED_URL_MAX_LENGTH,
'notnull' => false,
]);
$visits->addColumn('type', Types::STRING, [
'length' => 255,
'default' => Visit::TYPE_VALID_SHORT_URL,
]);
}
public function down(Schema $schema): void
{
$visits = $schema->getTable('visits');
$this->skipIf(! $visits->hasColumn('visited_url'));
$shortUrlId = $visits->getColumn('short_url_id');
$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

@@ -3,7 +3,7 @@ version: '3'
services:
shlink_nginx:
container_name: shlink_nginx
image: nginx:1.17.10-alpine
image: nginx:1.19.6-alpine
ports:
- "8000:80"
volumes:
@@ -34,7 +34,7 @@ services:
shlink_swoole_proxy:
container_name: shlink_swoole_proxy
image: nginx:1.17.10-alpine
image: nginx:1.19.6-alpine
ports:
- "8002:80"
volumes:
@@ -120,7 +120,7 @@ services:
shlink_mercure_proxy:
container_name: shlink_mercure_proxy
image: nginx:1.17.10-alpine
image: nginx:1.19.6-alpine
ports:
- "8001:80"
volumes:
@@ -131,7 +131,7 @@ services:
shlink_mercure:
container_name: shlink_mercure
image: dunglas/mercure:v0.9
image: dunglas/mercure:v0.10
ports:
- "3080:80"
environment:

View File

@@ -1,76 +1,21 @@
# Shlink Docker image
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
[![Docker build status](https://img.shields.io/github/workflow/status/shlinkio/shlink/Build%20docker%20image?logo=docker&style=flat-square)](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Build+docker+image%22)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime.
It exposes a shlink instance served with [swoole](https://www.swoole.co.uk/), which persists data in a local [sqlite](https://www.sqlite.org/index.html) database.
It exposes a shlink instance served with [swoole](https://www.swoole.co.uk/), which can be linked to external databases to persist data.
## Usage
Shlink docker image exposes port `8080` in order to interact with its HTTP interface.
It also expects these two env vars to be provided, in order to properly generate short URLs at runtime.
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**.
* `GEOLITE_LICENSE_KEY`: Your GeoLite2 license key. [Learn more](https://shlink.io/documentation/geolite-license-key/) about this.
So based on this, to run shlink on a local docker service, you should run a command like this:
```bash
docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https -e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 shlinkio/shlink:stable
```
### Interact with shlink's CLI on a running container.
Once the shlink container is running, you can interact with the CLI tool by running `shlink` with any of the supported commands.
For example, if the container is called `shlink_container`, you can generate a new API key with:
```bash
docker exec -it shlink_container shlink api-key:generate
```
Or you can list all tags with:
```bash
docker exec -it shlink_container shlink tag:list
```
Or locate remaining visits with:
```bash
docker exec -it shlink_container shlink visit:locate
```
All shlink commands will work the same way.
You can also list all available commands just by running this:
```bash
docker exec -it shlink_container shlink
```
## Use an external DB
The image comes with a working sqlite database, but in production you will probably want to usa a distributed database.
It is possible to use a set of env vars to make this shlink instance interact with an external MySQL, MariaDB, PostgreSQL or Microsoft SQL Server database.
* `DB_DRIVER`: **[Mandatory]**. Use the value **mysql**, **maria**, **postgres** or **mssql** to prevent the sqlite database to be used.
* `DB_NAME`: [Optional]. The database name to be used. Defaults to **shlink**.
* `DB_USER`: **[Mandatory]**. The username credential for the database server.
* `DB_PASSWORD`: **[Mandatory]**. The password credential for the database server.
* `DB_HOST`: **[Mandatory]**. The host name of the server running the database engine.
* `DB_PORT`: [Optional]. The port in which the database service is running.
* Default value is based on the value provided for `DB_DRIVER`:
* **mysql** or **maria** -> `3306`
* **postgres** -> `5432`
* **mssql** -> `1433`
> PostgreSQL is supported since v1.16.1 and Microsoft SQL server since v2.1.0. Do not try to use them with previous versions.
Taking this into account, you could run shlink on a local docker service like this:
To run shlink on top of a local docker service, and using an internal SQLite database, do the following:
```bash
docker run \
@@ -78,222 +23,12 @@ docker run \
-p 8080:8080 \
-e SHORT_DOMAIN_HOST=doma.in \
-e SHORT_DOMAIN_SCHEMA=https \
-e DB_DRIVER=mysql \
-e DB_USER=root \
-e DB_PASSWORD=123abc \
-e DB_HOST=something.rds.amazonaws.com \
shlinkio/shlink:stable
```
You could even link to a local database running on a different container:
```bash
docker run \
--name shlink \
-p 8080:8080 \
[...] \
-e DB_HOST=some_mysql_container \
--link some_mysql_container \
shlinkio/shlink:stable
```
> If you have considered using SQLite but sharing the database file with a volume, read [this issue](https://github.com/shlinkio/shlink-docker-image/issues/40) first.
## Other integrations
### Use an external redis server
If you plan to run more than one Shlink instance, there are some resources that should be shared ([Multi instance considerations](#multi-instance-considerations)).
One of those resources are the locks Shlink generates to prevent some operations to be run more than once in parallel (in the future, these redis servers could be used for other caching operations).
In order to share those locks, you should use an external redis server (or a cluster of redis servers), by providing the `REDIS_SERVERS` env var.
It can be either one server name or a comma-separated list of servers.
> If more than one redis server is provided, Shlink will expect them to be configured as a [redis cluster](https://redis.io/topics/cluster-tutorial).
### Integrate with a mercure hub server
One way to get real time updates when certain events happen in Shlink is by integrating it with a [mercure hub](https://mercure.rocks/) server.
If you do that, Shlink will publish updates and other clients can subscribe to those.
There are three env vars you need to provide if you want to enable this:
* `MERCURE_PUBLIC_HUB_URL`: **[Mandatory]**. The public URL of a mercure hub server to which Shlink will sent updates. This URL will also be served to consumers that want to subscribe to those updates.
* `MERCURE_INTERNAL_HUB_URL`: **[Optional]**. An internal URL for a mercure hub. Will be used only when publishing updates to mercure, and does not need to be public. If this is not provided, the `MERCURE_PUBLIC_HUB_URL` one will be used to publish updates.
* `MERCURE_JWT_SECRET`: **[Mandatory]**. The secret key that was provided to the mercure hub server, in order to be able to generate valid JWTs for publishing/subscribing to that server.
So in order to run shlink with mercure integration, you would do it like this:
```bash
docker run \
--name shlink \
-p 8080:8080 \
-e SHORT_DOMAIN_HOST=doma.in \
-e SHORT_DOMAIN_SCHEMA=https \
-e "MERCURE_PUBLIC_HUB_URL=https://example.com"
-e "MERCURE_INTERNAL_HUB_URL=http://my-mercure-hub.prod.svc.cluster.local"
-e MERCURE_JWT_SECRET=super_secret_key
shlinkio/shlink:stable
```
## All supported env vars
A few env vars have been already used in previous examples, but this image supports others that can be used to customize its behavior.
This is the complete list of supported 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**.
* `DB_DRIVER`: **sqlite** (which is the default value), **mysql**, **maria**, **postgres** or **mssql**.
* `DB_NAME`: The database name to be used when using an external database driver. Defaults to **shlink**.
* `DB_USER`: The username credential to be used when using an external database driver.
* `DB_PASSWORD`: The password credential to be used when using an external database driver.
* `DB_HOST`: The host name of the database server when using an external database driver.
* `DB_PORT`: The port in which the database service is running when using an external database driver.
* Default value is based on the value provided for `DB_DRIVER`:
* **mysql** or **maria** -> `3306`
* **postgres** -> `5432`
* **mssql** -> `1433`
* `DISABLE_TRACK_PARAM`: The name of a query param that can be used to visit short URLs avoiding the visit to be tracked. This feature won't be available if not value is provided.
* `DELETE_SHORT_URL_THRESHOLD`: The amount of visits on short URLs which will not allow them to be deleted. Defaults to `15`.
* `VALIDATE_URLS`: Boolean which tells if shlink should validate a status 20x is returned (after following redirects) when trying to shorten a URL. Defaults to `false`.
* `INVALID_SHORT_URL_REDIRECT_TO`: If a URL is provided here, when a user tries to access an invalid short URL, he/she will be redirected to this value. If this env var is not provided, the user will see a generic `404 - not found` page.
* `REGULAR_404_REDIRECT_TO`: If a URL is provided here, when a user tries to access a URL not matching any one supported by the router, he/she will be redirected to this value. If this env var is not provided, the user will see a generic `404 - not found` page.
* `BASE_URL_REDIRECT_TO`: If a URL is provided here, when a user tries to access Shlink's base URL, he/she will be redirected to this value. If this env var is not provided, the user will see a generic `404 - not found` page.
* `BASE_PATH`: The base path from which you plan to serve shlink, in case you don't want to serve it from the root of the domain. Defaults to `''`.
* `WEB_WORKER_NUM`: The amount of concurrent http requests this shlink instance will be able to server. Defaults to 16.
* `TASK_WORKER_NUM`: The amount of concurrent background tasks this shlink instance will be able to execute. Defaults to 16.
* `VISITS_WEBHOOKS`: A comma-separated list of URLs that will receive a `POST` request when a short URL receives a visit.
* `DEFAULT_SHORT_CODES_LENGTH`: The length you want generated short codes to have. It defaults to 5 and has to be at least 4, so any value smaller than that will fall back to 4.
* `GEOLITE_LICENSE_KEY`: The license key used to download new GeoLite2 database files. This is not mandatory, as a default license key is provided, but it is **strongly recommended** that you provide your own. Go to [https://shlink.io/documentation/geolite-license-key](https://shlink.io/documentation/geolite-license-key) to know how to generate it.
* `REDIS_SERVERS`: A comma-separated list of redis servers where Shlink locks are stored (locks are used to prevent some operations to be run more than once in parallel).
* `MERCURE_PUBLIC_HUB_URL`: The public URL of a mercure hub server to which Shlink will sent updates. This URL will also be served to consumers that want to subscribe to those updates.
* `MERCURE_INTERNAL_HUB_URL`: An internal URL for a mercure hub. Will be used only when publishing updates to mercure, and does not need to be public. If this is not provided but `MERCURE_PUBLIC_HUB_URL` was, the former one will be used to publish updates.
* `MERCURE_JWT_SECRET`: The secret key that was provided to the mercure hub server, in order to be able to generate valid JWTs for publishing/subscribing to that server.
* `ANONYMIZE_REMOTE_ADDR`: Tells if IP addresses from visitors should be obfuscated before storing them in the database. Default value is `true`. **Careful!** Setting this to `false` will make your Shlink instance no longer be in compliance with the GDPR and other similar data protection regulations.
* `REDIRECT_STATUS_CODE`: Either **301** or **302**. Used to determine if redirects from short to long URLs should be done with a 301 or 302 status. Defaults to 302.
* `REDIRECT_CACHE_LIFETIME`: Allows to set the amount of seconds that redirects should be cached when redirect status is 301. Default values is 30.
* `PORT`: Can be used to set the port in which shlink listens. Defaults to 8080 (Some cloud providers, like Google cloud or Heroku, expect to be able to customize exposed port by providing this env var).
An example using all env vars could look like this:
```bash
docker run \
--name shlink \
-p 8080:8888 \
-e SHORT_DOMAIN_HOST=doma.in \
-e SHORT_DOMAIN_SCHEMA=https \
-e PORT=8888 \
-e DB_DRIVER=mysql \
-e DB_NAME=shlink \
-e DB_USER=root \
-e DB_PASSWORD=123abc \
-e DB_HOST=something.rds.amazonaws.com \
-e DB_PORT=3306 \
-e DISABLE_TRACK_PARAM="no-track" \
-e DELETE_SHORT_URL_THRESHOLD=30 \
-e VALIDATE_URLS=true \
-e "INVALID_SHORT_URL_REDIRECT_TO=https://my-landing-page.com" \
-e "REGULAR_404_REDIRECT_TO=https://my-landing-page.com" \
-e "BASE_URL_REDIRECT_TO=https://my-landing-page.com" \
-e "REDIS_SERVERS=tcp://172.20.0.1:6379,tcp://172.20.0.2:6379" \
-e "BASE_PATH=/my-campaign" \
-e WEB_WORKER_NUM=64 \
-e TASK_WORKER_NUM=32 \
-e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \
-e DEFAULT_SHORT_CODES_LENGTH=6 \
-e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 \
-e "MERCURE_PUBLIC_HUB_URL=https://example.com" \
-e "MERCURE_INTERNAL_HUB_URL=http://my-mercure-hub.prod.svc.cluster.local" \
-e MERCURE_JWT_SECRET=super_secret_key \
-e ANONYMIZE_REMOTE_ADDR=false \
-e REDIRECT_STATUS_CODE=301 \
-e REDIRECT_CACHE_LIFETIME=90 \
shlinkio/shlink:stable
```
## Provide config via volumes
## Full documentation
Rather than providing custom configuration via env vars, it is also possible ot provide config files in json format.
All the features supported by Shlink are also supported by the docker image.
Mounting a volume at `config/params` you will make shlink load all the files on it with the `.config.json` suffix.
The whole configuration should have this format, but it can be split into multiple files that will be merged:
```json
{
"disable_track_param": "my_param",
"delete_short_url_threshold": 30,
"short_domain_schema": "https",
"short_domain_host": "doma.in",
"validate_url": true,
"invalid_short_url_redirect_to": "https://my-landing-page.com",
"regular_404_redirect_to": "https://my-landing-page.com",
"base_url_redirect_to": "https://my-landing-page.com",
"base_path": "/my-campaign",
"web_worker_num": 64,
"task_worker_num": 32,
"default_short_codes_length": 6,
"redis_servers": [
"tcp://172.20.0.1:6379",
"tcp://172.20.0.2:6379"
],
"visits_webhooks": [
"http://my-api.com/api/v2.3/notify",
"https://third-party.io/foo"
],
"db_config": {
"driver": "pdo_mysql",
"dbname": "shlink",
"user": "root",
"password": "123abc",
"host": "something.rds.amazonaws.com",
"port": "3306"
},
"geolite_license_key": "kjh23ljkbndskj345",
"mercure_public_hub_url": "https://example.com",
"mercure_internal_hub_url": "http://my-mercure-hub.prod.svc.cluster.local",
"mercure_jwt_secret": "super_secret_key",
"anonymize_remote_addr": false,
"redirect_status_code": 301,
"redirect_cache_lifetime": 90,
"port": 8888
}
```
> This is internally parsed to how shlink expects the config. If you are using a version previous to 1.17.0, this parser is not present and you need to provide a config structure like the one [documented previously](https://github.com/shlinkio/shlink-docker-image/tree/v1.16.3#provide-config-via-volumes).
Once created just run shlink with the volume:
```bash
docker run --name shlink -p 8080:8080 -v ${PWD}/my/config/dir:/etc/shlink/config/params shlinkio/shlink:stable
```
## Multi-architecture
Starting on v2.3.0, Shlink's docker image is built for multiple architectures.
The only limitation is that images for architectures other than `amd64` will not have support for Microsoft SQL databases, since there are no official binaries.
## Multi-instance considerations
These are some considerations to take into account when running multiple instances of shlink.
* Some operations performed by Shlink should never be run more than once at the same time (like creating the database for the first time, or downloading the GeoLite2 database). For this reason, Shlink uses a locking system.
However, these locks are locally scoped to each Shlink instance by default.
You can (and should) make the locks to be shared by all Shlink instances by using a redis server/cluster. Just define the `REDIS_SERVERS` env var with the list of servers.
## Versions
Versioning on this docker image works as follows:
* `X.X.X`: when providing a specific version number, the image version will match the shlink version it contains. For example, installing `shlinkio/shlink:1.15.0`, you will get an image containing shlink v1.15.0.
* `stable`: always holds the latest stable tag. For example, if latest shlink version is 2.0.0, installing `shlinkio/shlink:stable`, you will get an image containing shlink v2.0.0
* `latest`: always holds the latest contents, and it's considered unstable and not suitable for production.
> **Important**: The docker image was introduced with shlink v1.15.0, so there are no official images previous to that versions.
If you want to learn more, visit the [full documentation](https://shlink.io/documentation/install-docker-image/).

View File

@@ -34,6 +34,7 @@ $helper = new class {
public function getDbConfig(): array
{
$driver = env('DB_DRIVER');
$isMysql = contains(['maria', 'mysql'], $driver);
if ($driver === null || $driver === 'sqlite') {
return [
'driver' => 'pdo_sqlite',
@@ -41,7 +42,7 @@ $helper = new class {
];
}
$driverOptions = ! contains(['maria', 'mysql'], $driver) ? [] : [
$driverOptions = ! $isMysql ? [] : [
// 1002 -> PDO::MYSQL_ATTR_INIT_COMMAND
1002 => 'SET NAMES utf8',
// 1000 -> PDO::MYSQL_ATTR_USE_BUFFERED_QUERY
@@ -52,9 +53,10 @@ $helper = new class {
'dbname' => env('DB_NAME', 'shlink'),
'user' => env('DB_USER'),
'password' => env('DB_PASSWORD'),
'host' => env('DB_HOST'),
'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,
];
}
@@ -99,8 +101,6 @@ $helper = new class {
return [
'config_cache_enabled' => false,
'app_options' => [
'disable_track_param' => env('DISABLE_TRACK_PARAM'),
],
@@ -125,6 +125,8 @@ return [
'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(),

View File

@@ -17,4 +17,4 @@ php vendor/doctrine/orm/bin/doctrine.php orm:clear-cache:metadata -n -q
# 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/mezzio/mezzio-swoole/bin/mezzio-swoole start; do sleep 1 ; done
until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done

View File

@@ -0,0 +1,50 @@
# Support restrictions and permissions in API keys
* Status: Accepted
* Date: 2021-01-17
## Context and problem statement
Historically, every API key generated for Shlink granted you access to all existing resources.
The intention is to be able to apply some form of restriction to API keys, so that only a subset of "resources" can be accessed with it, naming:
* Allowing interactions only with short URLs and related resources, that have been created with the same API key.
* Allowing interactions only with short URLs and related resources, that have been attached to a specific domain.
The intention is to implement a system that allows adding to API keys as many of these restrictions as wanted.
Supporting more restrictions in the future is also desirable.
## Considered option
* Using an ACL/RBAC library, and checking roles in a middleware.
* Using a service that, provided an API key, tells if certain resource is reachable while it also allows building queries dynamically.
* Using some library implementing the specification pattern, to dynamically build queries transparently for outer layers.
## Decision outcome
The main difficulty on implementing this is that the entity conditioning the behavior (the API key) comes in the request in some form, but it can potentially affect database queries performed in the persistence layer.
Because of this, it has to traverse all the application layers from top to bottom, in most of the cases.
This motivated selecting the third option, as we can propagate the API key and delay its handling to the last step, without changing the behavior of the rest of the layers that much (except in some individual use cases).
The domain term used to refer these "restrictions" is finally **roles**.
It can be combined in the future with an ACL/RBAC library, if we want to restrict access to certain resources, but it didn't fulfil the initial requirements.
## Pros and Cons of the Options
### An ACL/RBAC library
* Good, because there are many good libraries out there.
* Bad, because when you need to filter resources lists this kind of libraries doesn't really work.
### A service with the logic
* Bad, because it would need to be used in many layers of the application, mixing unrelated concerns.
### A library implementing the specification pattern
* Good, because allows centralizing the generation of dynamic specs by the entity itself, that are later translated automatically into database queries.

View File

@@ -0,0 +1,35 @@
# Track visits to 'base_url', 'invalid_short_url' and 'regular_404'
* Status: Accepted
* Date: 2021-02-07
## Context and problem statement
Shlink has the mechanism to return either custom errors or custom redirects when visiting the instance's base URL, an invalid short URL, or any other kind of URL that would result in a "Not found" error.
However, it does not track visits to any of those, just to valid short URLs.
The intention is to change that, and allow users to track the cases mentioned above.
## Considered option
* Create a new table to track visits o this kind.
* Reuse the existing `visits` table, by making `short_url_id` nullable and adding a couple of other fields.
## Decision outcome
The decision is to use the existing table, as making the short URL nullable can be handled seamlessly by using named constructors, and it has a lot of benefits on regards of reusing existing components.
Also, the domain name this kind of visits will receive is "Orphan Visits", as they are detached from any existing short URL.
## Pros and Cons of the Options
### New table
* Good because we don't touch existing models and tables, reducing the risk to introduce a backwards compatibility break.
* Bad because we will have to repeat data modeling and logic, or refactor some components to support both contexts. This in turn increases the options to introduce a BC break.
### Reuse existing table
* Good because all the mechanisms in place to handle visits will work out of the box, including locating visits and such.
* Bad because we will have more optional properties, which means more double checks in many places.

6
docs/adr/README.md Normal file
View File

@@ -0,0 +1,6 @@
# Architectural Decision Records
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-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

@@ -58,6 +58,23 @@
}
}
}
},
"http://shlink.io/new-orphan-visit": {
"subscribe": {
"summary": "Receive information about any new orphan visit.",
"operationId": "newOrphanVisit",
"message": {
"payload": {
"type": "object",
"additionalProperties": false,
"properties": {
"visit": {
"$ref": "#/components/schemas/OrphanVisit"
}
}
}
}
}
}
},
"components": {
@@ -179,6 +196,46 @@
}
}
},
"OrphanVisit": {
"allOf": [
{"$ref": "#/components/schemas/Visit"},
{
"type": "object",
"properties": {
"visitedUrl": {
"type": "string",
"nullable": true,
"description": "The originally visited URL that triggered the tracking of this visit"
},
"type": {
"type": "string",
"enum": [
"invalid_short_url",
"base_url",
"regular_404"
],
"description": "Tells the type of orphan visit"
}
}
}
],
"example": {
"referer": "https://t.co",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
"visitLocation": {
"cityName": "Cupertino",
"countryCode": "US",
"countryName": "United States",
"latitude": 37.3042,
"longitude": -122.0946,
"regionName": "California",
"timezone": "America/Los_Angeles"
},
"visitedUrl": "https://doma.in",
"type": "base_url"
}
},
"VisitLocation": {
"type": "object",
"properties": {

View File

@@ -0,0 +1,23 @@
{
"type": "object",
"required": ["visitedUrl", "type"],
"allOf": [{
"$ref": "./Visit.json"
}],
"properties": {
"visitedUrl": {
"type": "string",
"nullable": true,
"description": "The originally visited URL that triggered the tracking of this visit"
},
"type": {
"type": "string",
"enum": [
"invalid_short_url",
"base_url",
"regular_404"
],
"description": "Tells the type of orphan visit"
}
}
}

View File

@@ -34,7 +34,13 @@
},
"domain": {
"type": "string",
"nullable": true,
"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."
}
}
}

View File

@@ -1,5 +1,6 @@
{
"type": "object",
"required": ["referer", "date", "userAgent", "visitLocation"],
"properties": {
"referer": {
"type": "string",

View File

@@ -1,10 +1,14 @@
{
"type": "object",
"required": ["visitsCount"],
"required": ["visitsCount", "orphanVisitsCount"],
"properties": {
"visitsCount": {
"type": "number",
"description": "The total amount of visits received."
"description": "The total amount of visits received on any short URL."
},
"orphanVisitsCount": {
"type": "number",
"description": "The total amount of visits that could not be matched to a short URL (visits to the base URL, an invalid short URL or any other kind of 404)."
}
}
}

View File

@@ -19,6 +19,15 @@
"type": "integer"
}
},
{
"name": "itemsPerPage",
"in": "query",
"description": "The amount of items to return on every page. Defaults to 10",
"required": false,
"schema": {
"type": "number"
}
},
{
"name": "searchTerm",
"in": "query",
@@ -55,7 +64,9 @@
"dateCreated-ASC",
"dateCreated-DESC",
"visits-ASC",
"visits-DESC"
"visits-DESC",
"title-ASC",
"title-DESC"
]
}
},
@@ -128,7 +139,8 @@
"validUntil": null,
"maxVisits": 100
},
"domain": null
"domain": null,
"title": "Welcome to Steam"
},
{
"shortCode": "12Kb3",
@@ -144,7 +156,8 @@
"validUntil": null,
"maxVisits": null
},
"domain": null
"domain": null,
"title": null
},
{
"shortCode": "123bA",
@@ -158,7 +171,8 @@
"validUntil": null,
"maxVisits": null
},
"domain": "example.com"
"domain": "example.com",
"title": null
}
],
"pagination": {
@@ -191,7 +205,7 @@
"Short URLs"
],
"summary": "Create short URL",
"description": "Creates a new short URL.<br></br>**Param findIfExists:**: Starting with v1.16, this new param allows to force shlink to return existing short URLs when found based on provided params, instead of creating a new one. However, it might add complexity and have unexpected outputs.\n\nThese are the use cases:\n* Only the long URL is provided: It will return the newest match or create a new short URL if none is found.\n* Long url and custom slug are provided: It will return the short URL when both params match, return an error when the slug is in use for another long URL, or create a new short URL otherwise.\n* Any of the above but including other params (tags, validSince, validUntil, maxVisits): It will behave the same as the previous two cases, but it will try to exactly match existing results using all the params. If any of them does not match, it will try to create a new short URL.",
"description": "Creates a new short URL.<br></br>**Param findIfExists**: This new param allows to force shlink to return existing short URLs when found based on provided params, instead of creating a new one. However, it might add complexity and have unexpected outputs.\n\nThese are the use cases:\n* Only the long URL is provided: It will return the newest match or create a new short URL if none is found.\n* Long url and custom slug are provided: It will return the short URL when both params match, return an error when the slug is in use for another long URL, or create a new short URL otherwise.\n* Any of the above but including other params (tags, validSince, validUntil, maxVisits): It will behave the same as the previous two cases, but it will try to exactly match existing results using all the params. If any of them does not match, it will try to create a new short URL.",
"security": [
{
"ApiKey": []
@@ -255,6 +269,10 @@
"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."
}
}
}

View File

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

View File

@@ -53,7 +53,8 @@
"validUntil": null,
"maxVisits": 100
},
"domain": null
"domain": null,
"title": null
}
}
},
@@ -118,19 +119,34 @@
},
"validSince": {
"description": "The date (in ISO-8601 format) from which this short code will be valid",
"type": "string"
"type": "string",
"nullable": true
},
"validUntil": {
"description": "The date (in ISO-8601 format) until which this short code will be valid",
"type": "string"
"type": "string",
"nullable": true
},
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number"
"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
}
}
}
@@ -143,8 +159,34 @@
}
],
"responses": {
"204": {
"description": "The short code has been properly updated."
"200": {
"description": "The short URL has been properly updated.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/ShortUrl.json"
}
}
},
"examples": {
"application/json": {
"shortCode": "12Kb3",
"shortUrl": "https://doma.in/12Kb3",
"longUrl": "https://shlink.io",
"dateCreated": "2016-05-01T20:34:16+02:00",
"visitsCount": 1029,
"tags": [
"shlink"
],
"meta": {
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 100
},
"domain": null,
"title": "Shlink - The URL shortener"
}
}
},
"400": {
"description": "Provided meta arguments are invalid.",

View File

@@ -1,11 +1,12 @@
{
"put": {
"deprecated": true,
"operationId": "editShortUrlTags",
"tags": [
"Short URLs"
],
"summary": "Edit tags on short URL",
"description": "Edit the tags on URL identified by provided short code.",
"description": "Edit the tags on URL identified by provided short code.<br />This endpoint is deprecated. Use the [Edit short URL](#/Short%20URLs/editShortUrl) endpoint to edit tags.",
"parameters": [
{
"$ref": "../parameters/version.json"

View File

@@ -232,6 +232,16 @@
}
}
},
"403": {
"description": "The API key you used does not have permissions to rename tags.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"404": {
"description": "There's no tag found with the name provided in oldName param.",
"content": {
@@ -298,6 +308,16 @@
"204": {
"description": "Tags properly deleted"
},
"403": {
"description": "The API key you used does not have permissions to delete tags.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {

View File

@@ -34,7 +34,8 @@
"examples": {
"application/json": {
"visits": {
"visitsCount": 1569874
"visitsCount": 1569874,
"orphanVisitsCount": 71345
}
}
}

View File

@@ -0,0 +1,141 @@
{
"get": {
"operationId": "getOrphanVisits",
"tags": [
"Visits"
],
"summary": "List orphan visits",
"description": "Get the list of visits to invalid short URLs, the base URL or any other 404.",
"parameters": [
{
"$ref": "../parameters/version.json"
},
{
"name": "startDate",
"in": "query",
"description": "The date (in ISO-8601 format) from which we want to get visits.",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "endDate",
"in": "query",
"description": "The date (in ISO-8601 format) until which we want to get visits.",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "page",
"in": "query",
"description": "The page to display. Defaults to 1",
"required": false,
"schema": {
"type": "number"
}
},
{
"name": "itemsPerPage",
"in": "query",
"description": "The amount of items to return on every page. Defaults to all the items",
"required": false,
"schema": {
"type": "number"
}
}
],
"security": [
{
"ApiKey": []
}
],
"responses": {
"200": {
"description": "List of visits.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"visits": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "../definitions/OrphanVisit.json"
}
},
"pagination": {
"$ref": "../definitions/Pagination.json"
}
}
}
}
}
}
},
"examples": {
"application/json": {
"visits": {
"data": [
{
"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,
"visitedUrl": "https://doma.in",
"type": "base_url"
},
{
"referer": "https://t.co",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
"visitLocation": {
"cityName": "Cupertino",
"countryCode": "US",
"countryName": "United States",
"latitude": 37.3042,
"longitude": -122.0946,
"regionName": "California",
"timezone": "America/Los_Angeles"
},
"visitedUrl": "https://doma.in/foo",
"type": "invalid_short_url"
},
{
"referer": null,
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "some_web_crawler/1.4",
"visitLocation": null,
"visitedUrl": "https://doma.in/foo/bar/baz",
"type": "regular_404"
}
],
"pagination": {
"currentPage": 5,
"pagesCount": 12,
"itemsPerPage": 10,
"itemsInCurrentPage": 10,
"totalItems": 115
}
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View File

@@ -18,7 +18,7 @@
},
{
"name": "size",
"in": "path",
"in": "query",
"description": "The size of the image to be returned.",
"required": false,
"schema": {
@@ -40,6 +40,17 @@
"svg"
]
}
},
{
"name": "margin",
"in": "query",
"description": "The margin around the QR code image.",
"required": false,
"schema": {
"type": "integer",
"minimum": 0,
"default": 0
}
}
],
"responses": {

View File

@@ -0,0 +1,66 @@
{
"get": {
"operationId": "shortUrlQrCodeSize",
"deprecated": true,
"tags": [
"URL Shortener"
],
"summary": "Short URL QR code",
"description": "Generates a QR code image pointing to a short URL",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The short code to resolve.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "size",
"in": "path",
"description": "The size of the image to be returned.",
"required": false,
"schema": {
"type": "integer",
"minimum": 50,
"maximum": 1000,
"default": 300
}
},
{
"name": "format",
"in": "query",
"description": "The format for the QR code image, being valid values png and svg. Not providing the param or providing any other value will fall back to png.",
"required": false,
"schema": {
"type": "string",
"enum": [
"png",
"svg"
]
}
}
],
"responses": {
"200": {
"description": "QR code in PNG format",
"content": {
"image/png": {
"schema": {
"type": "string",
"format": "binary"
}
},
"image/svg+xml": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
}

View File

@@ -50,6 +50,14 @@
"name": "Visits",
"description": "Operations to manage visits on short URLs"
},
{
"name": "Domains",
"description": "Operations to manage domains used on short URLs"
},
{
"name": "Integrations",
"description": "Handle services with which shlink is integrated"
},
{
"name": "Monitoring",
"description": "Public endpoints designed to monitor the service"
@@ -87,6 +95,9 @@
"/rest/v{version}/tags/{tag}/visits": {
"$ref": "paths/v2_tags_{tag}_visits.json"
},
"/rest/v{version}/visits/orphan": {
"$ref": "paths/v2_visits_orphan.json"
},
"/rest/v{version}/domains": {
"$ref": "paths/v2_domains.json"
@@ -108,6 +119,9 @@
},
"/{shortCode}/qr-code": {
"$ref": "paths/{shortCode}_qr-code.json"
},
"/{shortCode}/qr-code/{size}": {
"$ref": "paths/{shortCode}_qr-code_{size}.json"
}
}
}

23
infection-db.json Normal file
View File

@@ -0,0 +1,23 @@
{
"source": {
"directories": [
"module/*/src"
]
},
"timeout": 5,
"logs": {
"text": "build/infection-db/infection-log.txt",
"summary": "build/infection-db/summary-log.txt",
"debug": "build/infection-db/debug-log.txt"
},
"tmpDir": "build/infection-db/temp",
"phpUnit": {
"configDir": "."
},
"testFrameworkOptions": "--configuration=phpunit-db.xml",
"mutators": {
"@default": true,
"IdenticalEqual": false,
"NotIdenticalNotEqual": false
}
}

View File

@@ -6,11 +6,11 @@
},
"timeout": 5,
"logs": {
"text": "build/infection/infection-log.txt",
"summary": "build/infection/summary-log.txt",
"debug": "build/infection/debug-log.txt"
"text": "build/infection-unit/infection-log.txt",
"summary": "build/infection-unit/summary-log.txt",
"debug": "build/infection-unit/debug-log.txt"
},
"tmpDir": "build/infection/temp",
"tmpDir": "build/infection-unit/temp",
"phpUnit": {
"configDir": "."
},

View File

@@ -8,10 +8,11 @@ use Doctrine\DBAL\Connection;
use GeoIp2\Database\Reader;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Core\Tag\TagService;
use Shlinkio\Shlink\Core\Visit;
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
@@ -32,7 +33,10 @@ return [
SymfonyCli\Helper\ProcessHelper::class => ProcessHelperFactory::class,
PhpExecutableFinder::class => InvokableFactory::class,
GeolocationDbUpdater::class => ConfigAbstractFactory::class,
Util\GeolocationDbUpdater::class => ConfigAbstractFactory::class,
Util\ProcessRunner::class => ConfigAbstractFactory::class,
ApiKey\RoleResolver::class => ConfigAbstractFactory::class,
Command\ShortUrl\GenerateShortUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class,
@@ -59,26 +63,31 @@ return [
],
ConfigAbstractFactory::class => [
GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY],
Util\GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY],
Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class],
ApiKey\RoleResolver::class => [DomainService::class],
Command\ShortUrl\GenerateShortUrlCommand::class => [
Service\UrlShortener::class,
'config.url_shortener.domain',
ShortUrlStringifier::class,
'config.url_shortener.default_short_codes_length',
],
Command\ShortUrl\ResolveUrlCommand::class => [Service\ShortUrl\ShortUrlResolver::class],
Command\ShortUrl\ListShortUrlsCommand::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'],
Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class],
Command\ShortUrl\ListShortUrlsCommand::class => [
Service\ShortUrlService::class,
ShortUrlDataTransformer::class,
],
Command\ShortUrl\GetVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
Command\Visit\LocateVisitsCommand::class => [
Visit\VisitLocator::class,
IpLocationResolverInterface::class,
LockFactory::class,
GeolocationDbUpdater::class,
Util\GeolocationDbUpdater::class,
],
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class],
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
Command\Api\ListKeysCommand::class => [ApiKeyService::class],
@@ -87,18 +96,18 @@ return [
Command\Tag\RenameTagCommand::class => [TagService::class],
Command\Tag\DeleteTagsCommand::class => [TagService::class],
Command\Domain\ListDomainsCommand::class => [DomainService::class, 'config.url_shortener.domain.hostname'],
Command\Domain\ListDomainsCommand::class => [DomainService::class],
Command\Db\CreateDatabaseCommand::class => [
LockFactory::class,
SymfonyCli\Helper\ProcessHelper::class,
Util\ProcessRunner::class,
PhpExecutableFinder::class,
Connection::class,
NoDbNameConnectionFactory::SERVICE_NAME,
],
Command\Db\MigrateDatabaseCommand::class => [
LockFactory::class,
SymfonyCli\Helper\ProcessHelper::class,
Util\ProcessRunner::class,
PhpExecutableFinder::class,
],
],

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\ApiKey;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Symfony\Component\Console\Input\InputInterface;
class RoleResolver implements RoleResolverInterface
{
private DomainServiceInterface $domainService;
public function __construct(DomainServiceInterface $domainService)
{
$this->domainService = $domainService;
}
public function determineRoles(InputInterface $input): array
{
$domainAuthority = $input->getOption('domain-only');
$author = $input->getOption('author-only');
$roleDefinitions = [];
if ($author) {
$roleDefinitions[] = RoleDefinition::forAuthoredShortUrls();
}
if ($domainAuthority !== null) {
$domain = $this->domainService->getOrCreate($domainAuthority);
$roleDefinitions[] = RoleDefinition::forDomain($domain);
}
return $roleDefinitions;
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\ApiKey;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Symfony\Component\Console\Input\InputInterface;
interface RoleResolverInterface
{
public const AUTHOR_ONLY_PARAM = 'author-only';
public const DOMAIN_ONLY_PARAM = 'domain-only';
/**
* @return RoleDefinition[]
*/
public function determineRoles(InputInterface $input): array;
}

View File

@@ -5,47 +5,98 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
use Shlinkio\Shlink\CLI\Command\BaseCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function Shlinkio\Shlink\Core\arrayToString;
use function sprintf;
class GenerateKeyCommand extends Command
class GenerateKeyCommand extends BaseCommand
{
public const NAME = 'api-key:generate';
private ApiKeyServiceInterface $apiKeyService;
private RoleResolverInterface $roleResolver;
public function __construct(ApiKeyServiceInterface $apiKeyService)
public function __construct(ApiKeyServiceInterface $apiKeyService, RoleResolverInterface $roleResolver)
{
$this->apiKeyService = $apiKeyService;
parent::__construct();
$this->apiKeyService = $apiKeyService;
$this->roleResolver = $roleResolver;
}
protected function configure(): void
{
$authorOnly = RoleResolverInterface::AUTHOR_ONLY_PARAM;
$domainOnly = RoleResolverInterface::DOMAIN_ONLY_PARAM;
$help = <<<HELP
The <info>%command.name%</info> generates a new valid API key.
<info>%command.full_name%</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>
You can also set roles to the API key:
* Can interact with short URLs created with this API key: <info>%command.full_name% --{$authorOnly}</info>
* Can interact with short URLs for one domain: <info>%command.full_name% --{$domainOnly}=example.com</info>
* Both: <info>%command.full_name% --{$authorOnly} --{$domainOnly}=example.com</info>
HELP;
$this
->setName(self::NAME)
->setDescription('Generates a new valid API key.')
->addOption(
'expirationDate',
->addOptionWithDeprecatedFallback(
'expiration-date',
'e',
InputOption::VALUE_REQUIRED,
'The date in which the API key should expire. Use any valid PHP format.',
);
)
->addOption(
$authorOnly,
'a',
InputOption::VALUE_NONE,
sprintf('Adds the "%s" role to the new API key.', Role::AUTHORED_SHORT_URLS),
)
->addOption(
$domainOnly,
'd',
InputOption::VALUE_REQUIRED,
sprintf('Adds the "%s" role to the new API key, with the domain provided.', Role::DOMAIN_SPECIFIC),
)
->setHelp($help);
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$expirationDate = $input->getOption('expirationDate');
$apiKey = $this->apiKeyService->create(isset($expirationDate) ? Chronos::parse($expirationDate) : null);
$expirationDate = $this->getOptionWithDeprecatedFallback($input, 'expiration-date');
$apiKey = $this->apiKeyService->create(
isset($expirationDate) ? Chronos::parse($expirationDate) : null,
...$this->roleResolver->determineRoles($input),
);
$io = new SymfonyStyle($input, $output);
$io->success(sprintf('Generated API key: "%s"', $apiKey->toString()));
if (! $apiKey->isAdmin()) {
ShlinkTable::fromOutput($io)->render(
['Role name', 'Role metadata'],
$apiKey->mapRoles(fn (string $name, array $meta) => [$name, arrayToString($meta, 0)]),
null,
'Roles',
);
}
(new SymfonyStyle($input, $output))->success(sprintf('Generated API key: "%s"', $apiKey));
return ExitCodes::EXIT_SUCCESS;
}
}

View File

@@ -4,20 +4,22 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Command\BaseCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use function array_filter;
use function array_map;
use function Functional\map;
use function implode;
use function sprintf;
class ListKeysCommand extends Command
class ListKeysCommand extends BaseCommand
{
private const ERROR_STRING_PATTERN = '<fg=red>%s</>';
private const SUCCESS_STRING_PATTERN = '<info>%s</info>';
@@ -38,8 +40,8 @@ class ListKeysCommand extends Command
$this
->setName(self::NAME)
->setDescription('Lists all the available API keys.')
->addOption(
'enabledOnly',
->addOptionWithDeprecatedFallback(
'enabled-only',
'e',
InputOption::VALUE_NONE,
'Tells if only enabled API keys should be returned.',
@@ -48,9 +50,9 @@ class ListKeysCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$enabledOnly = $input->getOption('enabledOnly');
$enabledOnly = $this->getOptionWithDeprecatedFallback($input, 'enabled-only');
$rows = array_map(function (ApiKey $apiKey) use ($enabledOnly) {
$rows = map($this->apiKeyService->listKeys($enabledOnly), function (ApiKey $apiKey) use ($enabledOnly) {
$expiration = $apiKey->getExpirationDate();
$messagePattern = $this->determineMessagePattern($apiKey);
@@ -60,13 +62,21 @@ class ListKeysCommand extends Command
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
}
$rowData[] = $expiration !== null ? $expiration->toAtomString() : '-';
$rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles(
fn (string $roleName, array $meta) =>
empty($meta)
? Role::toFriendlyName($roleName)
: sprintf('%s: %s', Role::toFriendlyName($roleName), Role::domainAuthorityFromMeta($meta)),
));
return $rowData;
}, $this->apiKeyService->listKeys($enabledOnly));
});
ShlinkTable::fromOutput($output)->render(array_filter([
'Key',
! $enabledOnly ? 'Is enabled' : null,
'Expiration date',
'Roles',
]), $rows);
return ExitCodes::EXIT_SUCCESS;
}
@@ -80,8 +90,6 @@ class ListKeysCommand extends Command
return $apiKey->isExpired() ? self::WARNING_STRING_PATTERN : self::SUCCESS_STRING_PATTERN;
}
/**
*/
private function getEnabledSymbol(ApiKey $apiKey): string
{
return ! $apiKey->isEnabled() || $apiKey->isExpired() ? '---' : '+++';

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use function method_exists;
use function Shlinkio\Shlink\Core\kebabCaseToCamelCase;
use function sprintf;
use function str_contains;
abstract class BaseCommand extends Command
{
/**
* @param mixed|null $default
*/
protected function addOptionWithDeprecatedFallback(
string $name,
?string $shortcut = null,
?int $mode = null,
string $description = '',
$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);
}
return $this;
}
/**
* @return bool|string|string[]|null
*/
protected function getOptionWithDeprecatedFallback(InputInterface $input, string $name)
{
$rawInput = method_exists($input, '__toString') ? $input->__toString() : '';
$camelCaseName = kebabCaseToCamelCase($name);
if (str_contains($rawInput, $camelCaseName)) {
return $input->getOption($camelCaseName);
}
return $input->getOption($name);
}
}

View File

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

View File

@@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Db;
use Doctrine\DBAL\Connection;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Symfony\Component\Console\Helper\ProcessHelper;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
@@ -26,12 +26,12 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
public function __construct(
LockFactory $locker,
ProcessHelper $processHelper,
ProcessRunnerInterface $processRunner,
PhpExecutableFinder $phpFinder,
Connection $conn,
Connection $noDbNameConn
) {
parent::__construct($locker, $processHelper, $phpFinder);
parent::__construct($locker, $processRunner, $phpFinder);
$this->regularConn = $conn;
$this->noDbNameConn = $noDbNameConn;
}

View File

@@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@@ -19,13 +19,11 @@ class ListDomainsCommand extends Command
public const NAME = 'domain:list';
private DomainServiceInterface $domainService;
private string $defaultDomain;
public function __construct(DomainServiceInterface $domainService, string $defaultDomain)
public function __construct(DomainServiceInterface $domainService)
{
parent::__construct();
$this->domainService = $domainService;
$this->defaultDomain = $defaultDomain;
}
protected function configure(): void
@@ -37,12 +35,12 @@ class ListDomainsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$regularDomains = $this->domainService->listDomainsWithout($this->defaultDomain);
$domains = $this->domainService->listDomains();
ShlinkTable::fromOutput($output)->render(['Domain', 'Is default'], [
[$this->defaultDomain, 'Yes'],
...map($regularDomains, fn (Domain $domain) => [$domain->getAuthority(), 'No']),
]);
ShlinkTable::fromOutput($output)->render(
['Domain', 'Is default'],
map($domains, fn (DomainItem $domain) => [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No']),
);
return ExitCodes::EXIT_SUCCESS;
}

View File

@@ -4,13 +4,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Command\BaseCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
use Symfony\Component\Console\Command\Command;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@@ -23,21 +24,24 @@ use function Functional\flatten;
use function Functional\unique;
use function method_exists;
use function sprintf;
use function strpos;
use function str_contains;
class GenerateShortUrlCommand extends Command
class GenerateShortUrlCommand extends BaseCommand
{
public const NAME = 'short-url:generate';
private UrlShortenerInterface $urlShortener;
private array $domainConfig;
private ShortUrlStringifierInterface $stringifier;
private int $defaultShortCodeLength;
public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig, int $defaultShortCodeLength)
{
public function __construct(
UrlShortenerInterface $urlShortener,
ShortUrlStringifierInterface $stringifier,
int $defaultShortCodeLength
) {
parent::__construct();
$this->urlShortener = $urlShortener;
$this->domainConfig = $domainConfig;
$this->stringifier = $stringifier;
$this->defaultShortCodeLength = $defaultShortCodeLength;
}
@@ -53,34 +57,34 @@ class GenerateShortUrlCommand extends Command
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
'Tags to apply to the new short URL',
)
->addOption(
'validSince',
->addOptionWithDeprecatedFallback(
'valid-since',
's',
InputOption::VALUE_REQUIRED,
'The date from which this short URL will be valid. '
. 'If someone tries to access it before this date, it will not be found.',
)
->addOption(
'validUntil',
->addOptionWithDeprecatedFallback(
'valid-until',
'u',
InputOption::VALUE_REQUIRED,
'The date until which this short URL will be valid. '
. 'If someone tries to access it after this date, it will not be found.',
)
->addOption(
'customSlug',
->addOptionWithDeprecatedFallback(
'custom-slug',
'c',
InputOption::VALUE_REQUIRED,
'If provided, this slug will be used instead of generating a short code',
)
->addOption(
'maxVisits',
->addOptionWithDeprecatedFallback(
'max-visits',
'm',
InputOption::VALUE_REQUIRED,
'This will limit the number of visits for this short URL.',
)
->addOption(
'findIfExists',
->addOptionWithDeprecatedFallback(
'find-if-exists',
'f',
InputOption::VALUE_NONE,
'This will force existing matching URL to be returned if found, instead of creating a new one.',
@@ -91,11 +95,11 @@ class GenerateShortUrlCommand extends Command
InputOption::VALUE_REQUIRED,
'The domain to which this short URL will be attached.',
)
->addOption(
'shortCodeLength',
->addOptionWithDeprecatedFallback(
'short-code-length',
'l',
InputOption::VALUE_REQUIRED,
'The length for generated short code (it will be ignored if --customSlug was provided).',
'The length for generated short code (it will be ignored if --custom-slug was provided).',
)
->addOption(
'validate-url',
@@ -136,26 +140,34 @@ class GenerateShortUrlCommand extends Command
$explodeWithComma = curry('explode')(',');
$tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
$customSlug = $input->getOption('customSlug');
$maxVisits = $input->getOption('maxVisits');
$shortCodeLength = $input->getOption('shortCodeLength') ?? $this->defaultShortCodeLength;
$customSlug = $this->getOptionWithDeprecatedFallback($input, 'custom-slug');
$maxVisits = $this->getOptionWithDeprecatedFallback($input, 'max-visits');
$shortCodeLength = $this->getOptionWithDeprecatedFallback(
$input,
'short-code-length',
) ?? $this->defaultShortCodeLength;
$doValidateUrl = $this->doValidateUrl($input);
try {
$shortUrl = $this->urlShortener->shorten($longUrl, $tags, ShortUrlMeta::fromRawData([
ShortUrlMetaInputFilter::VALID_SINCE => $input->getOption('validSince'),
ShortUrlMetaInputFilter::VALID_UNTIL => $input->getOption('validUntil'),
ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug,
ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
ShortUrlMetaInputFilter::FIND_IF_EXISTS => $input->getOption('findIfExists'),
ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'),
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
ShortUrlMetaInputFilter::VALIDATE_URL => $doValidateUrl,
$shortUrl = $this->urlShortener->shorten(ShortUrlMeta::fromRawData([
ShortUrlInputFilter::LONG_URL => $longUrl,
ShortUrlInputFilter::VALID_SINCE => $this->getOptionWithDeprecatedFallback($input, 'valid-since'),
ShortUrlInputFilter::VALID_UNTIL => $this->getOptionWithDeprecatedFallback($input, 'valid-until'),
ShortUrlInputFilter::CUSTOM_SLUG => $customSlug,
ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
ShortUrlInputFilter::FIND_IF_EXISTS => $this->getOptionWithDeprecatedFallback(
$input,
'find-if-exists',
),
ShortUrlInputFilter::DOMAIN => $input->getOption('domain'),
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
ShortUrlInputFilter::VALIDATE_URL => $doValidateUrl,
ShortUrlInputFilter::TAGS => $tags,
]));
$io->writeln([
sprintf('Processed long URL: <info>%s</info>', $longUrl),
sprintf('Generated short URL: <info>%s</info>', $shortUrl->toString($this->domainConfig)),
sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($shortUrl)),
]);
return ExitCodes::EXIT_SUCCESS;
} catch (InvalidUrlException | NonUniqueSlugException $e) {
@@ -168,10 +180,10 @@ class GenerateShortUrlCommand extends Command
{
$rawInput = method_exists($input, '__toString') ? $input->__toString() : '';
if (strpos($rawInput, '--no-validate-url') !== false) {
if (str_contains($rawInput, '--no-validate-url')) {
return false;
}
if (strpos($rawInput, '--validate-url') !== false) {
if (str_contains($rawInput, '--validate-url')) {
return true;
}

View File

@@ -11,8 +11,8 @@ use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@@ -21,16 +21,17 @@ use Symfony\Component\Console\Style\SymfonyStyle;
use function Functional\map;
use function Functional\select_keys;
use function sprintf;
class GetVisitsCommand extends AbstractWithDateRangeCommand
{
public const NAME = 'short-url:visits';
private VisitsTrackerInterface $visitsTracker;
private VisitsStatsHelperInterface $visitsHelper;
public function __construct(VisitsTrackerInterface $visitsTracker)
public function __construct(VisitsStatsHelperInterface $visitsHelper)
{
$this->visitsTracker = $visitsTracker;
$this->visitsHelper = $visitsHelper;
parent::__construct();
}
@@ -39,18 +40,18 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
$this
->setName(self::NAME)
->setDescription('Returns the detailed visits information for provided short code')
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get')
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code');
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get.')
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code.');
}
protected function getStartDateDesc(): string
protected function getStartDateDesc(string $optionName): string
{
return 'Allows to filter visits, returning only those older than start date';
return sprintf('Allows to filter visits, returning only those older than "%s".', $optionName);
}
protected function getEndDateDesc(): string
protected function getEndDateDesc(string $optionName): string
{
return 'Allows to filter visits, returning only those newer than end date';
return sprintf('Allows to filter visits, returning only those newer than "%s".', $optionName);
}
protected function interact(InputInterface $input, OutputInterface $output): void
@@ -70,12 +71,15 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$identifier = ShortUrlIdentifier::fromCli($input);
$startDate = $this->getDateOption($input, $output, 'startDate');
$endDate = $this->getDateOption($input, $output, 'endDate');
$startDate = $this->getStartDateOption($input, $output);
$endDate = $this->getEndDateOption($input, $output);
$paginator = $this->visitsTracker->info($identifier, new VisitsParams(new DateRange($startDate, $endDate)));
$paginator = $this->visitsHelper->visitsForShortUrl(
$identifier,
new VisitsParams(new DateRange($startDate, $endDate)),
);
$rows = map($paginator->getCurrentItems(), function (Visit $visit) {
$rows = map($paginator->getCurrentPageResults(), function (Visit $visit) {
$rowData = $visit->jsonSerialize();
$rowData['country'] = ($visit->getVisitLocation() ?? new UnknownVisitLocation())->getCountryName();
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);

View File

@@ -4,51 +4,53 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Laminas\Paginator\Paginator;
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_flip;
use function array_intersect_key;
use function array_values;
use function count;
use function array_pad;
use function explode;
use function Functional\map;
use function implode;
use function sprintf;
class ListShortUrlsCommand extends AbstractWithDateRangeCommand
{
use PaginatorUtilsTrait;
use PagerfantaUtilsTrait;
public const NAME = 'short-url:list';
private const COLUMNS_WHITELIST = [
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 ShortUrlDataTransformer $transformer;
private DataTransformerInterface $transformer;
public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig)
public function __construct(ShortUrlServiceInterface $shortUrlService, DataTransformerInterface $transformer)
{
parent::__construct();
$this->shortUrlService = $shortUrlService;
$this->transformer = new ShortUrlDataTransformer($domainConfig);
$this->transformer = $transformer;
}
protected function doConfigure(): void
@@ -60,28 +62,34 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
'page',
'p',
InputOption::VALUE_REQUIRED,
'The first page to list (10 items per page unless "--all" is provided)',
'The first page to list (10 items per page unless "--all" is provided).',
'1',
)
->addOption(
'searchTerm',
->addOptionWithDeprecatedFallback(
'search-term',
'st',
InputOption::VALUE_REQUIRED,
'A query used to filter results by searching for it on the longUrl and shortCode fields',
'A query used to filter results by searching for it on the longUrl and shortCode fields.',
)
->addOption(
'tags',
't',
InputOption::VALUE_REQUIRED,
'A comma-separated list of tags to filter results',
'A comma-separated list of tags to filter results.',
)
->addOption(
'orderBy',
->addOptionWithDeprecatedFallback(
'order-by',
'o',
InputOption::VALUE_REQUIRED,
'The field from which we want to order by. Pass ASC or DESC separated by a comma',
'The field from which you want to order by. '
. 'Define ordering dir by passing ASC or DESC after "," or "-".',
)
->addOptionWithDeprecatedFallback(
'show-tags',
null,
InputOption::VALUE_NONE,
'Whether to display the tags or not.',
)
->addOption('showTags', null, InputOption::VALUE_NONE, 'Whether to display the tags or not')
->addOption(
'all',
'a',
@@ -91,14 +99,14 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
);
}
protected function getStartDateDesc(): string
protected function getStartDateDesc(string $optionName): string
{
return 'Allows to filter short URLs, returning only those created after "startDate"';
return sprintf('Allows to filter short URLs, returning only those created after "%s".', $optionName);
}
protected function getEndDateDesc(): string
protected function getEndDateDesc(string $optionName): string
{
return 'Allows to filter short URLs, returning only those created before "endDate"';
return sprintf('Allows to filter short URLs, returning only those created before "%s".', $optionName);
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
@@ -106,13 +114,13 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
$io = new SymfonyStyle($input, $output);
$page = (int) $input->getOption('page');
$searchTerm = $input->getOption('searchTerm');
$searchTerm = $this->getOptionWithDeprecatedFallback($input, 'search-term');
$tags = $input->getOption('tags');
$tags = ! empty($tags) ? explode(',', $tags) : [];
$showTags = (bool) $input->getOption('showTags');
$all = (bool) $input->getOption('all');
$startDate = $this->getDateOption($input, $output, 'startDate');
$endDate = $this->getDateOption($input, $output, 'endDate');
$showTags = $this->getOptionWithDeprecatedFallback($input, 'show-tags');
$all = $input->getOption('all');
$startDate = $this->getStartDateOption($input, $output);
$endDate = $this->getEndDateOption($input, $output);
$orderBy = $this->processOrderBy($input);
$data = [
@@ -132,7 +140,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
$result = $this->renderPage($output, $showTags, ShortUrlsParams::fromRawData($data), $all);
$page++;
$continue = ! $this->isLastPage($result) && $io->confirm(
$continue = $result->hasNextPage() && $io->confirm(
sprintf('Continue with page <options=bold>%s</>?', $page),
false,
);
@@ -148,21 +156,20 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
{
$result = $this->shortUrlService->listShortUrls($params);
$headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
$headers = ['Short code', 'Title', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
if ($showTags) {
$headers[] = 'Tags';
}
$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']);
} else {
unset($shortUrl['tags']);
}
$rows[] = array_values(array_intersect_key($shortUrl, array_flip(self::COLUMNS_WHITELIST)));
$rows[] = map($columnsToShow, fn (string $prop) => $shortUrl[$prop]);
}
ShlinkTable::fromOutput($output)->render($headers, $rows, $all ? null : $this->formatCurrentPageMessage(
@@ -173,17 +180,14 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
return $result;
}
/**
* @return array|string|null
*/
private function processOrderBy(InputInterface $input)
private function processOrderBy(InputInterface $input): ?string
{
$orderBy = $input->getOption('orderBy');
$orderBy = $this->getOptionWithDeprecatedFallback($input, 'order-by');
if (empty($orderBy)) {
return null;
}
$orderBy = explode(',', $orderBy);
return count($orderBy) === 1 ? $orderBy[0] : [$orderBy[0] => $orderBy[1]];
[$field, $dir] = array_pad(explode(',', $orderBy), 2, null);
return $dir === null ? $field : sprintf('%s-%s', $field, $dir);
}
}

View File

@@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -42,7 +43,7 @@ class RenameTagCommand extends Command
$newName = $input->getArgument('newName');
try {
$this->tagService->renameTag($oldName, $newName);
$this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName));
$io->success('Tag properly renamed.');
return ExitCodes::EXIT_SUCCESS;
} catch (TagNotFoundException | TagConflictException $e) {

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Util;
use Cake\Chronos\Chronos;
use Symfony\Component\Console\Command\Command;
use Shlinkio\Shlink\CLI\Command\BaseCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
@@ -13,19 +13,42 @@ use Throwable;
use function sprintf;
abstract class AbstractWithDateRangeCommand extends Command
abstract class AbstractWithDateRangeCommand extends BaseCommand
{
private const START_DATE = 'start-date';
private const END_DATE = 'end-date';
final protected function configure(): void
{
$this->doConfigure();
$this
->addOption('startDate', 's', InputOption::VALUE_REQUIRED, $this->getStartDateDesc())
->addOption('endDate', 'e', InputOption::VALUE_REQUIRED, $this->getEndDateDesc());
->addOptionWithDeprecatedFallback(
self::START_DATE,
's',
InputOption::VALUE_REQUIRED,
$this->getStartDateDesc(self::START_DATE),
)
->addOptionWithDeprecatedFallback(
self::END_DATE,
'e',
InputOption::VALUE_REQUIRED,
$this->getEndDateDesc(self::END_DATE),
);
}
protected function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos
protected function getStartDateOption(InputInterface $input, OutputInterface $output): ?Chronos
{
$value = $input->getOption($key);
return $this->getDateOption($input, $output, self::START_DATE);
}
protected function getEndDateOption(InputInterface $input, OutputInterface $output): ?Chronos
{
return $this->getDateOption($input, $output, self::END_DATE);
}
private function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos
{
$value = $this->getOptionWithDeprecatedFallback($input, $key);
if (empty($value)) {
return null;
}
@@ -49,6 +72,7 @@ abstract class AbstractWithDateRangeCommand extends Command
abstract protected function doConfigure(): void;
abstract protected function getStartDateDesc(): string;
abstract protected function getEndDateDesc(): string;
abstract protected function getStartDateDesc(string $optionName): string;
abstract protected function getEndDateDesc(string $optionName): string;
}

View File

@@ -6,19 +6,29 @@ namespace Shlinkio\Shlink\CLI\Command\Util;
final class LockedCommandConfig
{
private const DEFAULT_TTL = 90.0; // 1.5 minutes
public const DEFAULT_TTL = 600.0; // 10 minutes
private string $lockName;
private bool $isBlocking;
private float $ttl;
public function __construct(string $lockName, bool $isBlocking = false, float $ttl = self::DEFAULT_TTL)
private function __construct(string $lockName, bool $isBlocking, float $ttl = self::DEFAULT_TTL)
{
$this->lockName = $lockName;
$this->isBlocking = $isBlocking;
$this->ttl = $ttl;
}
public static function blocking(string $lockName): self
{
return new self($lockName, true);
}
public static function nonBlocking(string $lockName): self
{
return new self($lockName, false);
}
public function lockName(): string
{
return $this->lockName;

View File

@@ -208,6 +208,6 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
protected function getLockConfig(): LockedCommandConfig
{
return new LockedCommandConfig($this->getName());
return LockedCommandConfig::nonBlocking($this->getName());
}
}

View File

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

View File

@@ -6,11 +6,14 @@ namespace Shlinkio\Shlink\CLI\Util;
use Cake\Chronos\Chronos;
use GeoIp2\Database\Reader;
use MaxMind\Db\Reader\Metadata;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Symfony\Component\Lock\LockFactory;
use function is_int;
class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
{
private const LOCK_NAME = 'geolocation-db-update';
@@ -52,7 +55,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
}
$meta = $this->geoLiteDbReader->metadata();
if ($this->buildIsTooOld($meta->buildEpoch)) {
if ($this->buildIsTooOld($meta)) {
$this->downloadNewDb(true, $mustBeUpdated, $handleProgress);
}
}
@@ -69,14 +72,37 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
try {
$this->dbUpdater->downloadFreshCopy($handleProgress);
} catch (RuntimeException $e) {
throw GeolocationDbUpdateFailedException::create($olderDbExists, $e);
throw $olderDbExists
? GeolocationDbUpdateFailedException::withOlderDb($e)
: GeolocationDbUpdateFailedException::withoutOlderDb($e);
}
}
private function buildIsTooOld(int $buildTimestamp): bool
private function buildIsTooOld(Metadata $meta): bool
{
$buildTimestamp = $this->resolveBuildTimestamp($meta);
$buildDate = Chronos::createFromTimestamp($buildTimestamp);
$now = Chronos::now();
return $now->gt($buildDate->addDays(35));
}
private function resolveBuildTimestamp(Metadata $meta): int
{
// In theory the buildEpoch should be an int, but it has been reported to come as a string.
// See https://github.com/shlinkio/shlink/issues/1002 for context
/** @var int|string $buildEpoch */
$buildEpoch = $meta->buildEpoch;
if (is_int($buildEpoch)) {
return $buildEpoch;
}
$intBuildEpoch = (int) $buildEpoch;
if ($buildEpoch === (string) $intBuildEpoch) {
return $intBuildEpoch;
}
throw GeolocationDbUpdateFailedException::withInvalidEpochInOldDb($buildEpoch);
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Util;
use Closure;
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
use Symfony\Component\Console\Helper\DebugFormatterHelper;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
use function spl_object_hash;
use function sprintf;
use function str_replace;
class ProcessRunner implements ProcessRunnerInterface
{
private ProcessHelper $helper;
private Closure $createProcess;
public function __construct(ProcessHelper $helper, ?callable $createProcess = null)
{
$this->helper = $helper;
$this->createProcess = $createProcess !== null
? Closure::fromCallable($createProcess)
: static fn (array $cmd) => new Process($cmd, null, null, null, LockedCommandConfig::DEFAULT_TTL);
}
public function run(OutputInterface $output, array $cmd): void
{
if ($output instanceof ConsoleOutputInterface) {
$output = $output->getErrorOutput();
}
/** @var DebugFormatterHelper $formatter */
$formatter = $this->helper->getHelperSet()->get('debug_formatter');
/** @var Process $process */
$process = ($this->createProcess)($cmd);
if ($output->isVeryVerbose()) {
$output->write(
$formatter->start(spl_object_hash($process), str_replace('<', '\\<', $process->getCommandLine())),
);
}
$callback = $output->isDebug() ? $this->helper->wrapCallback($output, $process) : null;
$process->mustRun($callback);
if ($output->isVeryVerbose()) {
$message = $process->isSuccessful() ? 'Command ran successfully' : sprintf(
'%s Command did not run successfully',
$process->getExitCode(),
);
$output->write($formatter->stop(spl_object_hash($process), $message, $process->isSuccessful()));
}
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Util;
use Symfony\Component\Console\Output\OutputInterface;
interface ProcessRunnerInterface
{
public function run(OutputInterface $output, array $cmd): void;
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\ApiKey;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolver;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Symfony\Component\Console\Input\InputInterface;
class RoleResolverTest extends TestCase
{
use ProphecyTrait;
private RoleResolver $resolver;
private ObjectProphecy $domainService;
protected function setUp(): void
{
$this->domainService = $this->prophesize(DomainServiceInterface::class);
$this->resolver = new RoleResolver($this->domainService->reveal());
}
/**
* @test
* @dataProvider provideRoles
*/
public function properRolesAreResolvedBasedOnInput(
InputInterface $input,
array $expectedRoles,
int $expectedDomainCalls
): void {
$getDomain = $this->domainService->getOrCreate('example.com')->willReturn(
(new Domain('example.com'))->setId('1'),
);
$result = $this->resolver->determineRoles($input);
self::assertEquals($expectedRoles, $result);
$getDomain->shouldHaveBeenCalledTimes($expectedDomainCalls);
}
public function provideRoles(): iterable
{
$domain = (new Domain('example.com'))->setId('1');
$buildInput = function (array $definition): InputInterface {
$input = $this->prophesize(InputInterface::class);
foreach ($definition as $name => $value) {
$input->getOption($name)->willReturn($value);
}
return $input->reveal();
};
yield 'no roles' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => false]),
[],
0,
];
yield 'domain role only' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => false]),
[RoleDefinition::forDomain($domain)],
1,
];
yield 'author role only' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => true]),
[RoleDefinition::forAuthoredShortUrls()],
0,
];
yield 'both roles' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => true]),
[RoleDefinition::forAuthoredShortUrls(), RoleDefinition::forDomain($domain)],
1,
];
}
}

View File

@@ -9,10 +9,12 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Tester\CommandTester;
class GenerateKeyCommandTest extends TestCase
@@ -21,11 +23,15 @@ class GenerateKeyCommandTest extends TestCase
private CommandTester $commandTester;
private ObjectProphecy $apiKeyService;
private ObjectProphecy $roleResolver;
public function setUp(): void
{
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
$command = new GenerateKeyCommand($this->apiKeyService->reveal());
$this->roleResolver = $this->prophesize(RoleResolverInterface::class);
$this->roleResolver->determineRoles(Argument::type(InputInterface::class))->willReturn([]);
$command = new GenerateKeyCommand($this->apiKeyService->reveal(), $this->roleResolver->reveal());
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
@@ -49,7 +55,7 @@ class GenerateKeyCommandTest extends TestCase
$this->apiKeyService->create(Argument::type(Chronos::class))->shouldBeCalledOnce()
->willReturn(new ApiKey());
$this->commandTester->execute([
'--expirationDate' => '2016-01-01',
'--expiration-date' => '2016-01-01',
]);
}
}

View File

@@ -8,6 +8,8 @@ use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Application;
@@ -29,42 +31,87 @@ class ListKeysCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/** @test */
public function everythingIsListedIfEnabledOnlyIsNotProvided(): void
/**
* @test
* @dataProvider provideKeysAndOutputs
*/
public function returnsExpectedOutput(array $keys, bool $enabledOnly, string $expected): void
{
$this->apiKeyService->listKeys(false)->willReturn([
new ApiKey(),
new ApiKey(),
new ApiKey(),
])->shouldBeCalledOnce();
$listKeys = $this->apiKeyService->listKeys($enabledOnly)->willReturn($keys);
$this->commandTester->execute([]);
$this->commandTester->execute(['--enabled-only' => $enabledOnly]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('Key', $output);
self::assertStringContainsString('Is enabled', $output);
self::assertStringContainsString(' +++ ', $output);
self::assertStringNotContainsString(' --- ', $output);
self::assertStringContainsString('Expiration date', $output);
self::assertEquals($expected, $output);
$listKeys->shouldHaveBeenCalledOnce();
}
/** @test */
public function onlyEnabledKeysAreListedIfEnabledOnlyIsProvided(): void
public function provideKeysAndOutputs(): iterable
{
$this->apiKeyService->listKeys(true)->willReturn([
(new ApiKey())->disable(),
new ApiKey(),
])->shouldBeCalledOnce();
yield 'all keys' => [
[ApiKey::withKey('foo'), ApiKey::withKey('bar'), ApiKey::withKey('baz')],
false,
<<<OUTPUT
+-----+------------+-----------------+-------+
| Key | Is enabled | Expiration date | Roles |
+-----+------------+-----------------+-------+
| foo | +++ | - | Admin |
| bar | +++ | - | Admin |
| baz | +++ | - | Admin |
+-----+------------+-----------------+-------+
$this->commandTester->execute([
'--enabledOnly' => true,
]);
$output = $this->commandTester->getDisplay();
OUTPUT,
];
yield 'enabled keys' => [
[ApiKey::withKey('foo')->disable(), ApiKey::withKey('bar')],
true,
<<<OUTPUT
+-----+-----------------+-------+
| Key | Expiration date | Roles |
+-----+-----------------+-------+
| foo | - | Admin |
| bar | - | Admin |
+-----+-----------------+-------+
self::assertStringContainsString('Key', $output);
self::assertStringNotContainsString('Is enabled', $output);
self::assertStringNotContainsString(' +++ ', $output);
self::assertStringNotContainsString(' --- ', $output);
self::assertStringContainsString('Expiration date', $output);
OUTPUT,
];
yield 'with roles' => [
[
ApiKey::withKey('foo'),
$this->apiKeyWithRoles('bar', [RoleDefinition::forAuthoredShortUrls()]),
$this->apiKeyWithRoles('baz', [RoleDefinition::forDomain((new Domain('example.com'))->setId('1'))]),
ApiKey::withKey('foo2'),
$this->apiKeyWithRoles('baz2', [
RoleDefinition::forAuthoredShortUrls(),
RoleDefinition::forDomain((new Domain('example.com'))->setId('1')),
]),
ApiKey::withKey('foo3'),
],
true,
<<<OUTPUT
+------+-----------------+--------------------------+
| Key | Expiration date | Roles |
+------+-----------------+--------------------------+
| foo | - | Admin |
| bar | - | Author only |
| baz | - | Domain only: example.com |
| foo2 | - | Admin |
| baz2 | - | Author only |
| | | Domain only: example.com |
| foo3 | - | Admin |
+------+-----------------+--------------------------+
OUTPUT,
];
}
private function apiKeyWithRoles(string $key, array $roles): ApiKey
{
$apiKey = ApiKey::withKey($key);
foreach ($roles as $role) {
$apiKey->registerRole($role);
}
return $apiKey;
}
}

View File

@@ -12,14 +12,13 @@ use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
class CreateDatabaseCommandTest extends TestCase
{
@@ -43,7 +42,7 @@ class CreateDatabaseCommandTest extends TestCase
$phpExecutableFinder = $this->prophesize(PhpExecutableFinder::class);
$phpExecutableFinder->find(false)->willReturn('/usr/local/bin/php');
$this->processHelper = $this->prophesize(ProcessHelper::class);
$this->processHelper = $this->prophesize(ProcessRunnerInterface::class);
$this->schemaManager = $this->prophesize(AbstractSchemaManager::class);
$this->databasePlatform = $this->prophesize(AbstractPlatform::class);
@@ -113,12 +112,12 @@ class CreateDatabaseCommandTest extends TestCase
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
});
$listTables = $this->schemaManager->listTableNames()->willReturn([]);
$runCommand = $this->processHelper->mustRun(Argument::type(OutputInterface::class), [
$runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [
'/usr/local/bin/php',
CreateDatabaseCommand::DOCTRINE_SCRIPT,
CreateDatabaseCommand::DOCTRINE_CREATE_SCHEMA_COMMAND,
'--no-interaction',
], Argument::cetera())->willReturn(new Process([]));
]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();

View File

@@ -9,14 +9,13 @@ use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
class MigrateDatabaseCommandTest extends TestCase
{
@@ -37,7 +36,7 @@ class MigrateDatabaseCommandTest extends TestCase
$phpExecutableFinder = $this->prophesize(PhpExecutableFinder::class);
$phpExecutableFinder->find(false)->willReturn('/usr/local/bin/php');
$this->processHelper = $this->prophesize(ProcessHelper::class);
$this->processHelper = $this->prophesize(ProcessRunnerInterface::class);
$command = new MigrateDatabaseCommand(
$locker->reveal(),
@@ -53,12 +52,12 @@ class MigrateDatabaseCommandTest extends TestCase
/** @test */
public function migrationsCommandIsRunWithProperVerbosity(): void
{
$runCommand = $this->processHelper->mustRun(Argument::type(OutputInterface::class), [
$runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [
'/usr/local/bin/php',
MigrateDatabaseCommand::DOCTRINE_MIGRATIONS_SCRIPT,
MigrateDatabaseCommand::DOCTRINE_MIGRATE_COMMAND,
'--no-interaction',
], Argument::cetera())->willReturn(new Process([]));
]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();

View File

@@ -10,7 +10,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
@@ -25,7 +25,7 @@ class ListDomainsCommandTest extends TestCase
{
$this->domainService = $this->prophesize(DomainServiceInterface::class);
$command = new ListDomainsCommand($this->domainService->reveal(), 'foo.com');
$command = new ListDomainsCommand($this->domainService->reveal());
$app = new Application();
$app->add($command);
@@ -45,9 +45,10 @@ class ListDomainsCommandTest extends TestCase
+---------+------------+
OUTPUT;
$listDomains = $this->domainService->listDomainsWithout('foo.com')->willReturn([
new Domain('bar.com'),
new Domain('baz.com'),
$listDomains = $this->domainService->listDomains()->willReturn([
new DomainItem('foo.com', true),
new DomainItem('bar.com', false),
new DomainItem('baz.com', false),
]);
$this->commandTester->execute([]);

View File

@@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
@@ -23,18 +24,17 @@ class GenerateShortUrlCommandTest extends TestCase
{
use ProphecyTrait;
private const DOMAIN_CONFIG = [
'schema' => 'http',
'hostname' => 'foo.com',
];
private CommandTester $commandTester;
private ObjectProphecy $urlShortener;
private ObjectProphecy $stringifier;
public function setUp(): void
{
$this->urlShortener = $this->prophesize(UrlShortener::class);
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), self::DOMAIN_CONFIG, 5);
$this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class);
$this->stringifier->stringify(Argument::type(ShortUrl::class))->willReturn('');
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), $this->stringifier->reveal(), 5);
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
@@ -43,18 +43,20 @@ class GenerateShortUrlCommandTest extends TestCase
/** @test */
public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void
{
$shortUrl = new ShortUrl('');
$shortUrl = ShortUrl::createEmpty();
$urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willReturn($shortUrl);
$stringify = $this->stringifier->stringify($shortUrl)->willReturn('stringified_short_url');
$this->commandTester->execute([
'longUrl' => 'http://domain.com/foo/bar',
'--maxVisits' => '3',
'--max-visits' => '3',
]);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
self::assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output);
self::assertStringContainsString('stringified_short_url', $output);
$urlToShortCode->shouldHaveBeenCalledOnce();
$stringify->shouldHaveBeenCalledOnce();
}
/** @test */
@@ -78,7 +80,7 @@ class GenerateShortUrlCommandTest extends TestCase
NonUniqueSlugException::fromSlug('my-slug'),
);
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--customSlug' => 'my-slug']);
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
@@ -89,15 +91,15 @@ class GenerateShortUrlCommandTest extends TestCase
/** @test */
public function properlyProcessesProvidedTags(): void
{
$shortUrl = new ShortUrl('');
$shortUrl = ShortUrl::createEmpty();
$urlToShortCode = $this->urlShortener->shorten(
Argument::type('string'),
Argument::that(function (array $tags) {
Argument::that(function (ShortUrlMeta $meta) {
$tags = $meta->getTags();
Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $tags);
return $tags;
return true;
}),
Argument::cetera(),
)->willReturn($shortUrl);
$stringify = $this->stringifier->stringify($shortUrl)->willReturn('stringified_short_url');
$this->commandTester->execute([
'longUrl' => 'http://domain.com/foo/bar',
@@ -106,8 +108,9 @@ class GenerateShortUrlCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
self::assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output);
self::assertStringContainsString('stringified_short_url', $output);
$urlToShortCode->shouldHaveBeenCalledOnce();
$stringify->shouldHaveBeenCalledOnce();
}
/**
@@ -116,10 +119,8 @@ class GenerateShortUrlCommandTest extends TestCase
*/
public function urlValidationHasExpectedValueBasedOnProvidedTags(array $options, ?bool $expectedValidateUrl): void
{
$shortUrl = new ShortUrl('');
$shortUrl = ShortUrl::createEmpty();
$urlToShortCode = $this->urlShortener->shorten(
Argument::type('string'),
Argument::type('array'),
Argument::that(function (ShortUrlMeta $meta) use ($expectedValidateUrl) {
Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl());
return $meta;

View File

@@ -5,13 +5,13 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use Laminas\Paginator\Adapter\ArrayAdapter;
use Laminas\Paginator\Paginator;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
@@ -19,7 +19,7 @@ use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
@@ -31,12 +31,12 @@ class GetVisitsCommandTest extends TestCase
use ProphecyTrait;
private CommandTester $commandTester;
private ObjectProphecy $visitsTracker;
private ObjectProphecy $visitsHelper;
public function setUp(): void
{
$this->visitsTracker = $this->prophesize(VisitsTrackerInterface::class);
$command = new GetVisitsCommand($this->visitsTracker->reveal());
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$command = new GetVisitsCommand($this->visitsHelper->reveal());
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
@@ -46,7 +46,7 @@ class GetVisitsCommandTest extends TestCase
public function noDateFlagsTriesToListWithoutDateRange(): void
{
$shortCode = 'abc123';
$this->visitsTracker->info(
$this->visitsHelper->visitsForShortUrl(
new ShortUrlIdentifier($shortCode),
new VisitsParams(new DateRange(null, null)),
)
@@ -62,7 +62,7 @@ class GetVisitsCommandTest extends TestCase
$shortCode = 'abc123';
$startDate = '2016-01-01';
$endDate = '2016-02-01';
$this->visitsTracker->info(
$this->visitsHelper->visitsForShortUrl(
new ShortUrlIdentifier($shortCode),
new VisitsParams(new DateRange(Chronos::parse($startDate), Chronos::parse($endDate))),
)
@@ -71,8 +71,8 @@ class GetVisitsCommandTest extends TestCase
$this->commandTester->execute([
'shortCode' => $shortCode,
'--startDate' => $startDate,
'--endDate' => $endDate,
'--start-date' => $startDate,
'--end-date' => $endDate,
]);
}
@@ -81,18 +81,20 @@ class GetVisitsCommandTest extends TestCase
{
$shortCode = 'abc123';
$startDate = 'foo';
$info = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams(new DateRange()))
->willReturn(new Paginator(new ArrayAdapter([])));
$info = $this->visitsHelper->visitsForShortUrl(
new ShortUrlIdentifier($shortCode),
new VisitsParams(new DateRange()),
)->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute([
'shortCode' => $shortCode,
'--startDate' => $startDate,
'--start-date' => $startDate,
]);
$output = $this->commandTester->getDisplay();
$info->shouldHaveBeenCalledOnce();
self::assertStringContainsString(
sprintf('Ignored provided "startDate" since its value "%s" is not a valid date', $startDate),
sprintf('Ignored provided "start-date" since its value "%s" is not a valid date', $startDate),
$output,
);
}
@@ -101,9 +103,9 @@ class GetVisitsCommandTest extends TestCase
public function outputIsProperlyGenerated(): void
{
$shortCode = 'abc123';
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn(
$this->visitsHelper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn(
new Paginator(new ArrayAdapter([
(new Visit(new ShortUrl(''), new Visitor('bar', 'foo', '')))->locate(
Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate(
new VisitLocation(new Location('', 'Spain', '', '', 0, 0, '')),
),
])),

View File

@@ -5,16 +5,18 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use Laminas\Paginator\Adapter\ArrayAdapter;
use Laminas\Paginator\Paginator;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
@@ -31,7 +33,9 @@ class ListShortUrlsCommandTest extends TestCase
{
$this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class);
$app = new Application();
$command = new ListShortUrlsCommand($this->shortUrlService->reveal(), []);
$command = new ListShortUrlsCommand($this->shortUrlService->reveal(), new ShortUrlDataTransformer(
new ShortUrlStringifier([]),
));
$app->add($command);
$this->commandTester = new CommandTester($command);
}
@@ -42,7 +46,7 @@ class ListShortUrlsCommandTest extends TestCase
// The paginator will return more than one page
$data = [];
for ($i = 0; $i < 50; $i++) {
$data[] = new ShortUrl('url_' . $i);
$data[] = ShortUrl::withLongUrl('url_' . $i);
}
$this->shortUrlService->listShortUrls(Argument::cetera())
@@ -56,6 +60,7 @@ class ListShortUrlsCommandTest extends TestCase
self::assertStringContainsString('Continue with page 2?', $output);
self::assertStringContainsString('Continue with page 3?', $output);
self::assertStringContainsString('Continue with page 4?', $output);
self::assertStringNotContainsString('Continue with page 5?', $output);
}
/** @test */
@@ -64,7 +69,7 @@ class ListShortUrlsCommandTest extends TestCase
// The paginator will return more than one page
$data = [];
for ($i = 0; $i < 30; $i++) {
$data[] = new ShortUrl('url_' . $i);
$data[] = ShortUrl::withLongUrl('url_' . $i);
}
$this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
@@ -89,7 +94,7 @@ class ListShortUrlsCommandTest extends TestCase
{
$page = 5;
$this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData(['page' => $page]))
->willReturn(new Paginator(new ArrayAdapter()))
->willReturn(new Paginator(new ArrayAdapter([])))
->shouldBeCalledOnce();
$this->commandTester->setInputs(['y']);
@@ -100,11 +105,11 @@ class ListShortUrlsCommandTest extends TestCase
public function ifTagsFlagIsProvidedTagsColumnIsIncluded(): void
{
$this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
->willReturn(new Paginator(new ArrayAdapter()))
->willReturn(new Paginator(new ArrayAdapter([])))
->shouldBeCalledOnce();
$this->commandTester->setInputs(['y']);
$this->commandTester->execute(['--showTags' => true]);
$this->commandTester->execute(['--show-tags' => true]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('Tags', $output);
}
@@ -127,7 +132,7 @@ class ListShortUrlsCommandTest extends TestCase
'tags' => $tags,
'startDate' => $startDate !== null ? Chronos::parse($startDate)->toAtomString() : null,
'endDate' => $endDate !== null ? Chronos::parse($endDate)->toAtomString() : null,
]))->willReturn(new Paginator(new ArrayAdapter()));
]))->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->setInputs(['n']);
$this->commandTester->execute($commandArgs);
@@ -139,22 +144,22 @@ class ListShortUrlsCommandTest extends TestCase
{
yield [[], 1, null, []];
yield [['--page' => $page = 3], $page, null, []];
yield [['--searchTerm' => $searchTerm = 'search this'], 1, $searchTerm, []];
yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, []];
yield [
['--page' => $page = 3, '--searchTerm' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'],
['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'],
$page,
$searchTerm,
explode(',', $tags),
];
yield [
['--startDate' => $startDate = '2019-01-01'],
['--start-date' => $startDate = '2019-01-01'],
1,
null,
[],
$startDate,
];
yield [
['--endDate' => $endDate = '2020-05-23'],
['--end-date' => $endDate = '2020-05-23'],
1,
null,
[],
@@ -162,7 +167,7 @@ class ListShortUrlsCommandTest extends TestCase
$endDate,
];
yield [
['--startDate' => $startDate = '2019-01-01', '--endDate' => $endDate = '2020-05-23'],
['--start-date' => $startDate = '2019-01-01', '--end-date' => $endDate = '2020-05-23'],
1,
null,
[],
@@ -180,7 +185,7 @@ class ListShortUrlsCommandTest extends TestCase
{
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
'orderBy' => $expectedOrderBy,
]))->willReturn(new Paginator(new ArrayAdapter()));
]))->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->setInputs(['n']);
$this->commandTester->execute($commandArgs);
@@ -191,9 +196,9 @@ class ListShortUrlsCommandTest extends TestCase
public function provideOrderBy(): iterable
{
yield [[], null];
yield [['--orderBy' => 'foo'], 'foo'];
yield [['--orderBy' => 'foo,ASC'], ['foo' => 'ASC']];
yield [['--orderBy' => 'bar,DESC'], ['bar' => 'DESC']];
yield [['--order-by' => 'foo'], 'foo'];
yield [['--order-by' => 'foo,ASC'], ['foo' => 'ASC']];
yield [['--order-by' => 'bar,DESC'], ['bar' => 'DESC']];
}
/** @test */
@@ -207,7 +212,7 @@ class ListShortUrlsCommandTest extends TestCase
'endDate' => null,
'orderBy' => null,
'itemsPerPage' => -1,
]))->willReturn(new Paginator(new ArrayAdapter()));
]))->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute(['--all' => true]);

View File

@@ -41,7 +41,7 @@ class ResolveUrlCommandTest extends TestCase
{
$shortCode = 'abc123';
$expectedUrl = 'http://domain.com/foo/bar';
$shortUrl = new ShortUrl($expectedUrl);
$shortUrl = ShortUrl::withLongUrl($expectedUrl);
$this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn($shortUrl)
->shouldBeCalledOnce();

View File

@@ -10,6 +10,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
@@ -37,7 +38,9 @@ class RenameTagCommandTest extends TestCase
{
$oldName = 'foo';
$newName = 'bar';
$renameTag = $this->tagService->renameTag($oldName, $newName)->willThrow(TagNotFoundException::fromTag('foo'));
$renameTag = $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName))->willThrow(
TagNotFoundException::fromTag('foo'),
);
$this->commandTester->execute([
'oldName' => $oldName,
@@ -54,7 +57,9 @@ class RenameTagCommandTest extends TestCase
{
$oldName = 'foo';
$newName = 'bar';
$renameTag = $this->tagService->renameTag($oldName, $newName)->willReturn(new Tag($newName));
$renameTag = $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName))->willReturn(
new Tag($newName),
);
$this->commandTester->execute([
'oldName' => $oldName,

View File

@@ -52,7 +52,7 @@ class LocateVisitsCommandTest extends TestCase
$this->lock->acquire(false)->willReturn(true);
$this->lock->release()->will(function (): void {
});
$locker->createLock(Argument::type('string'), 90.0, false)->willReturn($this->lock->reveal());
$locker->createLock(Argument::type('string'), 600.0, false)->willReturn($this->lock->reveal());
$command = new LocateVisitsCommand(
$this->visitService->reveal(),
@@ -77,7 +77,7 @@ class LocateVisitsCommandTest extends TestCase
bool $expectWarningPrint,
array $args
): void {
$visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
$location = new VisitLocation(Location::emptyInstance());
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
@@ -121,7 +121,7 @@ class LocateVisitsCommandTest extends TestCase
*/
public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message): void
{
$visit = new Visit(new ShortUrl(''), new Visitor('', '', $address));
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $address, ''));
$location = new VisitLocation(Location::emptyInstance());
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
@@ -154,7 +154,7 @@ class LocateVisitsCommandTest extends TestCase
/** @test */
public function errorWhileLocatingIpIsDisplayed(): void
{
$visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
$location = new VisitLocation(Location::emptyInstance());
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
@@ -217,7 +217,9 @@ class LocateVisitsCommandTest extends TestCase
$mustBeUpdated($olderDbExists);
$handleProgress(100, 50);
throw GeolocationDbUpdateFailedException::create($olderDbExists);
throw $olderDbExists
? GeolocationDbUpdateFailedException::withOlderDb()
: GeolocationDbUpdateFailedException::withoutOlderDb();
},
);

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\ConfigProvider;
@@ -21,7 +22,9 @@ class ConfigProviderTest extends TestCase
{
$config = ($this->configProvider)();
self::assertCount(3, $config);
self::assertArrayHasKey('cli', $config);
self::assertArrayHasKey('dependencies', $config);
self::assertArrayHasKey(ConfigAbstractFactory::class, $config);
}
}

View File

@@ -14,26 +14,54 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase
{
/**
* @test
* @dataProvider provideCreateArgs
* @dataProvider providePrev
*/
public function createBuildsException(bool $olderDbExists, ?Throwable $prev): void
public function withOlderDbBuildsException(?Throwable $prev): void
{
$e = GeolocationDbUpdateFailedException::create($olderDbExists, $prev);
$e = GeolocationDbUpdateFailedException::withOlderDb($prev);
self::assertEquals($olderDbExists, $e->olderDbExists());
self::assertTrue($e->olderDbExists());
self::assertEquals(
'An error occurred while updating geolocation database, and an older version could not be found',
'An error occurred while updating geolocation database, but an older DB is already present.',
$e->getMessage(),
);
self::assertEquals(0, $e->getCode());
self::assertEquals($prev, $e->getPrevious());
}
public function provideCreateArgs(): iterable
/**
* @test
* @dataProvider providePrev
*/
public function withoutOlderDbBuildsException(?Throwable $prev): void
{
yield 'older DB and no prev' => [true, null];
yield 'older DB and prev' => [true, new RuntimeException('prev')];
yield 'no older DB and no prev' => [false, null];
yield 'no older DB and prev' => [false, new Exception('prev')];
$e = GeolocationDbUpdateFailedException::withoutOlderDb($prev);
self::assertFalse($e->olderDbExists());
self::assertEquals(
'An error occurred while updating geolocation database, and an older version could not be found.',
$e->getMessage(),
);
self::assertEquals(0, $e->getCode());
self::assertEquals($prev, $e->getPrevious());
}
public function providePrev(): iterable
{
yield 'no prev' => [null];
yield 'RuntimeException' => [new RuntimeException('prev')];
yield 'Exception' => [new Exception('prev')];
}
/** @test */
public function withInvalidEpochInOldDbBuildsException(): void
{
$e = GeolocationDbUpdateFailedException::withInvalidEpochInOldDb('foobar');
self::assertTrue($e->olderDbExists());
self::assertEquals(
'Build epoch with value "foobar" from existing geolocation database, could not be parsed to integer.',
$e->getMessage(),
);
}
}

View File

@@ -80,17 +80,9 @@ class GeolocationDbUpdaterTest extends TestCase
public function exceptionIsThrownWhenOlderDbIsTooOldAndDownloadFails(int $days): void
{
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true);
$getMeta = $this->geoLiteDbReader->metadata()->willReturn(new Metadata([
'binary_format_major_version' => '',
'binary_format_minor_version' => '',
'build_epoch' => Chronos::now()->subDays($days)->getTimestamp(),
'database_type' => '',
'languages' => '',
'description' => '',
'ip_version' => '',
'node_count' => 1,
'record_size' => 4,
]));
$getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch(
Chronos::now()->subDays($days)->getTimestamp(),
));
$prev = new RuntimeException('');
$download = $this->dbUpdater->downloadFreshCopy(null)->willThrow($prev);
@@ -120,21 +112,12 @@ class GeolocationDbUpdaterTest extends TestCase
/**
* @test
* @dataProvider provideSmallDays
* @param string|int $buildEpoch
*/
public function databaseIsNotUpdatedIfItIsYoungerThanOneWeek(int $days): void
public function databaseIsNotUpdatedIfItIsYoungerThanOneWeek($buildEpoch): void
{
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true);
$getMeta = $this->geoLiteDbReader->metadata()->willReturn(new Metadata([
'binary_format_major_version' => '',
'binary_format_minor_version' => '',
'build_epoch' => Chronos::now()->subDays($days)->getTimestamp(),
'database_type' => '',
'languages' => '',
'description' => '',
'ip_version' => '',
'node_count' => 1,
'record_size' => 4,
]));
$getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch($buildEpoch));
$download = $this->dbUpdater->downloadFreshCopy(null)->will(function (): void {
});
@@ -147,6 +130,48 @@ class GeolocationDbUpdaterTest extends TestCase
public function provideSmallDays(): iterable
{
return map(range(0, 34), fn (int $days) => [$days]);
$generateParamsWithTimestamp = static function (int $days) {
$timestamp = Chronos::now()->subDays($days)->getTimestamp();
return [$days % 2 === 0 ? $timestamp : (string) $timestamp];
};
return map(range(0, 34), $generateParamsWithTimestamp);
}
/** @test */
public function exceptionIsThrownWhenCheckingExistingDatabaseWithInvalidBuildEpoch(): void
{
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true);
$getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch('invalid'));
$download = $this->dbUpdater->downloadFreshCopy(null)->will(function (): void {
});
$this->expectException(GeolocationDbUpdateFailedException::class);
$this->expectExceptionMessage(
'Build epoch with value "invalid" from existing geolocation database, could not be parsed to integer.',
);
$fileExists->shouldBeCalledOnce();
$getMeta->shouldBeCalledOnce();
$download->shouldNotBeCalled();
$this->geolocationDbUpdater->checkDbUpdate();
}
/**
* @param string|int $buildEpoch
*/
private function buildMetaWithBuildEpoch($buildEpoch): Metadata
{
return new Metadata([
'binary_format_major_version' => '',
'binary_format_minor_version' => '',
'build_epoch' => $buildEpoch,
'database_type' => '',
'languages' => '',
'description' => '',
'ip_version' => '',
'node_count' => 1,
'record_size' => 4,
]);
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Util;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Util\ProcessRunner;
use Symfony\Component\Console\Helper\DebugFormatterHelper;
use Symfony\Component\Console\Helper\HelperSet;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
class ProcessRunnerTest extends TestCase
{
use ProphecyTrait;
private ProcessRunner $runner;
private ObjectProphecy $helper;
private ObjectProphecy $formatter;
private ObjectProphecy $process;
private ObjectProphecy $output;
protected function setUp(): void
{
$this->helper = $this->prophesize(ProcessHelper::class);
$this->formatter = $this->prophesize(DebugFormatterHelper::class);
$helperSet = $this->prophesize(HelperSet::class);
$helperSet->get('debug_formatter')->willReturn($this->formatter->reveal());
$this->helper->getHelperSet()->willReturn($helperSet->reveal());
$this->process = $this->prophesize(Process::class);
$this->runner = new ProcessRunner($this->helper->reveal(), fn () => $this->process->reveal());
$this->output = $this->prophesize(OutputInterface::class);
}
/** @test */
public function noMessagesAreWrittenWhenOutputIsNotVerbose(): void
{
$isVeryVerbose = $this->output->isVeryVerbose()->willReturn(false);
$isDebug = $this->output->isDebug()->willReturn(false);
$mustRun = $this->process->mustRun(Argument::cetera())->willReturn($this->process->reveal());
$this->runner->run($this->output->reveal(), []);
$isVeryVerbose->shouldHaveBeenCalledTimes(2);
$isDebug->shouldHaveBeenCalledOnce();
$mustRun->shouldHaveBeenCalledOnce();
$this->process->isSuccessful()->shouldNotHaveBeenCalled();
$this->process->getCommandLine()->shouldNotHaveBeenCalled();
$this->output->write(Argument::cetera())->shouldNotHaveBeenCalled();
$this->helper->wrapCallback(Argument::cetera())->shouldNotHaveBeenCalled();
$this->formatter->start(Argument::cetera())->shouldNotHaveBeenCalled();
$this->formatter->stop(Argument::cetera())->shouldNotHaveBeenCalled();
}
/** @test */
public function someMessagesAreWrittenWhenOutputIsVerbose(): void
{
$isVeryVerbose = $this->output->isVeryVerbose()->willReturn(true);
$isDebug = $this->output->isDebug()->willReturn(false);
$mustRun = $this->process->mustRun(Argument::cetera())->willReturn($this->process->reveal());
$isSuccessful = $this->process->isSuccessful()->willReturn(true);
$getCommandLine = $this->process->getCommandLine()->willReturn('true');
$start = $this->formatter->start(Argument::cetera())->willReturn('');
$stop = $this->formatter->stop(Argument::cetera())->willReturn('');
$this->runner->run($this->output->reveal(), []);
$isVeryVerbose->shouldHaveBeenCalledTimes(2);
$isDebug->shouldHaveBeenCalledOnce();
$mustRun->shouldHaveBeenCalledOnce();
$this->output->write(Argument::cetera())->shouldHaveBeenCalledTimes(2);
$this->helper->wrapCallback(Argument::cetera())->shouldNotHaveBeenCalled();
$isSuccessful->shouldHaveBeenCalledTimes(2);
$getCommandLine->shouldHaveBeenCalledOnce();
$start->shouldHaveBeenCalledOnce();
$stop->shouldHaveBeenCalledOnce();
}
/** @test */
public function wrapsCallbackWhenOutputIsDebug(): void
{
$isVeryVerbose = $this->output->isVeryVerbose()->willReturn(false);
$isDebug = $this->output->isDebug()->willReturn(true);
$mustRun = $this->process->mustRun(Argument::cetera())->willReturn($this->process->reveal());
$wrapCallback = $this->helper->wrapCallback(Argument::cetera())->willReturn(function (): void {
});
$this->runner->run($this->output->reveal(), []);
$isVeryVerbose->shouldHaveBeenCalledTimes(2);
$isDebug->shouldHaveBeenCalledOnce();
$mustRun->shouldHaveBeenCalledOnce();
$wrapCallback->shouldHaveBeenCalledOnce();
$this->process->isSuccessful()->shouldNotHaveBeenCalled();
$this->process->getCommandLine()->shouldNotHaveBeenCalled();
$this->output->write(Argument::cetera())->shouldNotHaveBeenCalled();
$this->formatter->start(Argument::cetera())->shouldNotHaveBeenCalled();
$this->formatter->stop(Argument::cetera())->shouldNotHaveBeenCalled();
}
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Mezzio\Template\TemplateRendererInterface;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Core\ErrorHandler;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
@@ -15,8 +15,10 @@ return [
'dependencies' => [
'factories' => [
ErrorHandler\NotFoundTypeResolverMiddleware::class => ConfigAbstractFactory::class,
ErrorHandler\NotFoundTrackerMiddleware::class => ConfigAbstractFactory::class,
ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class,
ErrorHandler\NotFoundTemplateHandler::class => ConfigAbstractFactory::class,
ErrorHandler\NotFoundTemplateHandler::class => InvokableFactory::class,
Options\AppOptions::class => ConfigAbstractFactory::class,
Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class,
@@ -24,16 +26,20 @@ return [
Options\UrlShortenerOptions::class => ConfigAbstractFactory::class,
Service\UrlShortener::class => ConfigAbstractFactory::class,
Service\VisitsTracker::class => ConfigAbstractFactory::class,
Service\ShortUrlService::class => ConfigAbstractFactory::class,
Visit\VisitLocator::class => ConfigAbstractFactory::class,
Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
Tag\TagService::class => ConfigAbstractFactory::class,
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
Service\ShortUrl\ShortCodeHelper::class => ConfigAbstractFactory::class,
Tag\TagService::class => ConfigAbstractFactory::class,
Domain\DomainService::class => ConfigAbstractFactory::class,
Visit\VisitsTracker::class => ConfigAbstractFactory::class,
Visit\VisitLocator::class => ConfigAbstractFactory::class,
Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
Visit\Transformer\OrphanVisitDataTransformer::class => InvokableFactory::class,
Util\UrlValidator::class => ConfigAbstractFactory::class,
Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class,
Util\RedirectResponseHelper::class => ConfigAbstractFactory::class,
@@ -43,6 +49,9 @@ return [
Action\QrCodeAction::class => ConfigAbstractFactory::class,
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ConfigAbstractFactory::class,
ShortUrl\Helper\ShortUrlStringifier::class => ConfigAbstractFactory::class,
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ConfigAbstractFactory::class,
ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class,
Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class,
@@ -55,12 +64,12 @@ return [
],
ConfigAbstractFactory::class => [
ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'],
ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\VisitsTracker::class],
ErrorHandler\NotFoundRedirectHandler::class => [
NotFoundRedirectOptions::class,
Util\RedirectResponseHelper::class,
'config.router.base_path',
],
ErrorHandler\NotFoundTemplateHandler::class => [TemplateRendererInterface::class],
Options\AppOptions::class => ['config.app_options'],
Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'],
@@ -68,17 +77,22 @@ return [
Options\UrlShortenerOptions::class => ['config.url_shortener'],
Service\UrlShortener::class => [
Util\UrlValidator::class,
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class,
'em',
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
Service\ShortUrl\ShortCodeHelper::class,
],
Service\VisitsTracker::class => [
Visit\VisitsTracker::class => [
'em',
EventDispatcherInterface::class,
'config.url_shortener.anonymize_remote_addr',
Options\UrlShortenerOptions::class,
],
Service\ShortUrlService::class => [
'em',
Service\ShortUrl\ShortUrlResolver::class,
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class,
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
],
Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class, Util\UrlValidator::class],
Visit\VisitLocator::class => ['em'],
Visit\VisitsStatsHelper::class => ['em'],
Tag\TagService::class => ['em'],
@@ -89,7 +103,7 @@ return [
],
Service\ShortUrl\ShortUrlResolver::class => ['em'],
Service\ShortUrl\ShortCodeHelper::class => ['em'],
Domain\DomainService::class => ['em'],
Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'],
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
Util\DoctrineBatchHelper::class => ['em'],
@@ -97,26 +111,32 @@ return [
Action\RedirectAction::class => [
Service\ShortUrl\ShortUrlResolver::class,
Service\VisitsTracker::class,
Visit\VisitsTracker::class,
Options\AppOptions::class,
Util\RedirectResponseHelper::class,
'Logger_Shlink',
],
Action\PixelAction::class => [
Service\ShortUrl\ShortUrlResolver::class,
Service\VisitsTracker::class,
Visit\VisitsTracker::class,
Options\AppOptions::class,
'Logger_Shlink',
],
Action\QrCodeAction::class => [
Service\ShortUrl\ShortUrlResolver::class,
'config.url_shortener.domain',
ShortUrl\Helper\ShortUrlStringifier::class,
'Logger_Shlink',
],
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em'],
ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'],
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class],
ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class],
Mercure\MercureUpdatesGenerator::class => ['config.url_shortener.domain'],
Mercure\MercureUpdatesGenerator::class => [
ShortUrl\Transformer\ShortUrlDataTransformer::class,
Visit\Transformer\OrphanVisitDataTransformer::class,
],
Importer\ImportedLinksProcessor::class => [
'em',

View File

@@ -84,4 +84,15 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->build();
$builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain');
$builder->createField('title', Types::STRING)
->columnName('title')
->length(512)
->nullable()
->build();
$builder->createField('titleWasAutoResolved', Types::BOOLEAN)
->columnName('title_was_auto_resolved')
->option('default', false)
->build();
};

View File

@@ -47,11 +47,22 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->build();
$builder->createManyToOne('shortUrl', Entity\ShortUrl::class)
->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE')
->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE')
->build();
$builder->createManyToOne('visitLocation', Entity\VisitLocation::class)
->addJoinColumn('visit_location_id', 'id', true, false, 'Set NULL')
->cascadePersist()
->build();
$builder->createField('visitedUrl', Types::STRING)
->columnName('visited_url')
->length(Visitor::VISITED_URL_MAX_LENGTH)
->nullable()
->build();
$builder->createField('type', Types::STRING)
->columnName('type')
->length(255)
->build();
};

View File

@@ -14,34 +14,34 @@ return [
'events' => [
'regular' => [
EventDispatcher\VisitLocated::class => [
EventDispatcher\Event\VisitLocated::class => [
EventDispatcher\NotifyVisitToMercure::class,
EventDispatcher\NotifyVisitToWebHooks::class,
],
],
'async' => [
EventDispatcher\ShortUrlVisited::class => [
EventDispatcher\LocateShortUrlVisit::class,
EventDispatcher\Event\UrlVisited::class => [
EventDispatcher\LocateVisit::class,
],
],
],
'dependencies' => [
'factories' => [
EventDispatcher\LocateShortUrlVisit::class => ConfigAbstractFactory::class,
EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
],
'delegators' => [
EventDispatcher\LocateShortUrlVisit::class => [
EventDispatcher\LocateVisit::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
],
],
ConfigAbstractFactory::class => [
EventDispatcher\LocateShortUrlVisit::class => [
EventDispatcher\LocateVisit::class => [
IpLocationResolverInterface::class,
'em',
'Logger_Shlink',
@@ -53,7 +53,7 @@ return [
'em',
'Logger_Shlink',
'config.url_shortener.visits_webhooks',
'config.url_shortener.domain',
ShortUrl\Transformer\ShortUrlDataTransformer::class,
Options\AppOptions::class,
],
EventDispatcher\NotifyVisitToMercure::class => [

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