Compare commits

..

133 Commits

Author SHA1 Message Date
Alejandro Celaya
92858aebd6 Merge pull request #204 from acelaya/master
Updated changelog with v1.12
2018-09-15 18:52:38 +02:00
Alejandro Celaya
1910724a98 Updated changelog with v1.12 2018-09-15 18:47:10 +02:00
Alejandro Celaya
ff8441fa95 Merge pull request #199 from acelaya/feature/delete-short-codes
Delete short URLs
2018-09-15 18:20:06 +02:00
Alejandro Celaya
9d8fb055b1 Updated translations 2018-09-15 18:03:54 +02:00
Alejandro Celaya
9651b3d692 Created command to delete short URLs 2018-09-15 17:57:12 +02:00
Alejandro Celaya
9d10c8627a Created migration fixing cascade delete on visits table 2018-09-15 13:20:13 +02:00
Alejandro Celaya
929d7670cb Documented delete short URLs endpoint in swagger 2018-09-15 13:07:52 +02:00
Alejandro Celaya
5714a8f884 Created action to delete short URLs 2018-09-15 12:56:17 +02:00
Alejandro Celaya
159529937d Created specific service to delete short URLs 2018-09-15 11:54:58 +02:00
Alejandro Celaya
394d9ff4d2 Defined config and implementation to delete short URLs 2018-09-15 11:01:28 +02:00
Alejandro Celaya
07165f344f Normalized entities adding missing type hints and removing superfluous comments 2018-09-15 10:03:42 +02:00
Alejandro Celaya
4f2146dd9c Replaced commands namespace shortcode by short-code, using the old one as an alias 2018-09-14 19:38:52 +02:00
Alejandro Celaya
5b9784cd9e Merge pull request #195 from acelaya/feature/gdpr
Fix GDPR compliance
2018-09-14 19:30:14 +02:00
Alejandro Celaya
9d9b61cf14 Fixed message displayed during installation process 2018-09-14 19:18:10 +02:00
Alejandro Celaya
9d7db96e4b Added country name to console comand that lists visits 2018-09-14 19:12:23 +02:00
Alejandro Celaya
3d0bca2781 Finally dropped the hashing of the address 2018-09-14 19:04:40 +02:00
Alejandro Celaya
ffb54c4f7a Fixed typehint 2018-09-13 23:52:22 +02:00
Alejandro Celaya
a01031303f Created migration which parses existing IP addresses, generating hashes and droping already used IPs 2018-09-13 23:50:09 +02:00
Alejandro Celaya
7808f6d182 Added remoteAddrHash field to Visit entity 2018-09-13 22:46:28 +02:00
Alejandro Celaya
a0c3b9412f Updated system to obfuscate IP addresses before persisting them 2018-09-13 22:36:28 +02:00
Alejandro Celaya
c32e2053c3 Merge pull request #194 from acelaya/feature/create-url-resp
Updated short URL creation responses to include more information
2018-09-12 20:49:03 +02:00
Alejandro Celaya
a33151248d Removed duplicated code by using a utils trait 2018-09-12 20:40:32 +02:00
Alejandro Celaya
038ba3b006 Fixed wrong typehint 2018-09-12 20:34:36 +02:00
Alejandro Celaya
f3c92f4110 Updated short URL creation responses to include more information 2018-09-12 20:32:58 +02:00
Alejandro Celaya
17779dbbc6 Merge pull request #192 from acelaya/feature/non-unique-long-urls
Ensured same long URL can be used multiple times for different short URLs
2018-09-11 20:44:40 +02:00
Alejandro Celaya
c2dd5b8c47 Ensured same long URL can be used multiple times for different short URLs 2018-09-11 19:44:33 +02:00
Alejandro Celaya
fcb9121e5a Merge pull request #191 from acelaya/feature/how-to-release
Added release instructions to readme file
2018-09-11 19:28:41 +02:00
Alejandro Celaya
0af1004860 Ordered gitignore placing all composer-related files together 2018-09-11 19:25:13 +02:00
Alejandro Celaya
917f668cf3 Added release instructions to readme file, and improved how to build instructions 2018-09-11 19:17:29 +02:00
Alejandro Celaya
436499b7c4 Merge pull request #186 from robwent/robots-txt
Adds robots.txt and disallows all
2018-09-09 18:50:53 +02:00
Robert Went
66af9866f0 Adds robots.txt and disallows all 2018-09-09 17:41:57 +01:00
Alejandro Celaya
3703dedad9 Merge pull request #184 from acelaya/master
Improved documentation in README file
2018-09-09 18:28:35 +02:00
Alejandro Celaya
37502151ef Updated date in license 2018-09-09 18:23:36 +02:00
Alejandro Celaya
3816a10de3 Improved documentation in README file 2018-09-09 18:20:12 +02:00
Alejandro Celaya
bdda6067ab Replaced hardcoded donate link by short URL 2018-09-02 08:05:39 +02:00
Alejandro Celaya
0f62af241f Updated badges and added donate badge 2018-08-30 19:43:11 +02:00
Alejandro Celaya
987919e87a Merge pull request #179 from acelaya/feature/1.11.0
v1.11.0
2018-08-13 16:40:37 +02:00
Alejandro Celaya
0c03a4b7ff Added v1.11.0 to changelog 2018-08-13 16:29:40 +02:00
Alejandro Celaya
5d6d13c95f Updated API docs including new response structure 2018-08-13 16:17:43 +02:00
Alejandro Celaya
563021bdc1 Updated resolve short url action to return all data for that short url 2018-08-11 10:40:44 +02:00
Alejandro Celaya
2d6d35a398 Added shortUrl field to serialized ShortUrl objects, both from CLI and REST 2018-08-10 23:14:45 +02:00
Alejandro Celaya
30297ac5ac Merge pull request #176 from acelaya/feature/1.10.2
feature/1.10.2
2018-08-04 16:51:01 +02:00
Alejandro Celaya
416c56dee2 Added new spanish translations 2018-08-04 16:37:54 +02:00
Alejandro Celaya
6b968a6843 Updated changelog including v1.10.2 2018-08-04 16:28:12 +02:00
Alejandro Celaya
080965e166 Improved ShortUrlRepositoryTest covering listing case with filter by tag and search term at the same time 2018-08-04 16:21:01 +02:00
Alejandro Celaya
c7239aaca2 Fixed duplicated join with same table performed while filtering short codes by search term and tags 2018-08-04 16:15:09 +02:00
Alejandro Celaya
110e8cb78d Added test to cover new IP resolution API limits 2018-08-04 15:50:02 +02:00
Alejandro Celaya
ed859767a8 Updated IpLocation resolver to be able to provide limits in order to apply sleeps 2018-08-02 23:02:48 +02:00
Alejandro Celaya
d54b823c88 Merge branch 'develop' 2018-08-02 17:56:38 +02:00
Alejandro Celaya
0ae44d3331 Merge pull request #168 from acelaya/feature/1.10.1
Feature/1.10.1
2018-08-02 17:55:25 +02:00
Alejandro Celaya
8063e643a3 Updated changelog with version 1.10.1 2018-08-01 20:58:48 +02:00
Alejandro Celaya
3883ed15c4 Fixed short codes DB length too short 2018-08-01 20:40:24 +02:00
Alejandro Celaya
a79c1f580e Fixed visits count multiplied by the number of tags when ordering and filtering by text 2018-08-01 20:31:54 +02:00
Alejandro Celaya
f4b569c245 Improved code 2018-08-01 20:28:05 +02:00
Alejandro Celaya
899771cc2e Fixed geolocation by switching to different API 2018-07-31 20:24:13 +02:00
Alejandro Celaya
863803b614 Fixed tests failing with new typehints 2018-07-31 19:59:41 +02:00
Alejandro Celaya
5be5e0bc60 Fixed coding styles 2018-07-31 19:53:59 +02:00
Alejandro Celaya
0b8e305533 Improved error management in process visits command 2018-07-31 19:42:33 +02:00
Alejandro Celaya
39d79366a3 Documented date range params for visits endpoint 2018-07-30 20:28:41 +02:00
Alejandro Celaya
d5b78f2a7e Fixed date fields not properly parsed depending if originally they were datetimes or strings 2018-07-28 18:57:24 +02:00
Alejandro Celaya
b2a63f734a Simplified how built shlink version is found out 2018-07-26 20:35:02 +02:00
Alejandro Celaya
82f41de87b Added build step which sets shlink's version 2018-07-26 18:44:04 +02:00
Alejandro Celaya
af4c66d40a Added version placeholder in place of hardcoded version in config 2018-07-26 18:42:53 +02:00
Alejandro Celaya
975260f126 Merge pull request #164 from shlinkio/develop
Develop
2018-07-09 18:48:28 +02:00
Alejandro Celaya
bd678b41f7 Merge pull request #163 from acelaya/develop
v1.10.0
2018-07-09 18:41:28 +02:00
Alejandro Celaya
66898b6ddc Added v1.10.0 to changelog 2018-07-09 18:40:48 +02:00
Alejandro Celaya
5eee683978 Added release datesto changelog 2018-07-09 17:48:23 +02:00
Alejandro Celaya
e92446df9b Updated changelog to use the keepachangelog format 2018-07-09 17:30:25 +02:00
Alejandro Celaya
63a69b05a1 Added Zend expressive swoole config provider to global config when present 2018-07-04 20:40:38 +02:00
Alejandro Celaya
c79ca1d13c Fixed phpstan issues 2018-07-04 20:28:05 +02:00
Alejandro Celaya
87c4851d7e Simplified ListKeysCommand reducing cyclomatic complexity on nested callbacks 2018-07-04 20:24:13 +02:00
Alejandro Celaya
a125c93ca3 Updated to phpstan 0.10 and infection 0.9 2018-07-04 12:08:27 +02:00
Alejandro Celaya
a8465094c1 Merge branch 'develop' 2018-06-18 20:49:32 +02:00
Alejandro Celaya
16f7359ac6 Merge pull request #156 from acelaya/feature/1.9.1
Improved paginator properties
2018-06-18 20:48:41 +02:00
Alejandro Celaya
f9f4817ee2 Aded v1.9.1 to changelog 2018-06-18 20:40:50 +02:00
Alejandro Celaya
c7e49f223f Fixed filtered lists not being properly paginated 2018-06-18 20:38:25 +02:00
Alejandro Celaya
6e79b4ba7b Fixed php binary used in child commands while installkation not properly inherited 2018-06-18 20:14:51 +02:00
Alejandro Celaya
f78a7f12a9 Improved paginator properties 2018-06-17 18:29:40 +02:00
Alejandro Celaya
b3664597b0 Merge branch 'develop' 2018-05-07 11:27:13 +02:00
Alejandro Celaya
8cfb4f61ca Merge pull request #148 from acelaya/feature/1.9.0
Version 1.9.0
2018-05-07 11:26:27 +02:00
Alejandro Celaya
b0dbb2dae4 Updated CreateShortCodeContentNegotiationMiddleware so that query parameter takes precedence over Accept header 2018-05-07 11:17:10 +02:00
Alejandro Celaya
7c6da4985d Updated build script to delete more development-specific files 2018-05-07 11:09:32 +02:00
Alejandro Celaya
386b0dfb7b Updated changelog 2018-05-07 11:03:28 +02:00
Alejandro Celaya
1437ff48ce Ensured all core actions log errors 2018-05-07 10:58:49 +02:00
Alejandro Celaya
63294f20ee Updated language files 2018-05-06 12:36:07 +02:00
Alejandro Celaya
d8acc3c247 Removed unused use statement 2018-05-06 12:34:21 +02:00
Alejandro Celaya
52d8ffa212 Improved CreateShortCodeContentNegotiationMiddleware sho that it takes into account the case in which an error is returned from next middleware 2018-05-06 12:28:22 +02:00
Alejandro Celaya
98ad2816e8 Documented new endpoint to create short URLs in a single step 2018-05-06 12:19:08 +02:00
Alejandro Celaya
9d890f4227 Created CreateShortCodeContentNegotiationMiddleware 2018-05-03 19:04:40 +02:00
Alejandro Celaya
0932d04907 Fixed tests namespaces to match their subject under test 2018-05-03 18:34:45 +02:00
Alejandro Celaya
1f78b5c524 Improved CreateShortCodeContentNegotiationMiddleware so that it can determine the format based on a query partameter 2018-05-03 18:32:32 +02:00
Alejandro Celaya
59f10619ba Created middleware used with short codes creation actions to handle content negotiation 2018-05-03 18:26:31 +02:00
Alejandro Celaya
334710e92c Added middleware which injects the content-length header in the response if not present 2018-05-03 18:25:57 +02:00
Alejandro Celaya
75b8175824 Fixed coding styles in config file 2018-05-03 18:05:16 +02:00
Alejandro Celaya
8a74ef2a33 Moved action to subnamespace 2018-05-03 18:04:00 +02:00
Alejandro Celaya
d05ac5ce9d Moved action to subnamespace 2018-05-03 18:03:10 +02:00
Alejandro Celaya
3100fffa2b Moved action to subnamespace 2018-05-03 18:02:45 +02:00
Alejandro Celaya
6bbacb1017 Moved action to subnamespace 2018-05-03 18:01:57 +02:00
Alejandro Celaya
4403dc5df9 Moved action to subnamespace 2018-05-03 18:00:32 +02:00
Alejandro Celaya
fdc637c23d Moved action to subnamespace 2018-05-03 17:59:28 +02:00
Alejandro Celaya
b99d662417 Created SingleStepCreateShortCodeActionTest 2018-05-03 17:57:43 +02:00
Alejandro Celaya
eb9a964c66 Removed unused use statement 2018-05-03 13:34:13 +02:00
Alejandro Celaya
e5ef8d7f8c Created action which allows short URLs to be created on a single API request 2018-05-03 13:21:43 +02:00
Alejandro Celaya
28650aee2b Fixed case sensitivity errors 2018-05-03 12:19:51 +02:00
Alejandro Celaya
a2294704e6 Split try catch to prevent undefined variables 2018-05-01 19:38:44 +02:00
Alejandro Celaya
e5e1aa2ff4 Defined abstract action which handles short codes generations 2018-05-01 19:35:12 +02:00
Alejandro Celaya
2f5290b9d3 Moved whitelisted routes in CheckAuthenticationMiddleware to external configuration 2018-05-01 18:36:42 +02:00
Alejandro Celaya
ef3c4aadf2 Moved most of rest routes config to their actions 2018-05-01 18:28:37 +02:00
Alejandro Celaya
c9ce56eea5 Added public method in AbstractRestAction which builds route definition 2018-05-01 18:16:44 +02:00
Alejandro Celaya
4fee656f96 Prepared version 1.9.0 2018-05-01 10:10:19 +02:00
Alejandro Celaya
d2a04259f5 Merge branch 'develop' 2018-04-07 09:06:45 +02:00
Alejandro Celaya
e504daa1ba Merge pull request #142 from acelaya/develop
Develop
2018-04-07 09:05:56 +02:00
Alejandro Celaya
8793a67ce9 Reduced the number of includes by pointing to dcotrine scripts with extension 2018-04-07 08:37:41 +02:00
Alejandro Celaya
b4ded374e9 Updated changelog 2018-04-07 08:32:06 +02:00
Alejandro Celaya
91d350b12f Removed path workaround in PathVersionMiddleware and simplified code 2018-04-07 08:31:03 +02:00
Alejandro Celaya
b3e25f28fd Added v1.8.1 to changelog 2018-04-07 08:25:01 +02:00
Alejandro Celaya
aca89f9abe Updated links to doctrine CLI scripts to avoid depending on symlinks 2018-04-07 08:21:34 +02:00
Alejandro Celaya
243075dd78 Merge branch 'develop' 2018-03-29 09:52:00 +02:00
Alejandro Celaya
7130425896 Merge pull request #133 from acelaya/feature/1.8.0
1.8.0
2018-03-29 09:50:58 +02:00
Alejandro Celaya
fe9ab20cbb Applied some improvements 2018-03-27 23:57:29 +02:00
Alejandro Celaya
6935b2ebe2 Updated system so that NotFoundDelegate is used 2018-03-26 20:37:04 +02:00
Alejandro Celaya
3dcc510da1 Updated to symfony 4 2018-03-26 20:32:12 +02:00
Alejandro Celaya
2f26c82fa6 Removed expressive migration tool from dev dependencies 2018-03-26 20:25:30 +02:00
Alejandro Celaya
9ddb60a882 Updated changelog including v1.8.0 2018-03-26 20:22:57 +02:00
Alejandro Celaya
210b08b61f Created PixelActionTest 2018-03-26 20:17:38 +02:00
Alejandro Celaya
42fe4bd5ce Created new action to track visits, which returns an empty pixel 2018-03-26 20:13:03 +02:00
Alejandro Celaya
1b2a0820e5 Updated to phpunit 7 and dropped dbunit dependency 2018-03-26 19:09:10 +02:00
Alejandro Celaya
6cf0155417 Updated minimum required MSI 2018-03-26 19:06:49 +02:00
Alejandro Celaya
9b8be3e5b8 Fixed phpstan errors 2018-03-26 19:05:26 +02:00
Alejandro Celaya
a27b01b895 Fixed tests 2018-03-26 19:02:41 +02:00
Alejandro Celaya
16dd1838aa Updated to expressive 3 2018-03-26 18:49:28 +02:00
Alejandro Celaya
f788d6872f Added infection to the build matrix 2018-03-26 18:16:59 +02:00
Alejandro Celaya
d0df007812 Dropped support for PHP 7.0 2018-03-26 18:16:59 +02:00
173 changed files with 4389 additions and 1677 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
.idea
build
composer.lock
composer.phar
vendor/
.env
data/database.sqlite

View File

@@ -5,7 +5,6 @@ branches:
- /.*/
php:
- 7
- 7.1
- 7.2
@@ -16,12 +15,10 @@ before_install:
before_script:
- composer self-update
- composer install --no-interaction
- if [[ $TRAVIS_PHP_VERSION = 7.1 ]] || [[ $TRAVIS_PHP_VERSION = 7.2 ]]; then composer global require --dev phpstan/phpstan:0.9.*; fi
script:
- mkdir build
- composer check
- if [[ $TRAVIS_PHP_VERSION = 7.1 ]] || [[ $TRAVIS_PHP_VERSION = 7.2 ]]; then ~/.composer/vendor/bin/phpstan analyse module/*/src/ --level=6 -c phpstan.neon; fi
after_script:
- vendor/bin/phpcov merge build --clover build/clover.xml

View File

@@ -1,227 +1,732 @@
## CHANGELOG
# CHANGELOG
### 1.7.2
## 1.12.0 - 2018-09-15
**Bugs:**
#### Added
* [135: Fix PathVersionMiddleware being ignored when using expressive 2.2](https://github.com/shlinkio/shlink/issues/135)
* [#187](https://github.com/shlinkio/shlink/issues/187) Included an API endpoint and a CLI command to delete short URLs.
### 1.7.1
Due to the implicit danger of this operation, the deletion includes a safety check. URLs cannot be deleted if they have more than a specific amount of visits.
**Enhancements:**
The visits threshold is set to **15** by default and currently it has to be manually changed. In future versions the installation/update process will ask you about the value of the visits threshold.
* [128: Upgrade to expressive 2.2](https://github.com/shlinkio/shlink/issues/128)
In order to change it, open the `config/autoload/delete_short_urls.global.php` file, which has this structure:
**Bugs**
```php
return [
* [126: Expressive 2.2 causes failures by triggering E_USER_DEPRECATED errors](https://github.com/shlinkio/shlink/issues/126)
'delete_short_urls' => [
'visits_threshold' => 15,
'check_visits_threshold' => true,
],
### 1.7.0
];
```
**Features**
Properties are self explanatory. Change `check_visits_threshold` to `false` to completely disable this safety check, and change the value of `visits_threshold` to allow short URLs with a different number of visits to be deleted.
* [88: Allow to disable tracking of the short URL by including a configurable query param](https://github.com/shlinkio/shlink/issues/88)
* [108: Allow to edit metadata in created shortcodes](https://github.com/shlinkio/shlink/issues/108)
Once changed, delete the `data/cache/app_config.php` file (if any) to let shlink know about the new values.
**Enhancements:**
This check is implicit for the API endpoint, but can be "disabled" for the CLI command, which will ask you when trying to delete a URL which has reached to threshold in order to force the deletion.
* [113: Update CLI commands to use SymfonyStyle](https://github.com/shlinkio/shlink/issues/113)
* [112: Configure cli commands lazy loading](https://github.com/shlinkio/shlink/issues/112)
* [#183](https://github.com/shlinkio/shlink/issues/183) and [#190](https://github.com/shlinkio/shlink/issues/190) Included important documentation improvements in the repository itself. You no longer need to go to the website in order to see how to install or use shlink.
* [#186](https://github.com/shlinkio/shlink/issues/186) Added a small robots.txt file that prevents 404 errors to be logged due to search engines trying to index the domain where shlink is located. Thanks to [@robwent](https://github.com/robwent) for the contribution.
**Tasks**
#### Changed
* [117: Make every module which throws exceptions have its own ExceptionInterface, and make them all extend Throwable](https://github.com/shlinkio/shlink/issues/117)
* [115: Add phpstan to build matrix on PHP >=7.1 envs](https://github.com/shlinkio/shlink/issues/115)
* [114: Replace vlucas/phpdotenv dev requirement by symfony/env](https://github.com/shlinkio/shlink/issues/114)
* [#145](https://github.com/shlinkio/shlink/issues/145) Shlink now obfuscates IP addresses from visitors by replacing the latest octet by `0`, which does not affect geolocation and allows it to fulfil the GDPR.
### 1.6.2
Other known services follow this same approach, like [Google Analytics](https://support.google.com/analytics/answer/2763052?hl=en) or [Matomo](https://matomo.org/docs/privacy/#step-1-automatically-anonymize-visitor-ips)
**Bugs**
* [#182](https://github.com/shlinkio/shlink/issues/182) The short URL creation API endpoints now return the same model used for lists and details endpoints.
* [109: Fix installation error due to typo in latest migration](https://github.com/shlinkio/shlink/issues/109)
#### Deprecated
### 1.6.1
* *Nothing*
**Tasks**
#### Removed
* [110: Create gitattributes file to define files to be excluded from distributable package](https://github.com/shlinkio/shlink/issues/110)
* *Nothing*
### 1.6.0
#### Fixed
**Features**
* [#188](https://github.com/shlinkio/shlink/issues/188) Shlink now allows multiple short URLs to be created that resolve to the same long URL.
* [44: Consider allowing to set custom slugs instead of generating a short code](https://github.com/shlinkio/shlink/issues/44)
* [47: Allow to limit short codes availability by date range](https://github.com/shlinkio/shlink/issues/47)
* [48: Allow to limit the number of visits to a short code](https://github.com/shlinkio/shlink/issues/48)
* [105: Added option to enable/disable URL validation by response status code.](https://github.com/shlinkio/shlink/pull/105)
**Enhancements:**
## 1.11.0 - 2018-08-13
* [27: Add repository functional tests with dbunit](https://github.com/shlinkio/shlink/issues/27)
* [86: Drop support for PHP 5](https://github.com/shlinkio/shlink/issues/86)
* [101: Make actions just capture very specific exceptions, and let the ErrorHandler catch any other exception](https://github.com/shlinkio/shlink/issues/101)
* [104: Use different templates for requested-short-code-does-not-exist and route-could-not-be-match](https://github.com/shlinkio/shlink/issues/104)
#### Added
**Tasks**
* [#170](https://github.com/shlinkio/shlink/issues/170) and [#171](https://github.com/shlinkio/shlink/issues/171) Updated `[GET /short-codes]` and `[GET /short-codes/{shortCode}]` endpoints to return more meaningful information and make their response consistent.
* [99: Replace AnnotatedFactory by ConfigAbstractFactory](https://github.com/shlinkio/shlink/issues/99)
* [100: Replace twig by plates](https://github.com/shlinkio/shlink/issues/100)
* [102: Improve coding standards strictness](https://github.com/shlinkio/shlink/issues/102)
The short URLs are now represented by this object in both cases:
**Bugs**
```json
{
"shortCode": "12Kb3",
"shortUrl": "https://doma.in/12Kb3",
"longUrl": "https://shlink.io",
"dateCreated": "2016-05-01T20:34:16+02:00",
"visitsCount": 1029,
"tags": [
"shlink"
],
"originalUrl": "https://shlink.io"
}
```
* [103: Make NotFoundDelegate return proper content types based on accepted content](https://github.com/shlinkio/shlink/issues/103)
The `originalUrl` property is considered deprecated and has been kept for backward compatibility purposes. It holds the same value as the `longUrl` property.
### 1.5.0
#### Changed
**Enhancements:**
* *Nothing*
* [95: Add tags CRUD to CLI](https://github.com/shlinkio/shlink/issues/95)
* [59: Add tags CRUD to REST](https://github.com/shlinkio/shlink/issues/59)
* [66: Allow to import certain information from older app directory when updating](https://github.com/shlinkio/shlink/issues/66)
#### Deprecated
**Tasks**
* The `originalUrl` property in `[GET /short-codes]` and `[GET /short-codes/{shortCode}]` endpoints is now deprecated and replaced by the `longUrl` property.
* [96: Add namespace to functions](https://github.com/shlinkio/shlink/issues/96)
* [76: Add response examples to swagger docs](https://github.com/shlinkio/shlink/issues/76)
* [93: Improve cross domain management by using the ImplicitOptionsMiddleware](https://github.com/shlinkio/shlink/issues/93)
#### Removed
**Bugs**
* *Nothing*
* [92: Fix formatted dates, using an ISO compliant format](https://github.com/shlinkio/shlink/issues/92)
#### Fixed
### 1.4.0
* *Nothing*
**Enhancements:**
* [89: Update to expressive 2](https://github.com/shlinkio/shlink/issues/89)
## 1.10.2 - 2018-08-04
### 1.3.1
#### Added
**Tasks**
* *Nothing*
* [82: Enable FastRoute routes cache](https://github.com/shlinkio/shlink/issues/82)
* [85: Update year in license file](https://github.com/shlinkio/shlink/issues/85)
* [81: Add docker containers config](https://github.com/shlinkio/shlink/issues/81)
#### Changed
**Bugs**
* *Nothing*
* [83: Short codes list: search in tags when filtering by query string](https://github.com/shlinkio/shlink/issues/83)
* [79: Increase the number of followed redirects](https://github.com/shlinkio/shlink/issues/79)
* [75: Apply PathVersionMiddleware only to rest routes defining it by configuration instead of code](https://github.com/shlinkio/shlink/issues/75)
* [77: Allow defining database server hostname and port](https://github.com/shlinkio/shlink/issues/77)
#### Deprecated
### 1.3.0
* *Nothing*
**Enhancements:**
#### Removed
* [67: Allow to order the short codes list](https://github.com/shlinkio/shlink/issues/67)
* [60: Accept JSON requests in REST and use a body parser middleware to set the parsedBody](https://github.com/shlinkio/shlink/issues/60)
* [72: When listing API keys from CLI, display in yellow color enabled keys that have expired](https://github.com/shlinkio/shlink/issues/72)
* [58: Allow to filter short URLs by tag](https://github.com/shlinkio/shlink/issues/58)
* [69: Allow to filter short codes by text query](https://github.com/shlinkio/shlink/issues/69)
* *Nothing*
**Tasks**
#### Fixed
* [73: Tag endpoints in swagger file](https://github.com/shlinkio/shlink/issues/73)
* [71: Separate swagger docs into multiple files](https://github.com/shlinkio/shlink/issues/71)
* [63: Add path versioning to REST API routes](https://github.com/shlinkio/shlink/issues/63)
* [#177](https://github.com/shlinkio/shlink/issues/177) Fixed `[GET] /short-codes` endpoint returning a 500 status code when trying to filter by `tags` and `searchTerm` at the same time.
* [#175](https://github.com/shlinkio/shlink/issues/175) Fixed error introduced in previous version, where you could end up banned from the service used to resolve IP address locations.
### 1.2.2
In order to fix that, just fill [this form](http://ip-api.com/docs/unban) including your server's IP address and your server should be unbanned.
In order to prevent this, after resolving 150 IP addresses, shlink now waits 1 minute before trying to resolve any more addresses.
**Bugs**
## 1.10.1 - 2018-08-02
#### Added
* *Nothing*
#### Changed
* [#167](https://github.com/shlinkio/shlink/issues/167) Shlink version is now set at build time to avoid older version numbers to be kept in newer builds.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#165](https://github.com/shlinkio/shlink/issues/165) Fixed custom slugs failing when they are longer than 10 characters.
* [#166](https://github.com/shlinkio/shlink/issues/166) Fixed unusual edge case in which visits were not properly counted when ordering by visit and filtering by search term in `[GET] /short-codes` API endpoint.
* [#174](https://github.com/shlinkio/shlink/issues/174) Fixed geolocation not working due to a deprecation on used service.
* [#172](https://github.com/shlinkio/shlink/issues/172) Documented missing filtering params for `[GET] /short-codes/{shortCode}/visits` API endpoint, which allow the list to be filtered by date range.
For example: `https://doma.in/rest/v1/short-urls/abc123/visits?startDate=2017-05-23&endDate=2017-10-05`
* [#169](https://github.com/shlinkio/shlink/issues/169) Fixed unhandled error when parsing `ShortUrlMeta` and date fields are already `DateTime` instances.
## 1.10.0 - 2018-07-09
#### Added
* [#161](https://github.com/shlinkio/shlink/issues/161) AddED support for shlink to be run with [swoole](https://www.swoole.co.uk/) via [zend-expressive-swoole](https://github.com/zendframework/zend-expressive-swoole) package
#### Changed
* [#159](https://github.com/shlinkio/shlink/issues/159) Updated CHANGELOG to follow the [keep-a-changelog](https://keepachangelog.com) format
* [#160](https://github.com/shlinkio/shlink/issues/160) Update infection to v0.9 and phpstan to v 0.10
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*
## 1.9.1 - 2018-06-18
#### Added
* [#155](https://github.com/shlinkio/shlink/issues/155) Improved the pagination object returned in lists, including more meaningful properties.
* Old structure:
```json
{
"pagination": {
"currentPage": 1,
"pagesCount": 2
}
}
```
* New structure:
```json
{
"pagination": {
"currentPage": 2,
"pagesCount": 13,
"itemsPerPage": 10,
"itemsInCurrentPage": 10,
"totalItems": 126
}
}
```
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#154](https://github.com/shlinkio/shlink/issues/154) Fixed sizes of every result page when filtering by searchTerm
* [#157](https://github.com/shlinkio/shlink/issues/157) Background commands executed by installation process now respect the originally used php binary
## 1.9.0 - 2018-05-07
#### Added
* [#147](https://github.com/shlinkio/shlink/issues/147) Allowed short URLs to be created on the fly using a single API request, including the API key in a query param.
This eases integration with third party services.
With this feature, a simple request to a URL like `https://doma.in/rest/v1/short-codes/shorten?apiKey=[YOUR_API_KEY]&longUrl=[URL_TO_BE_SHORTENED]` would return the shortened one in JSON or plain text format.
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#139](https://github.com/shlinkio/shlink/issues/139) Ensured all core actions log exceptions
## 1.8.1 - 2018-04-07
#### Added
* *Nothing*
#### Changed
* [#141](https://github.com/shlinkio/shlink/issues/141) Removed workaround used in `PathVersionMiddleware`, since the bug in zend-stratigility has been fixed.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#140](https://github.com/shlinkio/shlink/issues/140) Fixed warning thrown during installation while trying to include doctrine script
## 1.8.0 - 2018-03-29
#### Added
* [#125](https://github.com/shlinkio/shlink/issues/125) Implemented a path which returns a 1px image instead of a redirection.
Useful to track emails. Just add an image pointing to a URL like `https://doma.in/abc123/track` to any email and an invisible image will be generated tracking every time the email is opened.
* [#132](https://github.com/shlinkio/shlink/issues/132) Added infection to improve tests
#### Changed
* [#130](https://github.com/shlinkio/shlink/issues/130) Updated to Expressive 3
* [#137](https://github.com/shlinkio/shlink/issues/137) Updated symfony components to v4
#### Deprecated
* *Nothing*
#### Removed
* [#131](https://github.com/shlinkio/shlink/issues/131) Dropped support for PHP 7
#### Fixed
* *Nothing*
## 1.7.2 - 2018-03-26
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#135](https://github.com/shlinkio/shlink/issues/135) Fixed `PathVersionMiddleware` being ignored when using expressive 2.2
## 1.7.1 - 2018-03-21
#### Added
* *Nothing*
#### Changed
* [#128](https://github.com/shlinkio/shlink/issues/128) Upgraded to expressive 2.2
This will ease the upcoming update to expressive 3
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#126](https://github.com/shlinkio/shlink/issues/126) Fixed `E_USER_DEPRECATED` errors triggered when using Expressive 2.2
## 1.7.0 - 2018-01-21
#### Added
* [#88](https://github.com/shlinkio/shlink/issues/88) Allowed tracking of short URLs to be disabled by including a configurable query param
* [#108](https://github.com/shlinkio/shlink/issues/108) Allowed metadata to be defined when creating short codes
#### Changed
* [#113](https://github.com/shlinkio/shlink/issues/113) Updated CLI commands to use `SymfonyStyle`
* [#112](https://github.com/shlinkio/shlink/issues/112) Enabled Lazy loading in CLI commands
* [#117](https://github.com/shlinkio/shlink/issues/117) Every module which throws exceptions has now its own `ExceptionInterface` extending `Throwable`
* [#115](https://github.com/shlinkio/shlink/issues/115) Added phpstan to build matrix on PHP >=7.1 envs
* [#114](https://github.com/shlinkio/shlink/issues/114) Replaced [vlucas/phpdotenv](https://github.com/vlucas/phpdotenv) dev requirement by [symfony/dotenv](https://github.com/symfony/dotenv)
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*
## 1.6.2 - 2017-10-25
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#109](https://github.com/shlinkio/shlink/issues/109) Fixed installation error due to typo in latest migration
## 1.6.1 - 2017-10-24
#### Added
* *Nothing*
#### Changed
* [#110](https://github.com/shlinkio/shlink/issues/110) Created `.gitattributes` file to define files to be excluded from distributable package
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*
## 1.6.0 - 2017-10-23
#### Added
* [#44](https://github.com/shlinkio/shlink/issues/44) Now it is possible to set custom slugs for short URLs instead of using a generated short code
* [#47](https://github.com/shlinkio/shlink/issues/47) Allowed to limit short URLs availability by date range
* [#48](https://github.com/shlinkio/shlink/issues/48) Allowed to limit the number of visits to a short URL
* [#105](https://github.com/shlinkio/shlink/pull/105) Added option to enable/disable URL validation by response status code
#### Changed
* [#27](https://github.com/shlinkio/shlink/issues/27) Added repository functional tests with dbunit
* [#101](https://github.com/shlinkio/shlink/issues/101) Now specific actions just capture very specific exceptions, and let the `ErrorHandler` catch any other unhandled exception
* [#104](https://github.com/shlinkio/shlink/issues/104) Used different templates for *requested-short-code-does-not-exist* and *route-could-not-be-match*
* [#99](https://github.com/shlinkio/shlink/issues/99) Replaced usages of `AnnotatedFactory` by `ConfigAbstractFactory`
* [#100](https://github.com/shlinkio/shlink/issues/100) Updated templates engine. Replaced twig by plates
* [#102](https://github.com/shlinkio/shlink/issues/102) Improved coding standards strictness
#### Deprecated
* *Nothing*
#### Removed
* [#86](https://github.com/shlinkio/shlink/issues/86) Dropped support for PHP 5
#### Fixed
* [#103](https://github.com/shlinkio/shlink/issues/103) `NotFoundDelegate` now returns proper content types based on accepted content
## 1.5.0 - 2017-07-16
#### Added
* [#95](https://github.com/shlinkio/shlink/issues/95) Added tags CRUD to CLI
* [#59](https://github.com/shlinkio/shlink/issues/59) Added tags CRUD to REST
* [#66](https://github.com/shlinkio/shlink/issues/66) Allowed certain information to be imported from and older shlink instance directory when updating
#### Changed
* [#96](https://github.com/shlinkio/shlink/issues/96) Added namespace to functions
* [#76](https://github.com/shlinkio/shlink/issues/76) Added response examples to swagger docs
* [#93](https://github.com/shlinkio/shlink/issues/93) Improved cross domain management by using the `ImplicitOptionsMiddleware`
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#92](https://github.com/shlinkio/shlink/issues/92) Fixed formatted dates, using an ISO compliant format
## 1.4.0 - 2017-03-25
#### Added
* *Nothing*
#### Changed
* [#89](https://github.com/shlinkio/shlink/issues/89) Updated to expressive 2
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*
## 1.3.1 - 2017-01-22
#### Added
* *Nothing*
#### Changed
* [#82](https://github.com/shlinkio/shlink/issues/82) Enabled `FastRoute` routes cache
* [#85](https://github.com/shlinkio/shlink/issues/85) Updated year in license file
* [#81](https://github.com/shlinkio/shlink/issues/81) Added docker containers config
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#83](https://github.com/shlinkio/shlink/issues/83) Fixed short codes list: search in tags when filtering by query string
* [#79](https://github.com/shlinkio/shlink/issues/79) Increased the number of followed redirects
* [#75](https://github.com/shlinkio/shlink/issues/75) Applied `PathVersionMiddleware` only to rest routes defining it by configuration instead of code
* [#77](https://github.com/shlinkio/shlink/issues/77) Allowed defining database server hostname and port
## 1.3.0 - 2016-10-23
#### Added
* [#67](https://github.com/shlinkio/shlink/issues/67) Allowed to order the short codes list
* [#60](https://github.com/shlinkio/shlink/issues/60) Accepted JSON requests in REST and used a body parser middleware to set the request's `parsedBody`
* [#72](https://github.com/shlinkio/shlink/issues/72) When listing API keys from CLI, use yellow color for enabled keys that have expired
* [#58](https://github.com/shlinkio/shlink/issues/58) Allowed to filter short URLs by tag
* [#69](https://github.com/shlinkio/shlink/issues/69) Allowed to filter short URLs by text query
* [#73](https://github.com/shlinkio/shlink/issues/73) Added tag-related endpoints to swagger file
* [#63](https://github.com/shlinkio/shlink/issues/63) Added path versioning to REST API routes
#### Changed
* [#71](https://github.com/shlinkio/shlink/issues/71) Separated swagger docs into multiple files
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*
## 1.2.2 - 2016-08-29
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* Fixed minor bugs on CORS requests
### 1.2.1
**Bugs**
## 1.2.1 - 2016-08-21
* [62: Fix cross-domain requests in REST API](https://github.com/shlinkio/shlink/issues/62)
#### Added
### 1.2.0
* *Nothing*
**Features**
#### Changed
* [45: Allow to define tags on short codes, to improve filtering and classification](https://github.com/shlinkio/shlink/issues/45)
* [7: Add website previews while listing available URLs](https://github.com/shlinkio/shlink/issues/7)
* *Nothing*
**Enhancements:**
#### Deprecated
* [57: Add database migrations system to improve updating between versions](https://github.com/shlinkio/shlink/issues/57)
* [31: Add support for other database management systems by improving the EntityManager factory](https://github.com/shlinkio/shlink/issues/31)
* [51: Generate build process to paquetize the app and ease distribution](https://github.com/shlinkio/shlink/issues/51)
* [38: Define installation script. It will request dynamic data on the fly so that there is no need to define env vars](https://github.com/shlinkio/shlink/issues/38)
* *Nothing*
**Tasks**
#### Removed
* [55: Create update script which does not try to create a new database](https://github.com/shlinkio/shlink/issues/55)
* [54: Add cache namespace to prevent name collisions with other apps in the same environment](https://github.com/shlinkio/shlink/issues/54)
* [29: Use the acelaya/ze-content-based-error-handler package instead of custom error handler implementation](https://github.com/shlinkio/shlink/issues/29)
* *Nothing*
**Bugs**
#### Fixed
* [53: Fix entities database interoperability](https://github.com/shlinkio/shlink/issues/53)
* [52: Add missing htaccess file for apache environments](https://github.com/shlinkio/shlink/issues/52)
* [#62](https://github.com/shlinkio/shlink/issues/62) Fixed cross-domain requests in REST API
### 1.1.0
**Features**
## 1.2.0 - 2016-08-21
* [46: Define a route that returns a QR code representing the shortened URL](https://github.com/shlinkio/shlink/issues/46)
#### Added
**Enhancements:**
* [#45](https://github.com/shlinkio/shlink/issues/45) Allowed to define tags on short codes, to improve filtering and classification
* [#7](https://github.com/shlinkio/shlink/issues/7) Added website previews while listing available URLs
* [#57](https://github.com/shlinkio/shlink/issues/57) Added database migrations system to improve updating between versions
* [#31](https://github.com/shlinkio/shlink/issues/31) Added support for other database management systems by improving the `EntityManager` factory
* [#51](https://github.com/shlinkio/shlink/issues/51) Generated build process to create app package and ease distribution
* [#38](https://github.com/shlinkio/shlink/issues/38) Defined installation script. It will request dynamic data on the fly so that there is no need to define env vars
* [#55](https://github.com/shlinkio/shlink/issues/55) Created update script which does not try to create a new database
* [32: Add support for other cache adapters by improving the Cache factory](https://github.com/shlinkio/shlink/issues/32)
* [14: https://github.com/shlinkio/shlink/issues/14](https://github.com/shlinkio/shlink/issues/14)
* [41: Cache the "short code" => "URL" map to prevent extra DB hits](https://github.com/shlinkio/shlink/issues/41)
* [13: Improve REST authentication](https://github.com/shlinkio/shlink/issues/13)
#### Changed
**Tasks**
* [#54](https://github.com/shlinkio/shlink/issues/54) Added cache namespace to prevent name collisions with other apps in the same environment
* [#29](https://github.com/shlinkio/shlink/issues/29) Used the [acelaya/ze-content-based-error-handler](https://github.com/acelaya/ze-content-based-error-handler) package instead of custom error handler implementation
* [39: Change copyright from "Alejandro Celaya" to "Shlink" in error pages](https://github.com/shlinkio/shlink/issues/39)
* [42: Make REST endpoints that need to find something return a 404 when "something" is not found](https://github.com/shlinkio/shlink/issues/42)
* [35: Make CLI commands to use the same PHP namespace as the one used for the command name](https://github.com/shlinkio/shlink/issues/35)
#### Deprecated
**Bugs**
* *Nothing*
* [40: Take into account the X-Forwarded-For header in order to get the visitor information, in case the server is behind a load balancer or proxy](https://github.com/shlinkio/shlink/issues/40)
#### Removed
### 1.0.0
* *Nothing*
**Enhancements:**
#### Fixed
* [33: Create a command to generate a short code charset by randomizing the default one](https://github.com/shlinkio/shlink/issues/33)
* [15: Return JSON/HTML responses for errors (4xx and 5xx) based on accept header (content negotiation)](https://github.com/shlinkio/shlink/issues/15)
* [23: Translate application literals](https://github.com/shlinkio/shlink/issues/23)
* [21: Allow to filter visits by date range](https://github.com/shlinkio/shlink/issues/21)
* [22: Save visits locations data on a visit_locations table](https://github.com/shlinkio/shlink/issues/22)
* [20: Inject cross domain headers in response only if the Origin header is present in the request](https://github.com/shlinkio/shlink/issues/20)
* [11: Separate code into multiple modules](https://github.com/shlinkio/shlink/issues/11)
* [18: Group routable middleware in an Action namespace](https://github.com/shlinkio/shlink/issues/18)
* [#53](https://github.com/shlinkio/shlink/issues/53) Fixed entities database interoperability
* [#52](https://github.com/shlinkio/shlink/issues/52) Added missing htaccess file for apache environments
**Tasks**
* [36: Remove hhvm from the CI matrix since it doesn't support array constants and will fail](https://github.com/shlinkio/shlink/issues/36)
* [4: Installation steps](https://github.com/shlinkio/shlink/issues/4)
* [6: Remove dependency on expressive helpers package](https://github.com/shlinkio/shlink/issues/6)
* [30: Replace the "services" first level config entry by "dependencies", in order to fulfill default Expressive name](https://github.com/shlinkio/shlink/issues/30)
* [12: Improve code coverage](https://github.com/shlinkio/shlink/issues/12)
* [25: Replace "Middleware" suffix on routable middlewares by "Action"](https://github.com/shlinkio/shlink/issues/25)
* [19: Update the vendor and app namespace from Acelaya\UrlShortener to Shlinkio\Shlink](https://github.com/shlinkio/shlink/issues/19)
## 1.1.0 - 2016-08-09
**Bugs**
#### Added
* [24: Prevent duplicated shortcodes errors because of the case insensitive behavior on MySQL](https://github.com/shlinkio/shlink/issues/24)
* [#46](https://github.com/shlinkio/shlink/issues/46) Defined a route that returns a QR code representing the shortened URL.
### 0.2.0
In order to get the QR code URL, use a pattern like `https://doma.in/abc123/qr-code`
**Enhancements:**
* [#32](https://github.com/shlinkio/shlink/issues/32) Added support for other cache adapters by improving the Cache factory
* [#14](https://github.com/shlinkio/shlink/issues/14) Added logger and enabled errors logging
* [#13](https://github.com/shlinkio/shlink/issues/13) Improved REST authentication
* [9: Use symfony/console to dispatch console requests, instead of trying to integrate the process with expressive](https://github.com/shlinkio/shlink/issues/9)
* [8: Create a REST API](https://github.com/shlinkio/shlink/issues/8)
* [10: Add more CLI functionality](https://github.com/shlinkio/shlink/issues/10)
#### Changed
**Tasks**
* [#41](https://github.com/shlinkio/shlink/issues/41) Cached the "short code" => "URL" map to prevent extra DB hits
* [#39](https://github.com/shlinkio/shlink/issues/39) Changed copyright from "Alejandro Celaya" to "Shlink" in error pages
* [#42](https://github.com/shlinkio/shlink/issues/42) REST endpoints that need to find *something* now return a 404 when it is not found
* [#35](https://github.com/shlinkio/shlink/issues/35) Updated CLI commands to use the same PHP namespace as the one used for the command name
* [5: Create CHANGELOG file](https://github.com/shlinkio/shlink/issues/5)
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#40](https://github.com/shlinkio/shlink/issues/40) Taken into account the `X-Forwarded-For` header in order to get the visitor information, in case the server is behind a load balancer or proxy
## 1.0.0 - 2016-08-01
#### Added
* [#33](https://github.com/shlinkio/shlink/issues/33) Created a command that generates a short code charset by randomizing the default one
* [#23](https://github.com/shlinkio/shlink/issues/23) Translated application literals
* [#21](https://github.com/shlinkio/shlink/issues/21) Allowed to filter visits by date range
* [#4](https://github.com/shlinkio/shlink/issues/4) Added installation steps
* [#12](https://github.com/shlinkio/shlink/issues/12) Improved code coverage
#### Changed
* [#15](https://github.com/shlinkio/shlink/issues/15) HTTP requests now return JSON/HTML responses for errors (4xx and 5xx) based on `Accept` header
* [#22](https://github.com/shlinkio/shlink/issues/22) Now visits locations data is saved on a `visit_locations` table
* [#20](https://github.com/shlinkio/shlink/issues/20) Injected cross domain headers in response only if the `Origin` header is present in the request
* [#11](https://github.com/shlinkio/shlink/issues/11) Separated code into multiple modules
* [#18](https://github.com/shlinkio/shlink/issues/18) Grouped routable middleware in an Action namespace
* [#6](https://github.com/shlinkio/shlink/issues/6) Project no longer depends on [zendframework/zend-expressive-helpers](https://github.com/zendframework/zend-expressive-helpers) package
* [#30](https://github.com/shlinkio/shlink/issues/30) Replaced the "services" first level config entry by "dependencies", in order to fulfill default Expressive naming
* [#25](https://github.com/shlinkio/shlink/issues/25) Replaced "Middleware" suffix on routable middlewares by "Action"
* [#19](https://github.com/shlinkio/shlink/issues/19) Changed the vendor and app namespace from `Acelaya\UrlShortener` to `Shlinkio\Shlink`
#### Deprecated
* *Nothing*
#### Removed
* [#36](https://github.com/shlinkio/shlink/issues/36) Removed hhvm from the CI matrix since it doesn't support array constants and will fail
#### Fixed
* [#24](https://github.com/shlinkio/shlink/issues/24) Prevented duplicated short codes errors because of the case insensitive behavior on MySQL
## 0.2.0 - 2016-08-01
#### Added
* [#8](https://github.com/shlinkio/shlink/issues/8) Created a REST API
* [#10](https://github.com/shlinkio/shlink/issues/10) Added more CLI functionality
* [#5](https://github.com/shlinkio/shlink/issues/5) Created a CHANGELOG file
#### Changed
* [#9](https://github.com/shlinkio/shlink/issues/9) Used [symfony/console](https://github.com/symfony/console) to dispatch console requests, instead of trying to integrate the process with expressive
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*

View File

@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2017 Alejandro Celaya
Copyright (c) 2018 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

150
README.md
View File

@@ -1,9 +1,147 @@
# Shlink
[![Build Status](https://travis-ci.org/shlinkio/shlink.svg?branch=master)](https://travis-ci.org/shlinkio/shlink)
[![Code Coverage](https://scrutinizer-ci.com/g/shlinkio/shlink/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/shlinkio/shlink/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
[![Latest Stable Version](https://poser.pugx.org/shlinkio/shlink/v/stable.png)](https://packagist.org/packages/shlinkio/shlink)
[![License](https://poser.pugx.org/shlinkio/shlink/license.png)](https://packagist.org/packages/shlinkio/shlink)
[![Build Status](https://img.shields.io/travis/shlinkio/shlink.svg?style=flat-square)](https://travis-ci.org/shlinkio/shlink)
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
[![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink)
[![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/master/LICENSE)
[![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://acel.me/donate)
A PHP-based URL shortener application with analytics and management
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own custom domain.
## Installation
First make sure the host where you are going to run shlink fulfills these requirements:
* PHP 7.1 or greater with JSON, APCu, intl, curl, PDO and gd extensions enabled.
* MySQL, PostgreSQL or SQLite.
* The web server of your choice with PHP integration (Apache or Nginx recommended).
Then, you will need a built version of the project. There are a few ways to get it.
* **Using a dist file**
The easiest way to install shlink is by using one of the pre-bundled distributable packages.
Just 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.
Finally, decompress the file in the location of your choice.
* **Building from sources**
If for any reason you want to build the project yourself, follow these steps:
* 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).
After that, you will have a `shlink_x.x.x_dist.zip` dist file inside the `build` directory.
This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is created, attaching generated dist file to it.
Despite how you built the project, you are going to need to install it now, by following these steps:
* If you are going to use MySQL or PostgreSQL, create an empty database with the name of your choice.
* Recursively grant write permissions to the `data` directory. Shlink uses it to cache some information.
* Setup the application by running the `bin/install` script. It will guide you through the installation process.
* Configure the web server of your choice to serve shlink using your short domain.
For example, assuming your domain is doma.in and shlink is in the `/path/to/shlink` folder, this would be the basic configuration 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.1-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>
```
* 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.
* Finally access to [https://app.shlink.io](https://app.shlink.io) and configure your server to start creating short URLs.
**Bonus**
There are a couple of time-consuming tasks that shlink expects you to do manually, or at least it is recommended, since it will improve runtime performance.
Those tasks can be performed using shlink's CLI, so it should be easy to schedule them to be run in the background (for example, using cron jobs):
* Resolve IP address locations: `/path/to/shlink/bin/cli visit:process`
If you don't run this command regularly, the stats will say all visits come from *unknown* locations.
* Generate website previews: `/path/to/shlink/bin/cli shortcode:process-previews`
Running this will improve the performance of the `doma.in/abc123/preview` URLs, which return a preview of the site.
## Update to new version
When a new Shlink version is available, you don't need to repeat the whole process yourself.
Instead, get the latest version as explained in previous step, and then, run the script `bin/update`.
The script will ask you for the location from previous shlink version, and use it in order to import the configuration.
It will then update the database and generate some assets.
Right now, it does not import cached info (like website previews), but it will. By now you will need to regenerate them again.
**Important!** It is recommended that you don't skip any version when using this process. The update gets better on every version, but older versions might make assumptions.
## Using a docker image
Currently there's no official docker image, but there's a work in progress alpha version you can find [here](https://hub.docker.com/r/shlinkio/shlink/).
The idea will be that you can just generate a container using the image and provide predefined config files via volumes or CLI arguments, so that you get shlink up and running.
Currently the image does not expose an entry point which let's you interact with shlink's CLI interface, nor allows configuration to be passed.
## Using shlink
Once shlink is installed, there are two main ways to interact with it:
* **The command line**. Try running `bin/cli` and see all the available commands.
All of those commands can be run with the `--help`/`-h` flag in order to see how to use them and all the available options.
It is probably a good idea to symlink the CLI entry point (`bin/cli`) to somewhere in your path, so that you can run shlink from any directory.
* **The REST API**. The complete docs on how to use the API can be found [here](https://shlink.io/api-docs), and a sandbox which also documents every endpoint can be found [here](https://shlink.io/swagger-ui/index.html).
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.
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.

View File

@@ -10,6 +10,7 @@ fi
version=$1
builtcontent=$(readlink -f "../shlink_${version}_dist")
projectdir=$(pwd)
[ -f ./composer.phar ] && composerBin='./composer.phar' || composerBin='composer'
# Copy project content to temp dir
echo 'Copying project files...'
@@ -20,10 +21,11 @@ cp -R "${projectdir}"/* "${builtcontent}"
cd "${builtcontent}"
# Install dependencies
echo "Installing dependencies with $composerBin..."
rm -rf vendor
rm -f composer.lock
composer self-update
composer install --no-dev --optimize-autoloader --no-progress --no-interaction
$composerBin self-update
$composerBin install --no-dev --optimize-autoloader --no-progress --no-interaction
# Delete development files
echo 'Deleting dev files...'
@@ -33,8 +35,12 @@ rm composer.*
rm LICENSE
rm indocker
rm docker-compose.yml
rm docker-compose.override.yml
rm docker-compose.override.yml.dist
rm func_tests_bootstrap.php
rm php*
rm README.md
rm infection.json
rm -rf build
rm -ff data/database.sqlite
rm -rf data/infra
@@ -42,7 +48,13 @@ rm -rf data/{cache,log,proxies}/{*,.gitignore}
rm -rf config/params/{*,.gitignore}
rm -rf config/autoload/{{,*.}local.php{,.dist},.gitignore}
# Update shlink version in config
sed -i "s/%SHLINK_VERSION%/${version}/g" config/autoload/app_options.global.php
# Compressing file
echo 'Compressing files...'
rm -f "${projectdir}"/build/shlink_${version}_dist.zip
zip -ry "${projectdir}"/build/shlink_${version}_dist.zip "../shlink_${version}_dist"
rm -rf "${builtcontent}"
echo 'Done!'

View File

@@ -12,33 +12,31 @@
}
],
"require": {
"php": "^7.0",
"acelaya/ze-content-based-error-handler": "^2.0",
"php": "^7.1",
"ext-json": "*",
"ext-pdo": "*",
"acelaya/ze-content-based-error-handler": "^2.2",
"cocur/slugify": "^3.0",
"doctrine/annotations": "^1.4",
"doctrine/cache": "^1.6",
"doctrine/collections": "^1.4",
"doctrine/common": "^2.7",
"doctrine/dbal": "^2.5",
"doctrine/migrations": "^1.4",
"doctrine/orm": "^2.5",
"endroid/qrcode": "^1.7",
"endroid/qr-code": "^1.7",
"firebase/php-jwt": "^4.0",
"guzzlehttp/guzzle": "^6.2",
"http-interop/http-middleware": "^0.4.1",
"mikehaertl/phpwkhtmltopdf": "^2.2",
"monolog/monolog": "^1.21",
"roave/security-advisories": "dev-master",
"symfony/console": "^3.4",
"symfony/filesystem": "^3.0",
"symfony/process": "^3.0",
"symfony/console": "^4.0",
"symfony/filesystem": "^4.0",
"symfony/process": "^4.0",
"theorchard/monolog-cascade": "^0.4",
"zendframework/zend-config": "^3.0",
"zendframework/zend-config-aggregator": "^1.0",
"zendframework/zend-expressive": "^2.0",
"zendframework/zend-expressive-fastroute": "^2.0",
"zendframework/zend-expressive-helpers": "^4.2",
"zendframework/zend-expressive-platesrenderer": "^1.3",
"zendframework/zend-diactoros": "^1.7",
"zendframework/zend-expressive": "^3.0",
"zendframework/zend-expressive-fastroute": "^3.0",
"zendframework/zend-expressive-helpers": "^5.0",
"zendframework/zend-expressive-platesrenderer": "^2.0",
"zendframework/zend-i18n": "^2.7",
"zendframework/zend-inputfilter": "^2.8",
"zendframework/zend-paginator": "^2.6",
@@ -47,14 +45,16 @@
},
"require-dev": {
"filp/whoops": "^2.0",
"phpunit/dbunit": "^3.0",
"phpunit/phpcov": "^4.0",
"phpunit/phpunit": "^6.0",
"infection/infection": "^0.9.0",
"phpstan/phpstan": "^0.10.0",
"phpunit/phpcov": "^5.0",
"phpunit/phpunit": "^7.0",
"slevomat/coding-standard": "^4.0",
"squizlabs/php_codesniffer": "^3.1 <3.2",
"symfony/dotenv": "^3.4",
"symfony/var-dumper": "^3.0",
"zendframework/zend-expressive-tooling": "^0.4"
"squizlabs/php_codesniffer": "^3.2.3",
"symfony/dotenv": "^4.0",
"symfony/var-dumper": "^4.0",
"zendframework/zend-component-installer": "^2.1",
"zendframework/zend-expressive-tooling": "^1.0"
},
"autoload": {
"psr-4": {
@@ -84,8 +84,10 @@
"scripts": {
"check": [
"@cs",
"@stan",
"@test",
"@func-test"
"@func-test",
"@infect"
],
"cs": "phpcs",
"cs-fix": "phpcbf",
@@ -97,13 +99,17 @@
"@test",
"@func-test",
"phpcov merge build --html build/html"
]
],
"stan": "phpstan analyse module/*/src/ --level=6 -c phpstan.neon",
"infect": "infection --threads=4 --min-msi=55 --only-covered --log-verbosity=2",
"infect-show": "infection --threads=4 --min-msi=55 --only-covered --log-verbosity=2 --show-mutations",
"expressive": "expressive"
},
"config": {
"process-timeout": 0,
"sort-packages": true,
"platform": {
"php": "7.0.8"
"php": "7.1.8"
}
}
}

View File

@@ -7,8 +7,9 @@ return [
'app_options' => [
'name' => 'Shlink',
'version' => '1.7.0',
'version' => '%SHLINK_VERSION%',
'secret_key' => env('SECRET_KEY'),
'disable_track_param' => null,
],
];

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
return [
'delete_short_urls' => [
'visits_threshold' => 15,
'check_visits_threshold' => true,
],
];

View File

@@ -5,30 +5,23 @@ use Shlinkio\Shlink\Common\Factory\EmptyResponseImplicitOptionsMiddlewareFactory
use Zend\Expressive;
use Zend\Expressive\Container;
use Zend\Expressive\Helper;
use Zend\Expressive\Middleware;
use Zend\Expressive\Plates;
use Zend\Expressive\Router;
use Zend\Expressive\Router\Middleware\ImplicitOptionsMiddleware;
use Zend\Expressive\Template;
use Zend\ServiceManager\Factory\InvokableFactory;
use Zend\Stratigility\Middleware\ErrorHandler;
return [
'dependencies' => [
'factories' => [
Expressive\Application::class => Container\ApplicationFactory::class,
Template\TemplateRendererInterface::class => Plates\PlatesRendererFactory::class,
Router\RouterInterface::class => Router\FastRouteRouterFactory::class,
ErrorHandler::class => Container\ErrorHandlerFactory::class,
ImplicitOptionsMiddleware::class => EmptyResponseImplicitOptionsMiddlewareFactory::class,
Helper\UrlHelper::class => Helper\UrlHelperFactory::class,
Helper\ServerUrlHelper::class => InvokableFactory::class,
],
'aliases' => [
Middleware\ImplicitOptionsMiddleware::class => ImplicitOptionsMiddleware::class,
'delegators' => [
Expressive\Application::class => [
Container\ApplicationConfigInjectionDelegator::class,
],
],
],

View File

@@ -2,6 +2,7 @@
declare(strict_types=1);
use Shlinkio\Shlink\Common\Middleware\LocaleMiddleware;
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
use Shlinkio\Shlink\Rest\Middleware\BodyParserMiddleware;
use Shlinkio\Shlink\Rest\Middleware\CheckAuthenticationMiddleware;
use Shlinkio\Shlink\Rest\Middleware\CrossDomainMiddleware;
@@ -15,12 +16,13 @@ return [
'pre-routing' => [
'middleware' => [
ErrorHandler::class,
Expressive\Helper\ContentLengthMiddleware::class,
LocaleMiddleware::class,
],
'priority' => 11,
],
'pre-routing-rest' => [
// 'path' => '/rest',
'path' => '/rest',
'middleware' => [
PathVersionMiddleware::class,
],
@@ -48,6 +50,7 @@ return [
'post-routing' => [
'middleware' => [
Expressive\Router\Middleware\DispatchMiddleware::class,
NotFoundHandler::class,
],
'priority' => 1,
],

View File

@@ -7,6 +7,7 @@ use Shlinkio\Shlink\Common;
use Shlinkio\Shlink\Core;
use Shlinkio\Shlink\Rest;
use Zend\ConfigAggregator;
use Zend\Expressive;
/**
* Configuration files are loaded in a specific order. First ``global.php``, then ``*.global.php``.
@@ -18,8 +19,14 @@ use Zend\ConfigAggregator;
*/
return (new ConfigAggregator\ConfigAggregator([
Zend\Expressive\ConfigProvider::class,
Zend\Expressive\Router\ConfigProvider::class,
Expressive\ConfigProvider::class,
Expressive\Router\ConfigProvider::class,
Expressive\Router\FastRouteRouter\ConfigProvider::class,
Expressive\Plates\ConfigProvider::class,
Expressive\Helper\ConfigProvider::class,
\class_exists(Expressive\Swoole\ConfigProvider::class)
? Expressive\Swoole\ConfigProvider::class
: new ConfigAggregator\ArrayProvider([]),
ExpressiveErrorHandler\ConfigProvider::class,
Common\ConfigProvider::class,
Core\ConfigProvider::class,

0
data/infra/database/.gitignore vendored Normal file → Executable file
View File

0
data/infra/nginx/.gitignore vendored Normal file → Executable file
View File

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20180801183328 extends AbstractMigration
{
private const NEW_SIZE = 255;
private const OLD_SIZE = 10;
/**
* @param Schema $schema
* @throws SchemaException
*/
public function up(Schema $schema): void
{
$this->setSize($schema, self::NEW_SIZE);
}
/**
* @param Schema $schema
* @throws SchemaException
*/
public function down(Schema $schema): void
{
$this->setSize($schema, self::OLD_SIZE);
}
/**
* @param Schema $schema
* @param int $size
* @throws SchemaException
*/
private function setSize(Schema $schema, int $size): void
{
$schema->getTable('short_urls')->getColumn('short_code')->setLength($size);
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\DBALException;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\Util\IpAddress;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20180913205455 extends AbstractMigration
{
/**
* @param Schema $schema
*/
public function up(Schema $schema): void
{
// Nothing to create
}
/**
* @param Schema $schema
* @throws DBALException
*/
public function postUp(Schema $schema): void
{
$qb = $this->connection->createQueryBuilder();
$qb->select('id', 'remote_addr')
->from('visits');
$st = $this->connection->executeQuery($qb->getSQL());
$qb = $this->connection->createQueryBuilder();
$qb->update('visits', 'v')
->set('v.remote_addr', ':obfuscatedAddr')
->where('v.id=:id');
while ($row = $st->fetch(\PDO::FETCH_ASSOC)) {
$addr = $row['remote_addr'] ?? null;
if ($addr === null) {
continue;
}
$qb->setParameters([
'id' => $row['id'],
'obfuscatedAddr' => $this->determineAddress((string) $addr),
])->execute();
}
}
private function determineAddress(string $addr): ?string
{
if ($addr === IpAddress::LOCALHOST) {
return $addr;
}
try {
return (string) IpAddress::fromString($addr)->getObfuscatedCopy();
} catch (WrongIpException $e) {
return null;
}
}
/**
* @param Schema $schema
*/
public function down(Schema $schema): void
{
// Nothing to rollback
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20180915110857 extends AbstractMigration
{
private const ON_DELETE_MAP = [
'visit_locations' => 'SET NULL',
'short_urls' => 'CASCADE',
];
/**
* @param Schema $schema
* @throws SchemaException
*/
public function up(Schema $schema): void
{
$visits = $schema->getTable('visits');
$foreignKeys = $visits->getForeignKeys();
// Remove all existing foreign keys and add them again with CASCADE delete
foreach ($foreignKeys as $foreignKey) {
$visits->removeForeignKey($foreignKey->getName());
$foreignTable = $foreignKey->getForeignTableName();
$visits->addForeignKeyConstraint(
$foreignTable,
$foreignKey->getLocalColumns(),
$foreignKey->getForeignColumns(),
[
'onDelete' => self::ON_DELETE_MAP[$foreignTable],
'onUpdate' => 'RESTRICT',
]
);
}
}
public function down(Schema $schema): void
{
// Nothing to run
}
}

View File

@@ -3,11 +3,23 @@
"properties": {
"currentPage": {
"type": "integer",
"description": "The number of current page being displayed."
"description": "The number of current page."
},
"pagesCount": {
"type": "integer",
"description": "The total number of pages that can be displayed."
"description": "The total number of pages that can be obtained."
},
"itemsPerPage": {
"type": "integer",
"description": "The number of items for every page."
},
"itemsInCurrentPage": {
"type": "integer",
"description": "The number of items in current page (could be smaller than itemsPerPage)."
},
"totalItems": {
"type": "integer",
"description": "The total number of items among all pages."
}
}
}

View File

@@ -5,7 +5,11 @@
"type": "string",
"description": "The short code for this short URL."
},
"originalUrl": {
"shortUrl": {
"type": "string",
"description": "The short URL."
},
"longUrl": {
"type": "string",
"description": "The original long URL."
},
@@ -24,6 +28,11 @@
"type": "string"
},
"description": "A list of tags applied to this short URL"
},
"originalUrl": {
"deprecated": true,
"type": "string",
"description": "The original long URL. [DEPRECATED. Use longUrl instead]"
}
}
}

View File

@@ -2,17 +2,25 @@
"type": "object",
"properties": {
"referer": {
"type": "string"
"type": "string",
"description": "The origin from which the visit was performed"
},
"date": {
"type": "string",
"format": "date-time"
},
"remoteAddr": {
"type": "string"
"format": "date-time",
"description": "The date in which the visit was performed"
},
"userAgent": {
"type": "string"
"type": "string",
"description": "The user agent from which the visit was performed"
},
"visitLocation": {
"$ref": "./VisitLocation.json"
},
"remoteAddr": {
"type": "string",
"description": "This value is deprecated and will always be null",
"deprecated": true
}
}
}

View File

@@ -0,0 +1,26 @@
{
"type": "object",
"properties": {
"cityName": {
"type": "string"
},
"countryCode": {
"type": "string"
},
"countryName": {
"type": "string"
},
"latitude": {
"type": "string"
},
"longitude": {
"type": "string"
},
"regionName": {
"type": "string"
},
"timezone": {
"type": "string"
}
}
}

View File

@@ -25,7 +25,7 @@
}
},
{
"name": "tags",
"name": "tags[]",
"in": "query",
"description": "A list of tags used to filter the resultset. Only short URLs tagged with at least one of the provided tags will be returned. (Since v1.3.0)",
"required": false,
@@ -44,7 +44,7 @@
"schema": {
"type": "string",
"enum": [
"originalUrl",
"longUrl",
"shortCode",
"dateCreated",
"visits"
@@ -89,7 +89,8 @@
"data": [
{
"shortCode": "12C18",
"originalUrl": "https://store.steampowered.com",
"shortUrl": "https://doma.in/12C18",
"longUrl": "https://store.steampowered.com",
"dateCreated": "2016-08-21T20:34:16+02:00",
"visitsCount": 328,
"tags": [
@@ -99,7 +100,8 @@
},
{
"shortCode": "12Kb3",
"originalUrl": "https://shlink.io",
"shortUrl": "https://doma.in/12Kb3",
"longUrl": "https://shlink.io",
"dateCreated": "2016-05-01T20:34:16+02:00",
"visitsCount": 1029,
"tags": [
@@ -108,7 +110,8 @@
},
{
"shortCode": "123bA",
"originalUrl": "https://www.google.com",
"shortUrl": "https://doma.in/123bA",
"longUrl": "https://www.google.com",
"dateCreated": "2015-10-01T20:34:16+02:00",
"visitsCount": 25,
"tags": []
@@ -116,7 +119,10 @@
],
"pagination": {
"currentPage": 5,
"pagesCount": 12
"pagesCount": 12,
"itemsPerPage": 10,
"itemsInCurrentPage": 10,
"totalItems": 115
}
}
}

View File

@@ -0,0 +1,125 @@
{
"get": {
"tags": [
"ShortCodes"
],
"summary": "Create a short URL",
"description": "Creates a short URL in a single API call. Useful for third party integrations",
"parameters": [
{
"name": "apiKey",
"in": "query",
"description": "The API key used to authenticate the request",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "longUrl",
"in": "query",
"description": "The URL to be shortened",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "format",
"in": "query",
"description": "The format in which you want the response to be returned. You can also use the \"Accept\" header instead of this",
"required": false,
"schema": {
"type": "string",
"enum": [
"txt",
"json"
]
}
}
],
"responses": {
"200": {
"description": "The list of short URLs",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"longUrl": {
"type": "string",
"description": "The original long URL that has been shortened"
},
"shortUrl": {
"type": "string",
"description": "The generated short URL"
},
"shortCode": {
"type": "string",
"description": "the short code that is being used in the short URL"
}
}
}
},
"text/plain": {
"schema": {
"type": "string"
}
}
},
"examples": {
"application/json": {
"longUrl": "https://github.com/shlinkio/shlink",
"shortUrl": "https://dom.ain/abc123",
"shortCode": "abc123"
},
"text/plain": "https://dom.ain/abc123"
}
},
"400": {
"description": "The long URL was not provided or is invalid.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
},
"text/plain": {
"schema": {
"type": "string"
}
}
},
"examples": {
"application/json": {
"error": "INVALID_URL",
"message": "Provided URL foo is invalid. Try with a different one."
},
"text/plain": "INVALID_URL"
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
},
"text/plain": {
"schema": {
"type": "string"
}
}
},
"examples": {
"application/json": {
"error": "UNKNOWN_ERROR",
"message": "Unexpected error occurred"
},
"text/plain": "UNKNOWN_ERROR"
}
}
}
}
}

View File

@@ -23,23 +23,24 @@
],
"responses": {
"200": {
"description": "The long URL behind a short code.",
"description": "The URL info behind a short code.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"longUrl": {
"type": "string",
"description": "The original long URL behind the short code."
}
}
"$ref": "../definitions/ShortUrl.json"
}
}
},
"examples": {
"application/json": {
"longUrl": "https://shlink.io"
"shortCode": "12Kb3",
"shortUrl": "https://doma.in/12Kb3",
"longUrl": "https://shlink.io",
"dateCreated": "2016-05-01T20:34:16+02:00",
"visitsCount": 1029,
"tags": [
"shlink"
]
}
}
},
@@ -158,5 +159,70 @@
}
}
}
},
"delete": {
"tags": [
"ShortCodes"
],
"summary": "Delete short code",
"description": "Deletes the short URL for provided short code.",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The short code to edit.",
"required": true,
"schema": {
"type": "string"
}
}
],
"security": [
{
"Bearer": []
}
],
"responses": {
"204": {
"description": "The short code has been properly deleted."
},
"400": {
"description": "The visits threshold in shlink does not allow this short URL to be deleted.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
},
"examples": {
"application/json": {
"error": "INVALID_SHORTCODE_DELETION",
"message": "It is not possible to delete URL with short code \"abc123\" because it has reached more than \"15\" visits."
}
}
},
"404": {
"description": "No short URL was found for provided short code.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View File

@@ -15,6 +15,24 @@
"schema": {
"type": "string"
}
},
{
"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"
}
}
],
"security": [
@@ -52,20 +70,28 @@
{
"referer": "https://twitter.com",
"date": "2015-08-20T05:05:03+04:00",
"remoteAddr": "10.20.30.40",
"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"
"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
},
{
"referer": "https://t.co",
"date": "2015-08-20T05:05:03+04:00",
"remoteAddr": "11.22.33.44",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36"
"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"
}
},
{
"referer": null,
"date": "2015-08-20T05:05:03+04:00",
"remoteAddr": "110.220.5.6",
"userAgent": "some_web_crawler/1.4"
"userAgent": "some_web_crawler/1.4",
"visitLocation": null
}
]
}

View File

@@ -40,6 +40,9 @@
"/v1/short-codes": {
"$ref": "paths/v1_short-codes.json"
},
"/v1/short-codes/shorten": {
"$ref": "paths/v1_short-codes_shorten.json"
},
"/v1/short-codes/{shortCode}": {
"$ref": "paths/v1_short-codes_{shortCode}.json"
},

22
infection.json Normal file
View File

@@ -0,0 +1,22 @@
{
"source": {
"directories": [
"module/*/src"
],
"excludes": []
},
"timeout": 10,
"logs": {
"text": "build/infection/infection-log.txt",
"summary": "build/infection/summary-log.txt",
"debug": "build/infection/debug-log.txt"
},
"tmpDir": "build/infection/temp",
"phpUnit": {
"configDir": "."
},
"mutators": {
"@default": true,
"IdenticalEqual": false
}
}

View File

@@ -14,6 +14,7 @@ return [
Command\Shortcode\ListShortcodesCommand::NAME => Command\Shortcode\ListShortcodesCommand::class,
Command\Shortcode\GetVisitsCommand::NAME => Command\Shortcode\GetVisitsCommand::class,
Command\Shortcode\GeneratePreviewCommand::NAME => Command\Shortcode\GeneratePreviewCommand::class,
Command\Shortcode\DeleteShortCodeCommand::NAME => Command\Shortcode\DeleteShortCodeCommand::class,
Command\Visit\ProcessVisitsCommand::NAME => Command\Visit\ProcessVisitsCommand::class,

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
use Shlinkio\Shlink\CLI\Command;
use Shlinkio\Shlink\CLI\Factory\ApplicationFactory;
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
@@ -22,12 +22,17 @@ return [
Command\Shortcode\ListShortcodesCommand::class => ConfigAbstractFactory::class,
Command\Shortcode\GetVisitsCommand::class => ConfigAbstractFactory::class,
Command\Shortcode\GeneratePreviewCommand::class => ConfigAbstractFactory::class,
Command\Shortcode\DeleteShortCodeCommand::class => ConfigAbstractFactory::class,
Command\Visit\ProcessVisitsCommand::class => ConfigAbstractFactory::class,
Command\Config\GenerateCharsetCommand::class => ConfigAbstractFactory::class,
Command\Config\GenerateSecretCommand::class => ConfigAbstractFactory::class,
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class,
Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class,
Command\Tag\CreateTagCommand::class => ConfigAbstractFactory::class,
Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class,
@@ -42,23 +47,35 @@ return [
'config.url_shortener.domain',
],
Command\Shortcode\ResolveUrlCommand::class => [Service\UrlShortener::class, 'translator'],
Command\Shortcode\ListShortcodesCommand::class => [Service\ShortUrlService::class, 'translator'],
Command\Shortcode\ListShortcodesCommand::class => [
Service\ShortUrlService::class,
'translator',
'config.url_shortener.domain',
],
Command\Shortcode\GetVisitsCommand::class => [Service\VisitsTracker::class, 'translator'],
Command\Shortcode\GeneratePreviewCommand::class => [
Service\ShortUrlService::class,
PreviewGenerator::class,
'translator',
],
Command\Visit\ProcessVisitsCommand::class => [
Service\VisitService::class,
IpLocationResolver::class,
Command\Shortcode\DeleteShortCodeCommand::class => [
Service\ShortUrl\DeleteShortUrlService::class,
'translator',
],
Command\Visit\ProcessVisitsCommand::class => [
Service\VisitService::class,
IpApiLocationResolver::class,
'translator',
],
Command\Config\GenerateCharsetCommand::class => ['translator'],
Command\Config\GenerateSecretCommand::class => ['translator'],
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, 'translator'],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class, 'translator'],
Command\Api\ListKeysCommand::class => [ApiKeyService::class, 'translator'],
Command\Tag\ListTagsCommand::class => [Service\Tag\TagService::class, Translator::class],
Command\Tag\CreateTagCommand::class => [Service\Tag\TagService::class, Translator::class],
Command\Tag\RenameTagCommand::class => [Service\Tag\TagService::class, Translator::class],

Binary file not shown.

View File

@@ -1,15 +1,15 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2018-01-21 09:36+0100\n"
"PO-Revision-Date: 2018-01-21 09:39+0100\n"
"POT-Creation-Date: 2018-09-15 17:57+0200\n"
"PO-Revision-Date: 2018-09-15 18:02+0200\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.0.4\n"
"X-Generator: Poedit 2.0.6\n"
"X-Poedit-Basepath: ..\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Poedit-SourceCharset: UTF-8\n"
@@ -80,6 +80,41 @@ msgstr ""
msgid "Secret key: \"%s\""
msgstr "Clave secreta: \"%s\""
msgid "Deletes a short URL"
msgstr "Elimina una URL"
msgid "The short code to be deleted"
msgstr "El código corto a eliminar"
msgid ""
"Ignores the safety visits threshold check, which could make short URLs with "
"many visits to be accidentally deleted"
msgstr ""
"Ignora el límite de seguridad de visitas, pudiendo resultar en el borrado "
"accidental de URLs con muchas visitas"
#, php-format
msgid "Provided short code \"%s\" could not be found."
msgstr "El código corto proporcionado \"%s\" no ha podido ser encontrado."
#, php-format
msgid ""
"It was not possible to delete the short URL with short code \"%s\" because "
"it has more than %s visits."
msgstr ""
"No se pudo eliminar la URL acortada con código corto \"%s\" porque tiene más "
"de %s visitas."
msgid "Do you want to delete it anyway?"
msgstr "¿Aún así quieres eliminarla?"
msgid "Short URL was not deleted."
msgstr "La URL corta no ha sido eliminada."
#, php-format
msgid "Short URL with short code \"%s\" successfully deleted."
msgstr "La URL acortada con el código corto \"%s\" eliminada correctamente."
msgid ""
"Processes and generates the previews for every URL, improving performance "
"for later web requests."
@@ -183,12 +218,12 @@ msgstr "Origen"
msgid "Date"
msgstr "Fecha"
msgid "Remote Address"
msgstr "Dirección remota"
msgid "User agent"
msgstr "Agente de usuario"
msgid "Country"
msgstr "País"
msgid "List all short URLs"
msgstr "Listar todas las URLs cortas"
@@ -218,8 +253,11 @@ msgstr "Si se desea mostrar las etiquetas o no"
msgid "Short code"
msgstr "Código corto"
msgid "Original URL"
msgstr "URL original"
msgid "Short URL"
msgstr "URL corta"
msgid "Long URL"
msgstr "URL larga"
msgid "Date created"
msgstr "Fecha de creación"
@@ -253,10 +291,6 @@ msgstr "URL larga:"
msgid "Provided short code \"%s\" has an invalid format."
msgstr "El código corto proporcionado \"%s\" tiene un formato inválido."
#, php-format
msgid "Provided short code \"%s\" could not be found."
msgstr "El código corto proporcionado \"%s\" no ha podido ser encontrado."
msgid "Creates one or more tags."
msgstr "Crea una o más etiquetas."
@@ -317,9 +351,22 @@ msgstr "Ignorada IP de localhost"
msgid "Address located at \"%s\""
msgstr "Dirección localizada en \"%s\""
msgid "An error occurred while locating IP"
msgstr "Se produjo un error al localizar la IP"
#, php-format
msgid "IP location resolver limit reached. Waiting %s seconds..."
msgstr "Limite del localizador de IPs alcanzado. Esperando %s segundos..."
msgid "Finished processing all IPs"
msgstr "Finalizado el procesado de todas las IPs"
#~ msgid "Remote Address"
#~ msgstr "Dirección remota"
#~ msgid "Original URL"
#~ msgstr "URL original"
#~ msgid "You have reached last page"
#~ msgstr "Has alcanzado la última página"

View File

@@ -14,7 +14,11 @@ use Zend\I18n\Translator\TranslatorInterface;
class ListKeysCommand extends Command
{
const NAME = 'api-key:list';
private const ERROR_STRING_PATTERN = '<fg=red>%s</>';
private const SUCCESS_STRING_PATTERN = '<info>%s</info>';
private const WARNING_STRING_PATTERN = '<comment>%s</comment>';
public const NAME = 'api-key:list';
/**
* @var ApiKeyServiceInterface
@@ -55,59 +59,32 @@ class ListKeysCommand extends Command
foreach ($list as $row) {
$key = $row->getKey();
$expiration = $row->getExpirationDate();
$formatMethod = $this->determineFormatMethod($row);
$messagePattern = $this->determineMessagePattern($row);
// Set columns for this row
$rowData = [$formatMethod($key)];
$rowData = [\sprintf($messagePattern, $key)];
if (! $enabledOnly) {
$rowData[] = $formatMethod($this->getEnabledSymbol($row));
$rowData[] = \sprintf($messagePattern, $this->getEnabledSymbol($row));
}
$rowData[] = $expiration !== null ? $expiration->format(\DateTime::ATOM) : '-';
$rows[] = $rowData;
}
$io->table(array_filter([
$io->table(\array_filter([
$this->translator->translate('Key'),
! $enabledOnly ? $this->translator->translate('Is enabled') : null,
$this->translator->translate('Expiration date'),
]), $rows);
}
private function determineFormatMethod(ApiKey $apiKey): callable
private function determineMessagePattern(ApiKey $apiKey): string
{
if (! $apiKey->isEnabled()) {
return [$this, 'getErrorString'];
return self::ERROR_STRING_PATTERN;
}
return $apiKey->isExpired() ? [$this, 'getWarningString'] : [$this, 'getSuccessString'];
}
/**
* @param string $value
* @return string
*/
private function getErrorString(string $value): string
{
return sprintf('<fg=red>%s</>', $value);
}
/**
* @param string $value
* @return string
*/
private function getSuccessString(string $value): string
{
return sprintf('<info>%s</info>', $value);
}
/**
* @param string $value
* @return string
*/
private function getWarningString(string $value): string
{
return sprintf('<comment>%s</comment>', $value);
return $apiKey->isExpired() ? self::WARNING_STRING_PATTERN : self::SUCCESS_STRING_PATTERN;
}
/**

View File

@@ -18,6 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\PhpExecutableFinder;
use Zend\Config\Writer\WriterInterface;
class InstallCommand extends Command
@@ -48,6 +49,14 @@ class InstallCommand extends Command
* @var bool
*/
private $isUpdate;
/**
* @var PhpExecutableFinder
*/
private $phpFinder;
/**
* @var string|bool
*/
private $phpBinary;
/**
* InstallCommand constructor.
@@ -55,22 +64,25 @@ class InstallCommand extends Command
* @param Filesystem $filesystem
* @param ConfigCustomizerManagerInterface $configCustomizers
* @param bool $isUpdate
* @param PhpExecutableFinder|null $phpFinder
* @throws LogicException
*/
public function __construct(
WriterInterface $configWriter,
Filesystem $filesystem,
ConfigCustomizerManagerInterface $configCustomizers,
$isUpdate = false
bool $isUpdate = false,
PhpExecutableFinder $phpFinder = null
) {
parent::__construct();
$this->configWriter = $configWriter;
$this->isUpdate = $isUpdate;
$this->filesystem = $filesystem;
$this->configCustomizers = $configCustomizers;
$this->phpFinder = $phpFinder ?: new PhpExecutableFinder();
}
public function configure()
protected function configure(): void
{
$this
->setName('shlink:install')
@@ -84,7 +96,7 @@ class InstallCommand extends Command
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$this->io = new SymfonyStyle($input, $output);
@@ -133,8 +145,8 @@ class InstallCommand extends Command
// If current command is not update, generate database
if (! $this->isUpdate) {
$this->io->write('Initializing database...');
if (! $this->runCommand(
'php vendor/bin/doctrine.php orm:schema-tool:create',
if (! $this->runPhpCommand(
'vendor/doctrine/orm/bin/doctrine.php orm:schema-tool:create',
'Error generating database.',
$output
)) {
@@ -144,8 +156,8 @@ class InstallCommand extends Command
// Run database migrations
$this->io->write('Updating database...');
if (! $this->runCommand(
'php vendor/bin/doctrine-migrations migrations:migrate',
if (! $this->runPhpCommand(
'vendor/doctrine/migrations/bin/doctrine-migrations.php migrations:migrate',
'Error updating database.',
$output
)) {
@@ -154,8 +166,8 @@ class InstallCommand extends Command
// Generate proxies
$this->io->write('Generating proxies...');
if (! $this->runCommand(
'php vendor/bin/doctrine.php orm:generate-proxies',
if (! $this->runPhpCommand(
'vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies',
'Error generating proxies.',
$output
)) {
@@ -213,23 +225,31 @@ class InstallCommand extends Command
* @throws LogicException
* @throws InvalidArgumentException
*/
private function runCommand($command, $errorMessage, OutputInterface $output): bool
private function runPhpCommand($command, $errorMessage, OutputInterface $output): bool
{
if ($this->processHelper === null) {
$this->processHelper = $this->getHelper('process');
}
$process = $this->processHelper->run($output, $command);
if ($this->phpBinary === null) {
$this->phpBinary = $this->phpFinder->find(false) ?: 'php';
}
$this->io->write(
' <options=bold>[Running "' . sprintf('%s %s', $this->phpBinary, $command) . '"]</> ',
false,
OutputInterface::VERBOSITY_VERBOSE
);
$process = $this->processHelper->run($output, sprintf('%s %s', $this->phpBinary, $command));
if ($process->isSuccessful()) {
$this->io->writeln(' <info>Success!</info>');
return true;
}
if ($this->io->isVerbose()) {
return false;
if (! $this->io->isVerbose()) {
$this->io->error($errorMessage . ' Run this command with -vvv to see specific error info.');
}
$this->io->error($errorMessage . ' Run this command with -vvv to see specific error info.');
return false;
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Shortcode;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
class DeleteShortCodeCommand extends Command
{
public const NAME = 'short-code:delete';
private const ALIASES = [];
/**
* @var DeleteShortUrlServiceInterface
*/
private $deleteShortUrlService;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService, TranslatorInterface $translator)
{
$this->deleteShortUrlService = $deleteShortUrlService;
$this->translator = $translator;
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription(
$this->translator->translate('Deletes a short URL')
)
->addArgument(
'shortCode',
InputArgument::REQUIRED,
$this->translator->translate('The short code to be deleted')
)
->addOption(
'ignore-threshold',
'i',
InputOption::VALUE_NONE,
$this->translator->translate(
'Ignores the safety visits threshold check, which could make short URLs with many visits to be '
. 'accidentally deleted'
)
);
}
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode');
$ignoreThreshold = $input->getOption('ignore-threshold');
try {
$this->runDelete($io, $shortCode, $ignoreThreshold);
} catch (Exception\InvalidShortCodeException $e) {
$io->error(
\sprintf($this->translator->translate('Provided short code "%s" could not be found.'), $shortCode)
);
} catch (Exception\DeleteShortUrlException $e) {
$this->retry($io, $shortCode, $e);
}
}
private function retry(SymfonyStyle $io, string $shortCode, Exception\DeleteShortUrlException $e): void
{
$warningMsg = \sprintf($this->translator->translate(
'It was not possible to delete the short URL with short code "%s" because it has more than %s visits.'
), $shortCode, $e->getVisitsThreshold());
$io->writeln('<bg=yellow>' . $warningMsg . '</>');
$forceDelete = $io->confirm($this->translator->translate('Do you want to delete it anyway?'), false);
if ($forceDelete) {
$this->runDelete($io, $shortCode, true);
} else {
$io->warning($this->translator->translate('Short URL was not deleted.'));
}
}
private function runDelete(SymfonyStyle $io, string $shortCode, bool $ignoreThreshold): void
{
$this->deleteShortUrlService->deleteByShortCode($shortCode, $ignoreThreshold);
$io->success(\sprintf(
$this->translator->translate('Short URL with short code "%s" successfully deleted.'),
$shortCode
));
}
}

View File

@@ -14,7 +14,8 @@ use Zend\I18n\Translator\TranslatorInterface;
class GeneratePreviewCommand extends Command
{
const NAME = 'shortcode:process-previews';
public const NAME = 'short-code:process-previews';
private const ALIASES = ['shortcode:process-previews'];
/**
* @var PreviewGeneratorInterface
@@ -40,17 +41,19 @@ class GeneratePreviewCommand extends Command
parent::__construct(null);
}
public function configure()
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription(
$this->translator->translate(
'Processes and generates the previews for every URL, improving performance for later web requests.'
)
);
$this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription(
$this->translator->translate(
'Processes and generates the previews for every URL, improving performance for later web requests.'
)
);
}
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$page = 1;
do {
@@ -65,7 +68,7 @@ class GeneratePreviewCommand extends Command
(new SymfonyStyle($input, $output))->success($this->translator->translate('Finished processing all URLs'));
}
protected function processUrl($url, OutputInterface $output)
private function processUrl($url, OutputInterface $output): void
{
try {
$output->write(\sprintf($this->translator->translate('Processing URL %s...'), $url));

View File

@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Shortcode;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Util\ShortUrlBuilderTrait;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
@@ -17,7 +18,10 @@ use Zend\I18n\Translator\TranslatorInterface;
class GenerateShortcodeCommand extends Command
{
const NAME = 'shortcode:generate';
use ShortUrlBuilderTrait;
public const NAME = 'short-code:generate';
private const ALIASES = ['shortcode:generate'];
/**
* @var UrlShortenerInterface
@@ -43,36 +47,38 @@ class GenerateShortcodeCommand extends Command
parent::__construct(null);
}
public function configure()
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription(
$this->translator->translate('Generates a short code for provided URL and returns the short URL')
)
->addArgument('longUrl', InputArgument::REQUIRED, $this->translator->translate('The long URL to parse'))
->addOption(
'tags',
't',
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
$this->translator->translate('Tags to apply to the new short URL')
)
->addOption('validSince', 's', InputOption::VALUE_REQUIRED, $this->translator->translate(
'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', 'u', InputOption::VALUE_REQUIRED, $this->translator->translate(
'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', 'c', InputOption::VALUE_REQUIRED, $this->translator->translate(
'If provided, this slug will be used instead of generating a short code'
))
->addOption('maxVisits', 'm', InputOption::VALUE_REQUIRED, $this->translator->translate(
'This will limit the number of visits for this short URL.'
));
$this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription(
$this->translator->translate('Generates a short code for provided URL and returns the short URL')
)
->addArgument('longUrl', InputArgument::REQUIRED, $this->translator->translate('The long URL to parse'))
->addOption(
'tags',
't',
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
$this->translator->translate('Tags to apply to the new short URL')
)
->addOption('validSince', 's', InputOption::VALUE_REQUIRED, $this->translator->translate(
'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', 'u', InputOption::VALUE_REQUIRED, $this->translator->translate(
'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', 'c', InputOption::VALUE_REQUIRED, $this->translator->translate(
'If provided, this slug will be used instead of generating a short code'
))
->addOption('maxVisits', 'm', InputOption::VALUE_REQUIRED, $this->translator->translate(
'This will limit the number of visits for this short URL.'
));
}
public function interact(InputInterface $input, OutputInterface $output)
protected function interact(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$longUrl = $input->getArgument('longUrl');
@@ -88,7 +94,7 @@ class GenerateShortcodeCommand extends Command
}
}
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$longUrl = $input->getArgument('longUrl');
@@ -115,10 +121,8 @@ class GenerateShortcodeCommand extends Command
$this->getOptionalDate($input, 'validUntil'),
$customSlug,
$maxVisits !== null ? (int) $maxVisits : null
);
$shortUrl = (new Uri())->withPath($shortCode)
->withScheme($this->domainConfig['schema'])
->withHost($this->domainConfig['hostname']);
)->getShortCode();
$shortUrl = $this->buildShortUrl($this->domainConfig, $shortCode);
$io->writeln([
\sprintf('%s <info>%s</info>', $this->translator->translate('Processed long URL:'), $longUrl),
@@ -139,7 +143,7 @@ class GenerateShortcodeCommand extends Command
}
}
private function getOptionalDate(InputInterface $input, string $fieldName)
private function getOptionalDate(InputInterface $input, string $fieldName): ?\DateTime
{
$since = $input->getOption($fieldName);
return $since !== null ? new \DateTime($since) : null;

View File

@@ -15,7 +15,8 @@ use Zend\I18n\Translator\TranslatorInterface;
class GetVisitsCommand extends Command
{
const NAME = 'shortcode:visits';
public const NAME = 'short-code:visits';
private const ALIASES = ['shortcode:visits'];
/**
* @var VisitsTrackerInterface
@@ -33,9 +34,11 @@ class GetVisitsCommand extends Command
parent::__construct();
}
public function configure()
protected function configure(): void
{
$this->setName(self::NAME)
$this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription(
$this->translator->translate('Returns the detailed visits information for provided short code')
)
@@ -58,7 +61,7 @@ class GetVisitsCommand extends Command
);
}
public function interact(InputInterface $input, OutputInterface $output)
protected function interact(InputInterface $input, OutputInterface $output): void
{
$shortCode = $input->getArgument('shortCode');
if (! empty($shortCode)) {
@@ -74,7 +77,7 @@ class GetVisitsCommand extends Command
}
}
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode');
@@ -85,20 +88,23 @@ class GetVisitsCommand extends Command
$rows = [];
foreach ($visits as $row) {
$rowData = $row->jsonSerialize();
// Unset location info
unset($rowData['visitLocation']);
// Unset location info and remote addr
unset($rowData['visitLocation'], $rowData['remoteAddr']);
$rowData['country'] = $row->getVisitLocation()->getCountryName();
$rows[] = \array_values($rowData);
}
$io->table([
$this->translator->translate('Referer'),
$this->translator->translate('Date'),
$this->translator->translate('Remote Address'),
$this->translator->translate('User agent'),
$this->translator->translate('Country'),
], $rows);
}
protected function getDateOption(InputInterface $input, $key)
private function getDateOption(InputInterface $input, $key)
{
$value = $input->getOption($key);
if (! empty($value)) {

View File

@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Shortcode;
use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@@ -17,7 +18,8 @@ class ListShortcodesCommand extends Command
{
use PaginatorUtilsTrait;
const NAME = 'shortcode:list';
public const NAME = 'short-code:list';
private const ALIASES = ['shortcode:list'];
/**
* @var ShortUrlServiceInterface
@@ -27,59 +29,69 @@ class ListShortcodesCommand extends Command
* @var TranslatorInterface
*/
private $translator;
/**
* @var array
*/
private $domainConfig;
public function __construct(ShortUrlServiceInterface $shortUrlService, TranslatorInterface $translator)
{
public function __construct(
ShortUrlServiceInterface $shortUrlService,
TranslatorInterface $translator,
array $domainConfig
) {
$this->shortUrlService = $shortUrlService;
$this->translator = $translator;
parent::__construct();
$this->domainConfig = $domainConfig;
}
public function configure()
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription($this->translator->translate('List all short URLs'))
->addOption(
'page',
'p',
InputOption::VALUE_OPTIONAL,
sprintf(
$this->translator->translate('The first page to list (%s items per page)'),
PaginableRepositoryAdapter::ITEMS_PER_PAGE
),
1
)
->addOption(
'searchTerm',
's',
InputOption::VALUE_OPTIONAL,
$this->translator->translate(
'A query used to filter results by searching for it on the longUrl and shortCode fields'
)
)
->addOption(
'tags',
't',
InputOption::VALUE_OPTIONAL,
$this->translator->translate('A comma-separated list of tags to filter results')
)
->addOption(
'orderBy',
'o',
InputOption::VALUE_OPTIONAL,
$this->translator->translate(
'The field from which we want to order by. Pass ASC or DESC separated by a comma'
)
)
->addOption(
'showTags',
null,
InputOption::VALUE_NONE,
$this->translator->translate('Whether to display the tags or not')
);
$this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription($this->translator->translate('List all short URLs'))
->addOption(
'page',
'p',
InputOption::VALUE_OPTIONAL,
sprintf(
$this->translator->translate('The first page to list (%s items per page)'),
PaginableRepositoryAdapter::ITEMS_PER_PAGE
),
1
)
->addOption(
'searchTerm',
's',
InputOption::VALUE_OPTIONAL,
$this->translator->translate(
'A query used to filter results by searching for it on the longUrl and shortCode fields'
)
)
->addOption(
'tags',
't',
InputOption::VALUE_OPTIONAL,
$this->translator->translate('A comma-separated list of tags to filter results')
)
->addOption(
'orderBy',
'o',
InputOption::VALUE_OPTIONAL,
$this->translator->translate(
'The field from which we want to order by. Pass ASC or DESC separated by a comma'
)
)
->addOption(
'showTags',
null,
InputOption::VALUE_NONE,
$this->translator->translate('Whether to display the tags or not')
);
}
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$page = (int) $input->getOption('page');
@@ -87,6 +99,7 @@ class ListShortcodesCommand extends Command
$tags = $input->getOption('tags');
$tags = ! empty($tags) ? \explode(',', $tags) : [];
$showTags = $input->getOption('showTags');
$transformer = new ShortUrlDataTransformer($this->domainConfig);
do {
$result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input));
@@ -94,7 +107,8 @@ class ListShortcodesCommand extends Command
$headers = [
$this->translator->translate('Short code'),
$this->translator->translate('Original URL'),
$this->translator->translate('Short URL'),
$this->translator->translate('Long URL'),
$this->translator->translate('Date created'),
$this->translator->translate('Visits count'),
];
@@ -104,17 +118,14 @@ class ListShortcodesCommand extends Command
$rows = [];
foreach ($result as $row) {
$shortUrl = $row->jsonSerialize();
$shortUrl = $transformer->transform($row);
if ($showTags) {
$shortUrl['tags'] = [];
foreach ($row->getTags() as $tag) {
$shortUrl['tags'][] = $tag->getName();
}
$shortUrl['tags'] = implode(', ', $shortUrl['tags']);
$shortUrl['tags'] = \implode(', ', $shortUrl['tags']);
} else {
unset($shortUrl['tags']);
}
unset($shortUrl['originalUrl']);
$rows[] = \array_values($shortUrl);
}
$io->table($headers, $rows);
@@ -138,7 +149,7 @@ class ListShortcodesCommand extends Command
return null;
}
$orderBy = explode(',', $orderBy);
return count($orderBy) === 1 ? $orderBy[0] : [$orderBy[0] => $orderBy[1]];
$orderBy = \explode(',', $orderBy);
return \count($orderBy) === 1 ? $orderBy[0] : [$orderBy[0] => $orderBy[1]];
}
}

View File

@@ -15,7 +15,8 @@ use Zend\I18n\Translator\TranslatorInterface;
class ResolveUrlCommand extends Command
{
const NAME = 'shortcode:parse';
public const NAME = 'short-code:parse';
private const ALIASES = ['shortcode:parse'];
/**
* @var UrlShortenerInterface
@@ -33,18 +34,20 @@ class ResolveUrlCommand extends Command
parent::__construct(null);
}
public function configure()
protected function configure(): void
{
$this->setName(self::NAME)
->setDescription($this->translator->translate('Returns the long URL behind a short code'))
->addArgument(
'shortCode',
InputArgument::REQUIRED,
$this->translator->translate('The short code to parse')
);
$this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription($this->translator->translate('Returns the long URL behind a short code'))
->addArgument(
'shortCode',
InputArgument::REQUIRED,
$this->translator->translate('The short code to parse')
);
}
public function interact(InputInterface $input, OutputInterface $output)
protected function interact(InputInterface $input, OutputInterface $output): void
{
$shortCode = $input->getArgument('shortCode');
if (! empty($shortCode)) {
@@ -60,14 +63,16 @@ class ResolveUrlCommand extends Command
}
}
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode');
try {
$longUrl = $this->urlShortener->shortCodeToUrl($shortCode);
$output->writeln(\sprintf('%s <info>%s</info>', $this->translator->translate('Long URL:'), $longUrl));
$url = $this->urlShortener->shortCodeToUrl($shortCode);
$output->writeln(
\sprintf('%s <info>%s</info>', $this->translator->translate('Long URL:'), $url->getLongUrl())
);
} catch (InvalidShortCodeException $e) {
$io->error(
\sprintf($this->translator->translate('Provided short code "%s" has an invalid format.'), $shortCode)

View File

@@ -5,6 +5,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\Service\IpLocationResolverInterface;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Service\VisitServiceInterface;
use Symfony\Component\Console\Command\Command;
@@ -15,8 +16,7 @@ use Zend\I18n\Translator\TranslatorInterface;
class ProcessVisitsCommand extends Command
{
const LOCALHOST = '127.0.0.1';
const NAME = 'visit:process';
public const NAME = 'visit:process';
/**
* @var VisitServiceInterface
@@ -55,16 +55,18 @@ class ProcessVisitsCommand extends Command
$io = new SymfonyStyle($input, $output);
$visits = $this->visitService->getUnlocatedVisits();
$count = 0;
foreach ($visits as $visit) {
$ipAddr = $visit->getRemoteAddr();
$io->write(sprintf('%s <info>%s</info>', $this->translator->translate('Processing IP'), $ipAddr));
if ($ipAddr === self::LOCALHOST) {
$io->write(\sprintf('%s <info>%s</info>', $this->translator->translate('Processing IP'), $ipAddr));
if ($ipAddr === IpAddress::LOCALHOST) {
$io->writeln(
sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address'))
\sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address'))
);
continue;
}
$count++;
try {
$result = $this->ipLocationResolver->resolveIpLocation($ipAddr);
@@ -73,12 +75,27 @@ class ProcessVisitsCommand extends Command
$visit->setVisitLocation($location);
$this->visitService->saveVisit($visit);
$io->writeln(sprintf(
$io->writeln(\sprintf(
' (' . $this->translator->translate('Address located at "%s"') . ')',
$location->getCityName()
));
} catch (WrongIpException $e) {
continue;
$io->writeln(
\sprintf(' <error>%s</error>', $this->translator->translate('An error occurred while locating IP'))
);
if ($io->isVerbose()) {
$this->getApplication()->renderException($e, $output);
}
}
if ($count === $this->ipLocationResolver->getApiLimit()) {
$count = 0;
$seconds = $this->ipLocationResolver->getApiInterval();
$io->note(\sprintf(
$this->translator->translate('IP location resolver limit reached. Waiting %s seconds...'),
$seconds
));
\sleep($seconds);
}
}

View File

@@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
@@ -37,7 +38,8 @@ class GenerateKeyCommandTest extends TestCase
*/
public function noExpirationDateIsDefinedIfNotProvided()
{
$this->apiKeyService->create(null)->shouldBeCalledTimes(1);
$this->apiKeyService->create(null)->shouldBeCalledTimes(1)
->willReturn(new ApiKey());
$this->commandTester->execute([
'command' => 'api-key:generate',
]);
@@ -46,9 +48,10 @@ class GenerateKeyCommandTest extends TestCase
/**
* @test
*/
public function expirationDateIsDefinedIfWhenProvided()
public function expirationDateIsDefinedIfProvided()
{
$this->apiKeyService->create(Argument::type(\DateTime::class))->shouldBeCalledTimes(1);
$this->apiKeyService->create(Argument::type(\DateTime::class))->shouldBeCalledTimes(1)
->willReturn(new ApiKey());
$this->commandTester->execute([
'command' => 'api-key:generate',
'--expirationDate' => '2016-01-01',

View File

@@ -15,6 +15,7 @@ use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
use Zend\Config\Writer\WriterInterface;
@@ -55,6 +56,9 @@ class InstallCommandTest extends TestCase
$configCustomizers = $this->prophesize(ConfigCustomizerManagerInterface::class);
$configCustomizers->get(Argument::cetera())->willReturn($configCustomizer->reveal());
$finder = $this->prophesize(PhpExecutableFinder::class);
$finder->find(false)->willReturn('php');
$app = new Application();
$helperSet = $app->getHelperSet();
$helperSet->set($processHelper->reveal());
@@ -62,7 +66,9 @@ class InstallCommandTest extends TestCase
$this->command = new InstallCommand(
$this->configWriter->reveal(),
$this->filesystem->reveal(),
$configCustomizers->reveal()
$configCustomizers->reveal(),
false,
$finder->reveal()
);
$app->add($this->command);

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Shortcode\DeleteShortCodeCommand;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class DeleteShortCodeCommandTest extends TestCase
{
/**
* @var CommandTester
*/
private $commandTester;
/**
* @var ObjectProphecy
*/
private $service;
public function setUp()
{
$this->service = $this->prophesize(DeleteShortUrlServiceInterface::class);
$command = new DeleteShortCodeCommand($this->service->reveal(), Translator::factory([]));
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function successMessageIsPrintedIfUrlIsProperlyDeleted()
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->will(function () {
});
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(\sprintf('Short URL with short code "%s" successfully deleted.', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function invalidShortCodePrintsMessage()
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
Exception\InvalidShortCodeException::class
);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(\sprintf('Provided short code "%s" could not be found.', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted()
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, Argument::type('bool'))->will(
function (array $args) {
$ignoreThreshold = \array_pop($args);
if (!$ignoreThreshold) {
throw new Exception\DeleteShortUrlException(10);
}
}
);
$this->commandTester->setInputs(['yes']);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(\sprintf(
'It was not possible to delete the short URL with short code "%s" because it has more than 10 visits.',
$shortCode
), $output);
$this->assertContains(\sprintf('Short URL with short code "%s" successfully deleted.', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledTimes(2);
}
/**
* @test
*/
public function deleteIsNotRetriedWhenThresholdIsReachedAndQuestionIsDeclined()
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
new Exception\DeleteShortUrlException(10)
);
$this->commandTester->setInputs(['no']);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(\sprintf(
'It was not possible to delete the short URL with short code "%s" because it has more than 10 visits.',
$shortCode
), $output);
$this->assertContains('Short URL was not deleted.', $output);
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
}
}

View File

@@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Shortcode\GenerateShortcodeCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Application;
@@ -41,8 +42,12 @@ class GenerateShortcodeCommandTest extends TestCase
*/
public function properShortCodeIsCreatedIfLongUrlIsCorrect()
{
$this->urlShortener->urlToShortCode(Argument::cetera())->willReturn('abc123')
->shouldBeCalledTimes(1);
$this->urlShortener->urlToShortCode(Argument::cetera())
->willReturn(
(new ShortUrl())->setShortCode('abc123')
->setLongUrl('')
)
->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'shortcode:generate',

View File

@@ -9,6 +9,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Shortcode\GetVisitsCommand;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
@@ -77,7 +78,7 @@ class GetVisitsCommandTest extends TestCase
$shortCode = 'abc123';
$this->visitsTracker->info($shortCode, Argument::any())->willReturn([
(new Visit())->setReferer('foo')
->setRemoteAddr('1.2.3.4')
->setVisitLocation((new VisitLocation())->setCountryName('Spain'))
->setUserAgent('bar'),
])->shouldBeCalledTimes(1);
@@ -86,8 +87,8 @@ class GetVisitsCommandTest extends TestCase
'shortCode' => $shortCode,
]);
$output = $this->commandTester->getDisplay();
$this->assertTrue(strpos($output, 'foo') > 0);
$this->assertTrue(strpos($output, '1.2.3.4') > 0);
$this->assertTrue(strpos($output, 'bar') > 0);
$this->assertGreaterThan(0, \strpos($output, 'foo'));
$this->assertGreaterThan(0, \strpos($output, 'Spain'));
$this->assertGreaterThan(0, \strpos($output, 'bar'));
}
}

View File

@@ -30,7 +30,7 @@ class ListShortcodesCommandTest extends TestCase
{
$this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class);
$app = new Application();
$command = new ListShortcodesCommand($this->shortUrlService->reveal(), Translator::factory([]));
$command = new ListShortcodesCommand($this->shortUrlService->reveal(), Translator::factory([]), []);
$app->add($command);
$this->commandTester = new CommandTester($command);
}
@@ -55,7 +55,7 @@ class ListShortcodesCommandTest extends TestCase
// The paginator will return more than one page for the first 3 times
$data = [];
for ($i = 0; $i < 50; $i++) {
$data[] = new ShortUrl();
$data[] = (new ShortUrl())->setLongUrl('url_' . $i);
}
$this->shortUrlService->listShortUrls(Argument::cetera())->will(function () use (&$data) {
@@ -74,7 +74,7 @@ class ListShortcodesCommandTest extends TestCase
// The paginator will return more than one page
$data = [];
for ($i = 0; $i < 30; $i++) {
$data[] = new ShortUrl();
$data[] = (new ShortUrl())->setLongUrl('url_' . $i);
}
$this->shortUrlService->listShortUrls(Argument::cetera())->willReturn(new Paginator(new ArrayAdapter($data)))

View File

@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Shortcode\ResolveUrlCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
@@ -41,7 +42,8 @@ class ResolveUrlCommandTest extends TestCase
{
$shortCode = 'abc123';
$expectedUrl = 'http://domain.com/foo/bar';
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($expectedUrl)
$shortUrl = (new ShortUrl())->setLongUrl($expectedUrl);
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($shortUrl)
->shouldBeCalledTimes(1);
$this->commandTester->execute([

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use Doctrine\Common\Collections\ArrayCollection;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
@@ -52,7 +53,7 @@ class CreateTagCommandTest extends TestCase
{
$tagNames = ['foo', 'bar'];
/** @var MethodProphecy $createTags */
$createTags = $this->tagService->createTags($tagNames)->willReturn([]);
$createTags = $this->tagService->createTags($tagNames)->willReturn(new ArrayCollection());
$this->commandTester->execute([
'--name' => $tagNames,

View File

@@ -7,7 +7,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\ProcessVisitsCommand;
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Service\VisitService;
use Symfony\Component\Console\Application;
@@ -32,7 +32,9 @@ class ProcessVisitsCommandTest extends TestCase
public function setUp()
{
$this->visitService = $this->prophesize(VisitService::class);
$this->ipResolver = $this->prophesize(IpLocationResolver::class);
$this->ipResolver = $this->prophesize(IpApiLocationResolver::class);
$this->ipResolver->getApiLimit()->willReturn(10000000000);
$command = new ProcessVisitsCommand(
$this->visitService->reveal(),
$this->ipResolver->reveal(),
@@ -65,9 +67,9 @@ class ProcessVisitsCommandTest extends TestCase
'command' => 'visit:process',
]);
$output = $this->commandTester->getDisplay();
$this->assertTrue(strpos($output, 'Processing IP 1.2.3.4') === 0);
$this->assertTrue(strpos($output, 'Processing IP 4.3.2.1') > 0);
$this->assertTrue(strpos($output, 'Processing IP 12.34.56.78') > 0);
$this->assertEquals(0, \strpos($output, 'Processing IP 1.2.3.0'));
$this->assertGreaterThan(0, \strpos($output, 'Processing IP 4.3.2.0'));
$this->assertGreaterThan(0, \strpos($output, 'Processing IP 12.34.56.0'));
}
/**
@@ -85,14 +87,51 @@ class ProcessVisitsCommandTest extends TestCase
$this->visitService->getUnlocatedVisits()->willReturn($visits)
->shouldBeCalledTimes(1);
$this->visitService->saveVisit(Argument::any())->shouldBeCalledTimes(count($visits) - 2);
$this->visitService->saveVisit(Argument::any())->shouldBeCalledTimes(\count($visits) - 2);
$this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
->shouldBeCalledTimes(count($visits) - 2);
->shouldBeCalledTimes(\count($visits) - 2);
$this->commandTester->execute([
'command' => 'visit:process',
]);
$output = $this->commandTester->getDisplay();
$this->assertTrue(strpos($output, 'Ignored localhost address') > 0);
$this->assertGreaterThan(0, \strpos($output, 'Ignored localhost address'));
}
/**
* @test
*/
public function sleepsEveryTimeTheApiLimitIsReached()
{
$visits = [
(new Visit())->setRemoteAddr('1.2.3.4'),
(new Visit())->setRemoteAddr('4.3.2.1'),
(new Visit())->setRemoteAddr('12.34.56.78'),
(new Visit())->setRemoteAddr('1.2.3.4'),
(new Visit())->setRemoteAddr('4.3.2.1'),
(new Visit())->setRemoteAddr('12.34.56.78'),
(new Visit())->setRemoteAddr('1.2.3.4'),
(new Visit())->setRemoteAddr('4.3.2.1'),
(new Visit())->setRemoteAddr('12.34.56.78'),
(new Visit())->setRemoteAddr('4.3.2.1'),
];
$apiLimit = 3;
$this->visitService->getUnlocatedVisits()->willReturn($visits);
$this->visitService->saveVisit(Argument::any())->will(function () {
});
$getApiLimit = $this->ipResolver->getApiLimit()->willReturn($apiLimit);
$getApiInterval = $this->ipResolver->getApiInterval()->willReturn(0);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
->shouldBeCalledTimes(count($visits));
$this->commandTester->execute([
'command' => 'visit:process',
]);
$getApiLimit->shouldHaveBeenCalledTimes(\count($visits));
$getApiInterval->shouldHaveBeenCalledTimes(\round(\count($visits) / $apiLimit));
$resolveIpLocation->shouldHaveBeenCalledTimes(\count($visits));
}
}

View File

@@ -32,7 +32,7 @@ return [
Image\ImageBuilder::class => Image\ImageBuilderFactory::class,
Service\IpLocationResolver::class => ConfigAbstractFactory::class,
Service\IpApiLocationResolver::class => ConfigAbstractFactory::class,
Service\PreviewGenerator::class => ConfigAbstractFactory::class,
],
'aliases' => [
@@ -51,7 +51,7 @@ return [
ConfigAbstractFactory::class => [
TranslatorExtension::class => ['translator'],
LocaleMiddleware::class => ['translator'],
Service\IpLocationResolver::class => ['httpClient'],
Service\IpApiLocationResolver::class => ['httpClient'],
Service\PreviewGenerator::class => [
ImageBuilder::class,
Filesystem::class,

View File

@@ -3,6 +3,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common;
use function getenv;
use function strtolower;
use function trim;
/**
* Gets the value of an environment variable. Supports boolean, empty and null.
* This is basically Laravel's env helper

View File

@@ -8,26 +8,19 @@ use Doctrine\ORM\Mapping as ORM;
abstract class AbstractEntity
{
/**
* @var int
* @var string
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
* @ORM\Column(name="id", type="bigint", options={"unsigned"=true})
*/
protected $id;
/**
* @return int
*/
public function getId()
public function getId(): string
{
return $this->id;
}
/**
* @param int $id
* @return $this
*/
public function setId($id)
public function setId(string $id): self
{
$this->id = $id;
return $this;

View File

@@ -5,8 +5,8 @@ namespace Shlinkio\Shlink\Common\Exception;
class WrongIpException extends RuntimeException
{
public static function fromIpAddress($ipAddress, \Throwable $prev = null)
public static function fromIpAddress($ipAddress, \Throwable $prev = null): self
{
return new self(sprintf('Provided IP "%s" is invalid', $ipAddress), 0, $prev);
return new self(\sprintf('Provided IP "%s" is invalid', $ipAddress), 0, $prev);
}
}

View File

@@ -27,6 +27,8 @@ class EmptyResponseImplicitOptionsMiddlewareFactory implements FactoryInterface
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
return new ImplicitOptionsMiddleware(new EmptyResponse());
return new ImplicitOptionsMiddleware(function () {
return new EmptyResponse();
});
}
}

View File

@@ -23,8 +23,7 @@ class EntityManagerFactory implements FactoryInterface
* @param null|array $options
* @return object
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ServiceNotCreatedException if an exception is raised when creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
@@ -32,14 +31,14 @@ class EntityManagerFactory implements FactoryInterface
$globalConfig = $container->get('config');
$isDevMode = isset($globalConfig['debug']) ? ((bool) $globalConfig['debug']) : false;
$cache = $container->has(Cache::class) ? $container->get(Cache::class) : new ArrayCache();
$emConfig = isset($globalConfig['entity_manager']) ? $globalConfig['entity_manager'] : [];
$connecitonConfig = isset($emConfig['connection']) ? $emConfig['connection'] : [];
$ormConfig = isset($emConfig['orm']) ? $emConfig['orm'] : [];
$emConfig = $globalConfig['entity_manager'] ?? [];
$connectionConfig = $emConfig['connection'] ?? [];
$ormConfig = $emConfig['orm'] ?? [];
return EntityManager::create($connecitonConfig, Setup::createAnnotationMetadataConfiguration(
isset($ormConfig['entities_paths']) ? $ormConfig['entities_paths'] : [],
return EntityManager::create($connectionConfig, Setup::createAnnotationMetadataConfiguration(
$ormConfig['entities_paths'] ?? [],
$isDevMode,
isset($ormConfig['proxies_dir']) ? $ormConfig['proxies_dir'] : null,
$ormConfig['proxies_dir'] ?? null,
$cache,
false
));

View File

@@ -3,10 +3,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Middleware;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Interop\Http\ServerMiddleware\MiddlewareInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as DelegateInterface;
use Zend\I18n\Translator\Translator;
class LocaleMiddleware implements MiddlewareInterface
@@ -32,15 +32,15 @@ class LocaleMiddleware implements MiddlewareInterface
*
* @return Response
*/
public function process(Request $request, DelegateInterface $delegate)
public function process(Request $request, DelegateInterface $delegate): Response
{
if (! $request->hasHeader('Accept-Language')) {
return $delegate->process($request);
return $delegate->handle($request);
}
$locale = $request->getHeaderLine('Accept-Language');
$this->translator->setLocale($this->normalizeLocale($locale));
return $delegate->process($request);
return $delegate->handle($request);
}
/**

View File

@@ -8,7 +8,7 @@ use Zend\Paginator\Adapter\AdapterInterface;
class PaginableRepositoryAdapter implements AdapterInterface
{
const ITEMS_PER_PAGE = 10;
public const ITEMS_PER_PAGE = 10;
/**
* @var PaginableRepositoryInterface
@@ -34,7 +34,7 @@ class PaginableRepositoryAdapter implements AdapterInterface
$orderBy = null
) {
$this->paginableRepository = $paginableRepository;
$this->searchTerm = $searchTerm !== null ? trim(strip_tags($searchTerm)) : null;
$this->searchTerm = $searchTerm !== null ? \trim(\strip_tags($searchTerm)) : null;
$this->orderBy = $orderBy;
$this->tags = $tags;
}
@@ -46,7 +46,7 @@ class PaginableRepositoryAdapter implements AdapterInterface
* @param int $itemCountPerPage Number of items per page
* @return array
*/
public function getItems($offset, $itemCountPerPage)
public function getItems($offset, $itemCountPerPage): array
{
return $this->paginableRepository->findList(
$itemCountPerPage,
@@ -66,7 +66,7 @@ class PaginableRepositoryAdapter implements AdapterInterface
* The return value is cast to an integer.
* @since 5.1.0
*/
public function count()
public function count(): int
{
return $this->paginableRepository->countList($this->searchTerm, $this->tags);
}

View File

@@ -3,29 +3,38 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Paginator\Util;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Zend\Paginator\Paginator;
use Zend\Stdlib\ArrayUtils;
trait PaginatorUtilsTrait
{
protected function serializePaginator(Paginator $paginator)
private function serializePaginator(Paginator $paginator, ?DataTransformerInterface $transformer = null): array
{
return [
'data' => ArrayUtils::iteratorToArray($paginator->getCurrentItems()),
'data' => $this->serializeItems(ArrayUtils::iteratorToArray($paginator->getCurrentItems()), $transformer),
'pagination' => [
'currentPage' => $paginator->getCurrentPageNumber(),
'pagesCount' => $paginator->count(),
'itemsPerPage' => $paginator->getItemCountPerPage(),
'itemsInCurrentPage' => $paginator->getCurrentItemCount(),
'totalItems' => $paginator->getTotalItemCount(),
],
];
}
private function serializeItems(array $items, ?DataTransformerInterface $transformer = null): array
{
return $transformer === null ? $items : \array_map([$transformer, 'transform'], $items);
}
/**
* Checks if provided paginator is in last page
*
* @param Paginator $paginator
* @return bool
*/
protected function isLastPage(Paginator $paginator)
private function isLastPage(Paginator $paginator): bool
{
return $paginator->getCurrentPageNumber() >= $paginator->count();
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Response;
use Psr\Http\Message\StreamInterface;
use Zend\Diactoros\Response;
use Zend\Diactoros\Stream;
class PixelResponse extends Response
{
private const BASE_64_IMAGE = 'R0lGODlhAQABAJAAAP8AAAAAACH5BAUQAAAALAAAAAABAAEAAAICBAEAOw==';
private const CONTENT_TYPE = 'image/gif';
public function __construct(int $status = 200, array $headers = [])
{
$headers['content-type'] = self::CONTENT_TYPE;
parent::__construct($this->createBody(), $status, $headers);
}
/**
* Create the message body.
*
* @return StreamInterface
*/
private function createBody(): StreamInterface
{
$body = new Stream('php://temp', 'wb+');
$body->write((string) \base64_decode(self::BASE_64_IMAGE));
$body->rewind();
return $body;
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Rest;
interface DataTransformerInterface
{
public function transform($value): array;
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Service;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
class IpApiLocationResolver implements IpLocationResolverInterface
{
private const SERVICE_PATTERN = 'http://ip-api.com/json/%s';
/**
* @var Client
*/
private $httpClient;
public function __construct(Client $httpClient)
{
$this->httpClient = $httpClient;
}
/**
* @param string $ipAddress
* @return array
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array
{
try {
$response = $this->httpClient->get(\sprintf(self::SERVICE_PATTERN, $ipAddress));
return $this->mapFields(\json_decode((string) $response->getBody(), true));
} catch (GuzzleException $e) {
throw WrongIpException::fromIpAddress($ipAddress, $e);
}
}
private function mapFields(array $entry): array
{
return [
'country_code' => $entry['countryCode'] ?? '',
'country_name' => $entry['country'] ?? '',
'region_name' => $entry['regionName'] ?? '',
'city' => $entry['city'] ?? '',
'latitude' => $entry['lat'] ?? '',
'longitude' => $entry['lon'] ?? '',
'time_zone' => $entry['timezone'] ?? '',
];
}
/**
* Returns the interval in seconds that needs to be waited when the API limit is reached
*
* @return int
*/
public function getApiInterval(): int
{
return 65; // ip-api interval is 1 minute. Return 5 extra seconds just in case
}
/**
* Returns the limit of requests that can be performed to the API in a specific interval, or null if no limit exists
*
* @return int|null
*/
public function getApiLimit(): ?int
{
return 145; // ip-api limit is 150 requests per minute. Leave 5 less requests just in case
}
}

View File

@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Service;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
class IpLocationResolver implements IpLocationResolverInterface
{
const SERVICE_PATTERN = 'http://freegeoip.net/json/%s';
/**
* @var Client
*/
private $httpClient;
public function __construct(Client $httpClient)
{
$this->httpClient = $httpClient;
}
/**
* @param string $ipAddress
* @return array
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array
{
try {
$response = $this->httpClient->get(sprintf(self::SERVICE_PATTERN, $ipAddress));
return json_decode((string) $response->getBody(), true);
} catch (GuzzleException $e) {
throw WrongIpException::fromIpAddress($ipAddress, $e);
}
}
}

View File

@@ -13,4 +13,18 @@ interface IpLocationResolverInterface
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array;
/**
* Returns the interval in seconds that needs to be waited when the API limit is reached
*
* @return int
*/
public function getApiInterval(): int;
/**
* Returns the limit of requests that can be performed to the API in a specific interval, or null if no limit exists
*
* @return int|null
*/
public function getApiLimit(): ?int;
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Util;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
final class IpAddress
{
private const IPV4_PARTS_COUNT = 4;
private const OBFUSCATED_OCTET = '0';
public const LOCALHOST = '127.0.0.1';
/**
* @var string
*/
private $firstOctet;
/**
* @var string
*/
private $secondOctet;
/**
* @var string
*/
private $thirdOctet;
/**
* @var string
*/
private $fourthOctet;
private function __construct(string $firstOctet, string $secondOctet, string $thirdOctet, string $fourthOctet)
{
$this->firstOctet = $firstOctet;
$this->secondOctet = $secondOctet;
$this->thirdOctet = $thirdOctet;
$this->fourthOctet = $fourthOctet;
}
/**
* @param string $address
* @return IpAddress
* @throws WrongIpException
*/
public static function fromString(string $address): self
{
$address = \trim($address);
$parts = \explode('.', $address);
if (\count($parts) !== self::IPV4_PARTS_COUNT) {
throw WrongIpException::fromIpAddress($address);
}
return new self(...$parts);
}
public function getObfuscatedCopy(): self
{
return new self(
$this->firstOctet,
$this->secondOctet,
$this->thirdOctet,
self::OBFUSCATED_OCTET
);
}
public function __toString(): string
{
return \implode('.', [
$this->firstOctet,
$this->secondOctet,
$this->thirdOctet,
$this->fourthOctet,
]);
}
}

View File

@@ -3,40 +3,17 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\DbUnit;
use Doctrine\DBAL\Driver\PDOConnection;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\DbUnit\Database\Connection as DbConn;
use PHPUnit\DbUnit\DataSet\IDataSet as DataSet;
use PHPUnit\DbUnit\TestCase;
use PHPUnit\Framework\TestCase;
abstract class DatabaseTestCase extends TestCase
{
const ENTITIES_TO_EMPTY = [];
protected const ENTITIES_TO_EMPTY = [];
/**
* @var EntityManagerInterface
*/
public static $em;
/**
* @var DbConn
*/
private static $conn;
public function getConnection(): DbConn
{
if (isset(self::$conn)) {
return self::$conn;
}
/** @var PDOConnection $pdo */
$pdo = static::$em->getConnection()->getWrappedConnection();
return self::$conn = $this->createDefaultDBConnection($pdo, static::$em->getConnection()->getDatabase());
}
public function getDataSet(): DataSet
{
return $this->createArrayDataSet([]);
}
protected function getEntityManager(): EntityManagerInterface
{

View File

@@ -38,8 +38,8 @@ class EmptyResponseImplicitOptionsMiddlewareFactoryTest extends TestCase
$instance = $this->factory->__invoke(new ServiceManager(), '');
$ref = new \ReflectionObject($instance);
$prop = $ref->getProperty('response');
$prop = $ref->getProperty('responseFactory');
$prop->setAccessible(true);
$this->assertInstanceOf(EmptyResponse::class, $prop->getValue($instance));
$this->assertInstanceOf(EmptyResponse::class, $prop->getValue($instance)());
}
}

View File

@@ -32,7 +32,7 @@ class LocaleMiddlewareTest extends TestCase
public function whenNoHeaderIsPresentLocaleIsNotChanged()
{
$this->assertEquals('ru', $this->translator->getLocale());
$this->middleware->process(ServerRequestFactory::fromGlobals(), TestUtils::createDelegateMock()->reveal());
$this->middleware->process(ServerRequestFactory::fromGlobals(), TestUtils::createReqHandlerMock()->reveal());
$this->assertEquals('ru', $this->translator->getLocale());
}
@@ -43,7 +43,7 @@ class LocaleMiddlewareTest extends TestCase
{
$this->assertEquals('ru', $this->translator->getLocale());
$request = ServerRequestFactory::fromGlobals()->withHeader('Accept-Language', 'es');
$this->middleware->process($request, TestUtils::createDelegateMock()->reveal());
$this->middleware->process($request, TestUtils::createReqHandlerMock()->reveal());
$this->assertEquals('es', $this->translator->getLocale());
}
@@ -52,7 +52,7 @@ class LocaleMiddlewareTest extends TestCase
*/
public function localeGetsNormalized()
{
$delegate = TestUtils::createDelegateMock();
$delegate = TestUtils::createReqHandlerMock();
$this->assertEquals('ru', $this->translator->getLocale());

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Service;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
class IpApiLocationResolverTest extends TestCase
{
/**
* @var IpApiLocationResolver
*/
protected $ipResolver;
/**
* @var ObjectProphecy
*/
protected $client;
public function setUp()
{
$this->client = $this->prophesize(Client::class);
$this->ipResolver = new IpApiLocationResolver($this->client->reveal());
}
/**
* @test
*/
public function correctIpReturnsDecodedInfo()
{
$actual = [
'countryCode' => 'bar',
'lat' => 5,
'lon' => 10,
];
$expected = [
'country_code' => 'bar',
'country_name' => '',
'region_name' => '',
'city' => '',
'latitude' => 5,
'longitude' => 10,
'time_zone' => '',
];
$response = new Response();
$response->getBody()->write(\json_encode($actual));
$response->getBody()->rewind();
$this->client->get('http://ip-api.com/json/1.2.3.4')->willReturn($response)
->shouldBeCalledTimes(1);
$this->assertEquals($expected, $this->ipResolver->resolveIpLocation('1.2.3.4'));
}
/**
* @test
* @expectedException \Shlinkio\Shlink\Common\Exception\WrongIpException
*/
public function guzzleExceptionThrowsShlinkException()
{
$this->client->get('http://ip-api.com/json/1.2.3.4')->willThrow(new TransferException())
->shouldBeCalledTimes(1);
$this->ipResolver->resolveIpLocation('1.2.3.4');
}
/**
* @test
*/
public function getApiIntervalReturnsExpectedValue()
{
$this->assertEquals(65, $this->ipResolver->getApiInterval());
}
/**
* @test
*/
public function getApiLimitReturnsExpectedValue()
{
$this->assertEquals(145, $this->ipResolver->getApiLimit());
}
}

View File

@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Service;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
class IpLocationResolverTest extends TestCase
{
/**
* @var IpLocationResolver
*/
protected $ipResolver;
/**
* @var ObjectProphecy
*/
protected $client;
public function setUp()
{
$this->client = $this->prophesize(Client::class);
$this->ipResolver = new IpLocationResolver($this->client->reveal());
}
/**
* @test
*/
public function correctIpReturnsDecodedInfo()
{
$expected = [
'foo' => 'bar',
'baz' => 'foo',
];
$response = new Response();
$response->getBody()->write(json_encode($expected));
$response->getBody()->rewind();
$this->client->get('http://freegeoip.net/json/1.2.3.4')->willReturn($response)
->shouldBeCalledTimes(1);
$this->assertEquals($expected, $this->ipResolver->resolveIpLocation('1.2.3.4'));
}
/**
* @test
* @expectedException \Shlinkio\Shlink\Common\Exception\WrongIpException
*/
public function guzzleExceptionThrowsShlinkException()
{
$this->client->get('http://freegeoip.net/json/1.2.3.4')->willThrow(new TransferException())
->shouldBeCalledTimes(1);
$this->ipResolver->resolveIpLocation('1.2.3.4');
}
}

View File

@@ -3,22 +3,22 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Util;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Prophecy\Argument;
use Prophecy\Prophet;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Zend\Diactoros\Response;
class TestUtils
{
private static $prophet;
public static function createDelegateMock(ResponseInterface $response = null, RequestInterface $request = null)
public static function createReqHandlerMock(ResponseInterface $response = null, RequestInterface $request = null)
{
$argument = $request ?: Argument::any();
$delegate = static::getProphet()->prophesize(DelegateInterface::class);
$delegate->process($argument)->willReturn($response ?: new Response());
$delegate = static::getProphet()->prophesize(RequestHandlerInterface::class);
$delegate->handle($argument)->willReturn($response ?: new Response());
return $delegate;
}

View File

@@ -6,7 +6,7 @@ use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Core\Action;
use Shlinkio\Shlink\Core\Middleware;
use Shlinkio\Shlink\Core\Options;
use Shlinkio\Shlink\Core\Response\NotFoundDelegate;
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
use Shlinkio\Shlink\Core\Service;
use Zend\Expressive\Router\RouterInterface;
use Zend\Expressive\Template\TemplateRendererInterface;
@@ -17,7 +17,8 @@ return [
'dependencies' => [
'factories' => [
Options\AppOptions::class => Options\AppOptionsFactory::class,
NotFoundDelegate::class => ConfigAbstractFactory::class,
Options\DeleteShortUrlsOptions::class => Options\DeleteShortUrlsOptionsFactory::class,
NotFoundHandler::class => ConfigAbstractFactory::class,
// Services
Service\UrlShortener::class => ConfigAbstractFactory::class,
@@ -25,27 +26,24 @@ return [
Service\ShortUrlService::class => ConfigAbstractFactory::class,
Service\VisitService::class => ConfigAbstractFactory::class,
Service\Tag\TagService::class => ConfigAbstractFactory::class,
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
// Middleware
Action\RedirectAction::class => ConfigAbstractFactory::class,
Action\PixelAction::class => ConfigAbstractFactory::class,
Action\QrCodeAction::class => ConfigAbstractFactory::class,
Action\PreviewAction::class => ConfigAbstractFactory::class,
Middleware\QrCodeCacheMiddleware::class => ConfigAbstractFactory::class,
],
'aliases' => [
'Zend\Expressive\Delegate\DefaultDelegate' => NotFoundDelegate::class,
],
],
ConfigAbstractFactory::class => [
NotFoundDelegate::class => [TemplateRendererInterface::class],
NotFoundHandler::class => [TemplateRendererInterface::class],
// Services
Service\UrlShortener::class => [
'httpClient',
'em',
Cache::class,
'config.url_shortener.validate_url',
'config.url_shortener.shortcode_chars',
],
@@ -53,15 +51,23 @@ return [
Service\ShortUrlService::class => ['em'],
Service\VisitService::class => ['em'],
Service\Tag\TagService::class => ['em'],
Service\ShortUrl\DeleteShortUrlService::class => ['em', Options\DeleteShortUrlsOptions::class],
// Middleware
Action\RedirectAction::class => [
Service\UrlShortener::class,
Service\VisitsTracker::class,
Options\AppOptions::class,
'Logger_Shlink',
],
Action\PixelAction::class => [
Service\UrlShortener::class,
Service\VisitsTracker::class,
Options\AppOptions::class,
'Logger_Shlink',
],
Action\QrCodeAction::class => [RouterInterface::class, Service\UrlShortener::class, 'Logger_Shlink'],
Action\PreviewAction::class => [PreviewGenerator::class, Service\UrlShortener::class],
Action\PreviewAction::class => [PreviewGenerator::class, Service\UrlShortener::class, 'Logger_Shlink'],
Middleware\QrCodeCacheMiddleware::class => [Cache::class],
],

View File

@@ -13,6 +13,12 @@ return [
'middleware' => Action\RedirectAction::class,
'allowed_methods' => ['GET'],
],
[
'name' => 'pixel-tracking',
'path' => '/{shortCode}/track',
'middleware' => Action\PixelAction::class,
'allowed_methods' => ['GET'],
],
[
'name' => 'short-url-qr-code',
'path' => '/{shortCode}/qr-code[/{size:[0-9]+}]',

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Core\Action\Util\ErrorResponseBuilderTrait;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
abstract class AbstractTrackingAction implements MiddlewareInterface
{
use ErrorResponseBuilderTrait;
/**
* @var UrlShortenerInterface
*/
private $urlShortener;
/**
* @var VisitsTrackerInterface
*/
private $visitTracker;
/**
* @var AppOptions
*/
private $appOptions;
/**
* @var LoggerInterface
*/
private $logger;
public function __construct(
UrlShortenerInterface $urlShortener,
VisitsTrackerInterface $visitTracker,
AppOptions $appOptions,
LoggerInterface $logger = null
) {
$this->urlShortener = $urlShortener;
$this->visitTracker = $visitTracker;
$this->appOptions = $appOptions;
$this->logger = $logger ?: new NullLogger();
}
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
* @param ServerRequestInterface $request
* @param RequestHandlerInterface $handler
*
* @return ResponseInterface
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$shortCode = $request->getAttribute('shortCode', '');
$query = $request->getQueryParams();
$disableTrackParam = $this->appOptions->getDisableTrackParam();
try {
$url = $this->urlShortener->shortCodeToUrl($shortCode);
// Track visit to this short code
if ($disableTrackParam === null || ! \array_key_exists($disableTrackParam, $query)) {
$this->visitTracker->track($shortCode, $request);
}
return $this->createResp($url->getLongUrl());
} catch (InvalidShortCodeException | EntityDoesNotExistException $e) {
$this->logger->warning('An error occurred while tracking short code.' . PHP_EOL . $e);
return $this->buildErrorResponse($request, $handler);
}
}
abstract protected function createResp(string $longUrl): ResponseInterface;
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Psr\Http\Message\ResponseInterface;
use Shlinkio\Shlink\Common\Response\PixelResponse;
class PixelAction extends AbstractTrackingAction
{
protected function createResp(string $longUrl): ResponseInterface
{
return new PixelResponse();
}
}

View File

@@ -3,10 +3,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Interop\Http\ServerMiddleware\MiddlewareInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
use Shlinkio\Shlink\Common\Service\PreviewGeneratorInterface;
use Shlinkio\Shlink\Common\Util\ResponseUtilsTrait;
@@ -28,11 +30,19 @@ class PreviewAction implements MiddlewareInterface
* @var UrlShortenerInterface
*/
private $urlShortener;
/**
* @var LoggerInterface
*/
private $logger;
public function __construct(PreviewGeneratorInterface $previewGenerator, UrlShortenerInterface $urlShortener)
{
public function __construct(
PreviewGeneratorInterface $previewGenerator,
UrlShortenerInterface $urlShortener,
LoggerInterface $logger = null
) {
$this->previewGenerator = $previewGenerator;
$this->urlShortener = $urlShortener;
$this->logger = $logger ?: new NullLogger();
}
/**
@@ -40,24 +50,21 @@ class PreviewAction implements MiddlewareInterface
* to the next middleware component to create the response.
*
* @param Request $request
* @param DelegateInterface $delegate
* @param RequestHandlerInterface $handler
*
* @return Response
*/
public function process(Request $request, DelegateInterface $delegate)
public function process(Request $request, RequestHandlerInterface $handler): Response
{
$shortCode = $request->getAttribute('shortCode');
try {
$url = $this->urlShortener->shortCodeToUrl($shortCode);
$imagePath = $this->previewGenerator->generatePreview($url);
$imagePath = $this->previewGenerator->generatePreview($url->getLongUrl());
return $this->generateImageResponse($imagePath);
} catch (InvalidShortCodeException $e) {
return $this->buildErrorResponse($request, $delegate);
} catch (EntityDoesNotExistException $e) {
return $this->buildErrorResponse($request, $delegate);
} catch (PreviewGenerationException $e) {
return $this->buildErrorResponse($request, $delegate);
} catch (InvalidShortCodeException | EntityDoesNotExistException | PreviewGenerationException $e) {
$this->logger->warning('An error occurred while generating preview image.' . PHP_EOL . $e);
return $this->buildErrorResponse($request, $handler);
}
}
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Endroid\QrCode\QrCode;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Interop\Http\ServerMiddleware\MiddlewareInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
@@ -15,12 +15,17 @@ use Shlinkio\Shlink\Core\Action\Util\ErrorResponseBuilderTrait;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Zend\Expressive\Router\Exception\RuntimeException;
use Zend\Expressive\Router\RouterInterface;
class QrCodeAction implements MiddlewareInterface
{
use ErrorResponseBuilderTrait;
private const DEFAULT_SIZE = 300;
private const MIN_SIZE = 50;
private const MAX_SIZE = 1000;
/**
* @var RouterInterface
*/
@@ -49,22 +54,21 @@ class QrCodeAction implements MiddlewareInterface
* to the next middleware component to create the response.
*
* @param Request $request
* @param DelegateInterface $delegate
* @param RequestHandlerInterface $handler
*
* @return Response
* @throws \InvalidArgumentException
* @throws RuntimeException
*/
public function process(Request $request, DelegateInterface $delegate)
public function process(Request $request, RequestHandlerInterface $handler): Response
{
// Make sure the short URL exists for this short code
$shortCode = $request->getAttribute('shortCode');
try {
$this->urlShortener->shortCodeToUrl($shortCode);
} catch (InvalidShortCodeException $e) {
$this->logger->warning('Tried to create a QR code with an invalid short code' . PHP_EOL . $e);
return $this->buildErrorResponse($request, $delegate);
} catch (EntityDoesNotExistException $e) {
$this->logger->warning('Tried to create a QR code with a not found short code' . PHP_EOL . $e);
return $this->buildErrorResponse($request, $delegate);
} catch (InvalidShortCodeException | EntityDoesNotExistException $e) {
$this->logger->warning('An error occurred while creating QR code' . PHP_EOL . $e);
return $this->buildErrorResponse($request, $handler);
}
$path = $this->router->generateUri('long-url-redirect', ['shortCode' => $shortCode]);
@@ -80,13 +84,13 @@ class QrCodeAction implements MiddlewareInterface
* @param Request $request
* @return int
*/
protected function getSizeParam(Request $request)
private function getSizeParam(Request $request): int
{
$size = (int) $request->getAttribute('size', 300);
if ($size < 50) {
return 50;
$size = (int) $request->getAttribute('size', self::DEFAULT_SIZE);
if ($size < self::MIN_SIZE) {
return self::MIN_SIZE;
}
return $size > 1000 ? 1000 : $size;
return $size > self::MAX_SIZE ? self::MAX_SIZE : $size;
}
}

View File

@@ -3,75 +3,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Interop\Http\ServerMiddleware\MiddlewareInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Core\Action\Util\ErrorResponseBuilderTrait;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Zend\Diactoros\Response\RedirectResponse;
class RedirectAction implements MiddlewareInterface
class RedirectAction extends AbstractTrackingAction
{
use ErrorResponseBuilderTrait;
/**
* @var UrlShortenerInterface
*/
private $urlShortener;
/**
* @var VisitsTrackerInterface
*/
private $visitTracker;
/**
* @var AppOptions
*/
private $appOptions;
public function __construct(
UrlShortenerInterface $urlShortener,
VisitsTrackerInterface $visitTracker,
AppOptions $appOptions
) {
$this->urlShortener = $urlShortener;
$this->visitTracker = $visitTracker;
$this->appOptions = $appOptions;
}
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
* @param Request $request
* @param DelegateInterface $delegate
*
* @return Response
*/
public function process(Request $request, DelegateInterface $delegate)
protected function createResp(string $longUrl): Response
{
$shortCode = $request->getAttribute('shortCode', '');
$query = $request->getQueryParams();
$disableTrackParam = $this->appOptions->getDisableTrackParam();
try {
$longUrl = $this->urlShortener->shortCodeToUrl($shortCode);
// Track visit to this short code
if ($disableTrackParam === null || ! \array_key_exists($disableTrackParam, $query)) {
$this->visitTracker->track($shortCode, $request);
}
// Return a redirect response to the long URL.
// Use a temporary redirect to make sure browsers always hit the server for analytics purposes
return new RedirectResponse($longUrl);
} catch (InvalidShortCodeException $e) {
return $this->buildErrorResponse($request, $delegate);
} catch (EntityDoesNotExistException $e) {
return $this->buildErrorResponse($request, $delegate);
}
// Return a redirect response to the long URL.
// Use a temporary redirect to make sure browsers always hit the server for analytics purposes
return new RedirectResponse($longUrl);
}
}

View File

@@ -3,16 +3,18 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action\Util;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Response\NotFoundDelegate;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
trait ErrorResponseBuilderTrait
{
private function buildErrorResponse(ServerRequestInterface $request, DelegateInterface $delegate): ResponseInterface
{
$request = $request->withAttribute(NotFoundDelegate::NOT_FOUND_TEMPLATE, 'ShlinkCore::invalid-short-code');
return $delegate->process($request);
private function buildErrorResponse(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$request = $request->withAttribute(NotFoundHandler::NOT_FOUND_TEMPLATE, 'ShlinkCore::invalid-short-code');
return $handler->handle($request);
}
}

View File

@@ -7,43 +7,44 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
/**
* Class ShortUrl
* @author
* @link
*
* @ORM\Entity(repositoryClass="Shlinkio\Shlink\Core\Repository\ShortUrlRepository")
* @ORM\Entity(repositoryClass=ShortUrlRepository::class)
* @ORM\Table(name="short_urls")
*/
class ShortUrl extends AbstractEntity implements \JsonSerializable
class ShortUrl extends AbstractEntity
{
/**
* @var string
* @ORM\Column(name="original_url", type="string", nullable=false, length=1024)
*/
protected $originalUrl;
private $originalUrl;
/**
* @var string
* @ORM\Column(
* name="short_code",
* type="string",
* nullable=false,
* length=10,
* length=255,
* unique=true
* )
*/
protected $shortCode;
private $shortCode;
/**
* @var \DateTime
* @ORM\Column(name="date_created", type="datetime")
*/
protected $dateCreated;
private $dateCreated;
/**
* @var Collection|Visit[]
* @ORM\OneToMany(targetEntity=Visit::class, mappedBy="shortUrl", fetch="EXTRA_LAZY")
*/
protected $visits;
private $visits;
/**
* @var Collection|Tag[]
* @ORM\ManyToMany(targetEntity=Tag::class, cascade={"persist"})
@@ -53,83 +54,75 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
* @ORM\JoinColumn(name="tag_id", referencedColumnName="id")
* })
*/
protected $tags;
private $tags;
/**
* @var \DateTime
* @ORM\Column(name="valid_since", type="datetime", nullable=true)
*/
protected $validSince;
private $validSince;
/**
* @var \DateTime
* @ORM\Column(name="valid_until", type="datetime", nullable=true)
*/
protected $validUntil;
private $validUntil;
/**
* @var integer
* @ORM\Column(name="max_visits", type="integer", nullable=true)
*/
protected $maxVisits;
private $maxVisits;
/**
* ShortUrl constructor.
*/
public function __construct()
{
$this->shortCode = '';
$this->dateCreated = new \DateTime();
$this->visits = new ArrayCollection();
$this->shortCode = '';
$this->tags = new ArrayCollection();
}
/**
* @return string
*/
public function getOriginalUrl(): string
public function getLongUrl(): string
{
return $this->originalUrl;
}
/**
* @param string $originalUrl
* @return $this
*/
public function setOriginalUrl(string $originalUrl)
public function setLongUrl(string $longUrl): self
{
$this->originalUrl = $originalUrl;
$this->originalUrl = $longUrl;
return $this;
}
/**
* @return string
* @deprecated Use getLongUrl() instead
*/
public function getOriginalUrl(): string
{
return $this->getLongUrl();
}
/**
* @deprecated Use setLongUrl() instead
*/
public function setOriginalUrl(string $originalUrl): self
{
return $this->setLongUrl($originalUrl);
}
public function getShortCode(): string
{
return $this->shortCode;
}
/**
* @param string $shortCode
* @return $this
*/
public function setShortCode(string $shortCode)
public function setShortCode(string $shortCode): self
{
$this->shortCode = $shortCode;
return $this;
}
/**
* @return \DateTime
*/
public function getDateCreated(): \DateTime
{
return $this->dateCreated;
}
/**
* @param \DateTime $dateCreated
* @return $this
*/
public function setDateCreated(\DateTime $dateCreated)
public function setDateCreated(\DateTime $dateCreated): self
{
$this->dateCreated = $dateCreated;
return $this;
@@ -145,55 +138,36 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
/**
* @param Collection|Tag[] $tags
* @return $this
*/
public function setTags(Collection $tags)
public function setTags(Collection $tags): self
{
$this->tags = $tags;
return $this;
}
/**
* @param Tag $tag
* @return $this
*/
public function addTag(Tag $tag)
public function addTag(Tag $tag): self
{
$this->tags->add($tag);
return $this;
}
/**
* @return \DateTime|null
*/
public function getValidSince()
public function getValidSince(): ?\DateTime
{
return $this->validSince;
}
/**
* @param \DateTime|null $validSince
* @return $this|self
*/
public function setValidSince($validSince): self
public function setValidSince(?\DateTime $validSince): self
{
$this->validSince = $validSince;
return $this;
}
/**
* @return \DateTime|null
*/
public function getValidUntil()
public function getValidUntil(): ?\DateTime
{
return $this->validUntil;
}
/**
* @param \DateTime|null $validUntil
* @return $this|self
*/
public function setValidUntil($validUntil): self
public function setValidUntil(?\DateTime $validUntil): self
{
$this->validUntil = $validUntil;
return $this;
@@ -201,11 +175,11 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
public function getVisitsCount(): int
{
return count($this->visits);
return \count($this->visits);
}
/**
* @param Collection $visits
* @param Collection|Visit[] $visits
* @return ShortUrl
* @internal
*/
@@ -215,19 +189,12 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
return $this;
}
/**
* @return int|null
*/
public function getMaxVisits()
public function getMaxVisits(): ?int
{
return $this->maxVisits;
}
/**
* @param int|null $maxVisits
* @return $this|self
*/
public function setMaxVisits($maxVisits): self
public function setMaxVisits(?int $maxVisits): self
{
$this->maxVisits = $maxVisits;
return $this;
@@ -237,22 +204,4 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
{
return $this->maxVisits !== null && $this->getVisitsCount() >= $this->maxVisits;
}
/**
* Specify data which should be serialized to JSON
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
* @return mixed data which can be serialized by <b>json_encode</b>,
* which is a value of any type other than a resource.
* @since 5.4.0
*/
public function jsonSerialize()
{
return [
'shortCode' => $this->shortCode,
'originalUrl' => $this->originalUrl,
'dateCreated' => $this->dateCreated !== null ? $this->dateCreated->format(\DateTime::ATOM) : null,
'visitsCount' => $this->getVisitsCount(),
'tags' => $this->tags->toArray(),
];
}
}

View File

@@ -21,39 +21,25 @@ class Tag extends AbstractEntity implements \JsonSerializable
* @var string
* @ORM\Column(unique=true)
*/
protected $name;
private $name;
public function __construct($name = null)
{
$this->name = $name;
}
/**
* @return string
*/
public function getName()
public function getName(): string
{
return $this->name;
}
/**
* @param string $name
* @return $this
*/
public function setName($name)
public function setName(string $name)
{
$this->name = $name;
return $this;
}
/**
* Specify data which should be serialized to JSON
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
* @return mixed data which can be serialized by <b>json_encode</b>,
* which is a value of any type other than a resource.
* @since 5.4.0
*/
public function jsonSerialize()
public function jsonSerialize(): string
{
return $this->name;
}

View File

@@ -5,13 +5,16 @@ namespace Shlinkio\Shlink\Core\Entity;
use Doctrine\ORM\Mapping as ORM;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
/**
* Class Visit
* @author
* @link
*
* @ORM\Entity(repositoryClass="Shlinkio\Shlink\Core\Repository\VisitRepository")
* @ORM\Entity(repositoryClass=VisitRepository::class)
* @ORM\Table(name="visits")
*/
class Visit extends AbstractEntity implements \JsonSerializable
@@ -20,143 +23,115 @@ class Visit extends AbstractEntity implements \JsonSerializable
* @var string
* @ORM\Column(type="string", length=256, nullable=true)
*/
protected $referer;
private $referer;
/**
* @var \DateTime
* @ORM\Column(type="datetime", nullable=false)
*/
protected $date;
private $date;
/**
* @var string
* @var string|null
* @ORM\Column(type="string", length=256, name="remote_addr", nullable=true)
*/
protected $remoteAddr;
private $remoteAddr;
/**
* @var string
* @ORM\Column(type="string", length=256, name="user_agent", nullable=true)
*/
protected $userAgent;
private $userAgent;
/**
* @var ShortUrl
* @ORM\ManyToOne(targetEntity=ShortUrl::class)
* @ORM\JoinColumn(name="short_url_id", referencedColumnName="id")
*/
protected $shortUrl;
private $shortUrl;
/**
* @var VisitLocation
* @ORM\ManyToOne(targetEntity=VisitLocation::class, cascade={"persist"})
* @ORM\JoinColumn(name="visit_location_id", referencedColumnName="id", nullable=true)
*/
protected $visitLocation;
private $visitLocation;
public function __construct()
{
$this->date = new \DateTime();
}
/**
* @return string
*/
public function getReferer()
public function getReferer(): string
{
return $this->referer;
}
/**
* @param string $referer
* @return $this
*/
public function setReferer($referer)
public function setReferer(string $referer): self
{
$this->referer = $referer;
return $this;
}
/**
* @return \DateTime
*/
public function getDate()
public function getDate(): \DateTime
{
return $this->date;
}
/**
* @param \DateTime $date
* @return $this
*/
public function setDate($date)
public function setDate(\DateTime $date): self
{
$this->date = $date;
return $this;
}
/**
* @return ShortUrl
*/
public function getShortUrl()
public function getShortUrl(): ShortUrl
{
return $this->shortUrl;
}
/**
* @param ShortUrl $shortUrl
* @return $this
*/
public function setShortUrl($shortUrl)
public function setShortUrl(ShortUrl $shortUrl): self
{
$this->shortUrl = $shortUrl;
return $this;
}
/**
* @return string
*/
public function getRemoteAddr()
public function getRemoteAddr(): ?string
{
return $this->remoteAddr;
}
/**
* @param string $remoteAddr
* @return $this
*/
public function setRemoteAddr($remoteAddr)
public function setRemoteAddr(?string $remoteAddr): self
{
$this->remoteAddr = $remoteAddr;
$this->remoteAddr = $this->obfuscateAddress($remoteAddr);
return $this;
}
/**
* @return string
*/
public function getUserAgent()
private function obfuscateAddress(?string $address): ?string
{
// Localhost addresses do not need to be obfuscated
if ($address === null || $address === IpAddress::LOCALHOST) {
return $address;
}
try {
return (string) IpAddress::fromString($address)->getObfuscatedCopy();
} catch (WrongIpException $e) {
return null;
}
}
public function getUserAgent(): string
{
return $this->userAgent;
}
/**
* @param string $userAgent
* @return $this
*/
public function setUserAgent($userAgent)
public function setUserAgent(string $userAgent): self
{
$this->userAgent = $userAgent;
return $this;
}
/**
* @return VisitLocation
*/
public function getVisitLocation()
public function getVisitLocation(): VisitLocation
{
return $this->visitLocation;
}
/**
* @param VisitLocation $visitLocation
* @return $this
*/
public function setVisitLocation($visitLocation)
public function setVisitLocation(VisitLocation $visitLocation): self
{
$this->visitLocation = $visitLocation;
return $this;
@@ -165,18 +140,20 @@ class Visit extends AbstractEntity implements \JsonSerializable
/**
* Specify data which should be serialized to JSON
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
* @return mixed data which can be serialized by <b>json_encode</b>,
* @return array data which can be serialized by <b>json_encode</b>,
* which is a value of any type other than a resource.
* @since 5.4.0
*/
public function jsonSerialize()
public function jsonSerialize(): array
{
return [
'referer' => $this->referer,
'date' => isset($this->date) ? $this->date->format(\DateTime::ATOM) : null,
'remoteAddr' => $this->remoteAddr,
'userAgent' => $this->userAgent,
'visitLocation' => $this->visitLocation,
// Deprecated
'remoteAddr' => null,
];
}
}

View File

@@ -21,159 +21,110 @@ class VisitLocation extends AbstractEntity implements ArraySerializableInterface
* @var string
* @ORM\Column(nullable=true)
*/
protected $countryCode;
private $countryCode;
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $countryName;
private $countryName;
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $regionName;
private $regionName;
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $cityName;
private $cityName;
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $latitude;
private $latitude;
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $longitude;
private $longitude;
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $timezone;
private $timezone;
/**
* @return string
*/
public function getCountryCode()
public function getCountryCode(): string
{
return $this->countryCode;
return $this->countryCode ?? '';
}
/**
* @param string $countryCode
* @return $this
*/
public function setCountryCode($countryCode)
public function setCountryCode(string $countryCode)
{
$this->countryCode = $countryCode;
return $this;
}
/**
* @return string
*/
public function getCountryName()
public function getCountryName(): string
{
return $this->countryName;
return $this->countryName ?? '';
}
/**
* @param string $countryName
* @return $this
*/
public function setCountryName($countryName)
public function setCountryName(string $countryName): self
{
$this->countryName = $countryName;
return $this;
}
/**
* @return string
*/
public function getRegionName()
public function getRegionName(): string
{
return $this->regionName;
return $this->regionName ?? '';
}
/**
* @param string $regionName
* @return $this
*/
public function setRegionName($regionName)
public function setRegionName(string $regionName): self
{
$this->regionName = $regionName;
return $this;
}
/**
* @return string
*/
public function getCityName()
public function getCityName(): string
{
return $this->cityName;
return $this->cityName ?? '';
}
/**
* @param string $cityName
* @return $this
*/
public function setCityName($cityName)
public function setCityName(string $cityName): self
{
$this->cityName = $cityName;
return $this;
}
/**
* @return string
*/
public function getLatitude()
public function getLatitude(): string
{
return $this->latitude;
return $this->latitude ?? '';
}
/**
* @param string $latitude
* @return $this
*/
public function setLatitude($latitude)
public function setLatitude(string $latitude): self
{
$this->latitude = $latitude;
return $this;
}
/**
* @return string
*/
public function getLongitude()
public function getLongitude(): string
{
return $this->longitude;
return $this->longitude ?? '';
}
/**
* @param string $longitude
* @return $this
*/
public function setLongitude($longitude)
public function setLongitude(string $longitude): self
{
$this->longitude = $longitude;
return $this;
}
/**
* @return string
*/
public function getTimezone()
public function getTimezone(): string
{
return $this->timezone;
return $this->timezone ?? '';
}
/**
* @param string $timezone
* @return $this
*/
public function setTimezone($timezone)
public function setTimezone(string $timezone): self
{
$this->timezone = $timezone;
return $this;
@@ -181,41 +132,36 @@ class VisitLocation extends AbstractEntity implements ArraySerializableInterface
/**
* Exchange internal values from provided array
*
* @param array $array
* @return void
*/
public function exchangeArray(array $array)
public function exchangeArray(array $array): void
{
if (array_key_exists('country_code', $array)) {
if (\array_key_exists('country_code', $array)) {
$this->setCountryCode($array['country_code']);
}
if (array_key_exists('country_name', $array)) {
if (\array_key_exists('country_name', $array)) {
$this->setCountryName($array['country_name']);
}
if (array_key_exists('region_name', $array)) {
if (\array_key_exists('region_name', $array)) {
$this->setRegionName($array['region_name']);
}
if (array_key_exists('city', $array)) {
if (\array_key_exists('city', $array)) {
$this->setCityName($array['city']);
}
if (array_key_exists('latitude', $array)) {
if (\array_key_exists('latitude', $array)) {
$this->setLatitude($array['latitude']);
}
if (array_key_exists('longitude', $array)) {
if (\array_key_exists('longitude', $array)) {
$this->setLongitude($array['longitude']);
}
if (array_key_exists('time_zone', $array)) {
if (\array_key_exists('time_zone', $array)) {
$this->setTimezone($array['time_zone']);
}
}
/**
* Return an array representation of the object
*
* @return array
*/
public function getArrayCopy()
public function getArrayCopy(): array
{
return [
'countryCode' => $this->countryCode,
@@ -228,14 +174,7 @@ class VisitLocation extends AbstractEntity implements ArraySerializableInterface
];
}
/**
* Specify data which should be serialized to JSON
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
* @return mixed data which can be serialized by <b>json_encode</b>,
* which is a value of any type other than a resource.
* @since 5.4.0
*/
public function jsonSerialize()
public function jsonSerialize(): array
{
return $this->getArrayCopy();
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use Throwable;
class DeleteShortUrlException extends RuntimeException
{
/**
* @var int
*/
private $visitsThreshold;
public function __construct(int $visitsThreshold, string $message = '', int $code = 0, Throwable $previous = null)
{
$this->visitsThreshold = $visitsThreshold;
parent::__construct($message, $code, $previous);
}
public static function fromVisitsThreshold(int $threshold, string $shortCode): self
{
return new self($threshold, \sprintf(
'Impossible to delete short URL with short code "%s" since it has more than "%s" visits.',
$shortCode,
$threshold
));
}
public function getVisitsThreshold(): int
{
return $this->visitsThreshold;
}
}

View File

@@ -7,9 +7,9 @@ class InvalidShortCodeException extends RuntimeException
{
public static function fromCharset($shortCode, $charSet, \Exception $previous = null)
{
$code = isset($previous) ? $previous->getCode() : -1;
$code = $previous !== null ? $previous->getCode() : -1;
return new static(
sprintf('Provided short code "%s" does not match the char set "%s"', $shortCode, $charSet),
\sprintf('Provided short code "%s" does not match the char set "%s"', $shortCode, $charSet),
$code,
$previous
);
@@ -17,6 +17,6 @@ class InvalidShortCodeException extends RuntimeException
public static function fromNotFoundShortCode($shortCode)
{
return new static(sprintf('Provided short code "%s" does not belong to a short URL', $shortCode));
return new static(\sprintf('Provided short code "%s" does not belong to a short URL', $shortCode));
}
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Middleware;
use Doctrine\Common\Cache\Cache;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Interop\Http\ServerMiddleware\MiddlewareInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Zend\Diactoros\Response as DiactResp;
class QrCodeCacheMiddleware implements MiddlewareInterface
@@ -27,11 +27,11 @@ class QrCodeCacheMiddleware implements MiddlewareInterface
* to the next middleware component to create the response.
*
* @param Request $request
* @param DelegateInterface $delegate
* @param RequestHandlerInterface $handler
*
* @return Response
*/
public function process(Request $request, DelegateInterface $delegate)
public function process(Request $request, RequestHandlerInterface $handler): Response
{
$cacheKey = $request->getUri()->getPath();
@@ -45,7 +45,7 @@ class QrCodeCacheMiddleware implements MiddlewareInterface
// If not, call the next middleware and cache it
/** @var Response $resp */
$resp = $delegate->process($request);
$resp = $handler->handle($request);
$this->cache->save($cacheKey, [
'body' => $resp->getBody()->__toString(),
'content-type' => $resp->getHeaderLine('Content-Type'),

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Model;
use Psr\Http\Message\UriInterface;
final class CreateShortCodeData
{
/**
* @var UriInterface
*/
private $longUrl;
/**
* @var array
*/
private $tags;
/**
* @var ShortUrlMeta
*/
private $meta;
public function __construct(
UriInterface $longUrl,
array $tags = [],
ShortUrlMeta $meta = null
) {
$this->longUrl = $longUrl;
$this->tags = $tags;
$this->meta = $meta ?? ShortUrlMeta::createFromParams();
}
/**
* @return UriInterface
*/
public function getLongUrl(): UriInterface
{
return $this->longUrl;
}
/**
* @return array
*/
public function getTags(): array
{
return $this->tags;
}
/**
* @return ShortUrlMeta
*/
public function getMeta(): ShortUrlMeta
{
return $this->meta;
}
}

View File

@@ -71,26 +71,41 @@ final class ShortUrlMeta
* @param array $data
* @throws ValidationException
*/
private function validate(array $data)
private function validate(array $data): void
{
$inputFilter = new ShortUrlMetaInputFilter($data);
if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter);
}
$this->validSince = $inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE);
$this->validSince = $this->validSince !== null ? new \DateTime($this->validSince) : null;
$this->validUntil = $inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL);
$this->validUntil = $this->validUntil !== null ? new \DateTime($this->validUntil) : null;
$this->validSince = $this->parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
$this->validUntil = $this->parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
$this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
$this->maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS);
$this->maxVisits = $this->maxVisits !== null ? (int) $this->maxVisits : null;
}
/**
* @param string|\DateTime|null $date
* @return \DateTime|null
*/
public function getValidSince()
private function parseDateField($date): ?\DateTime
{
if ($date === null || $date instanceof \DateTime) {
return $date;
}
if (\is_string($date)) {
return new \DateTime($date);
}
return null;
}
/**
* @return \DateTime|null
*/
public function getValidSince(): ?\DateTime
{
return $this->validSince;
}
@@ -103,7 +118,7 @@ final class ShortUrlMeta
/**
* @return \DateTime|null
*/
public function getValidUntil()
public function getValidUntil(): ?\DateTime
{
return $this->validUntil;
}

View File

@@ -26,6 +26,6 @@ class AppOptionsFactory implements FactoryInterface
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$config = $container->has('config') ? $container->get('config') : [];
return new AppOptions(isset($config['app_options']) ? $config['app_options'] : []);
return new AppOptions($config['app_options'] ?? []);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Options;
use Zend\Stdlib\AbstractOptions;
class DeleteShortUrlsOptions extends AbstractOptions
{
private $visitsThreshold = 15;
private $checkVisitsThreshold = true;
public function getVisitsThreshold(): int
{
return $this->visitsThreshold;
}
protected function setVisitsThreshold(int $visitsThreshold): self
{
$this->visitsThreshold = $visitsThreshold;
return $this;
}
public function doCheckVisitsThreshold(): bool
{
return $this->checkVisitsThreshold;
}
protected function setCheckVisitsThreshold(bool $checkVisitsThreshold): self
{
$this->checkVisitsThreshold = $checkVisitsThreshold;
return $this;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Options;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class DeleteShortUrlsOptionsFactory implements FactoryInterface
{
/**
* Create an object
*
* @param ContainerInterface $container
* @param string $requestedName
* @param null|array $options
* @return object
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$config = $container->has('config') ? $container->get('config') : [];
return new DeleteShortUrlsOptions($config['delete_short_urls'] ?? []);
}
}

View File

@@ -25,7 +25,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
$orderBy = null
): array {
$qb = $this->createListQueryBuilder($searchTerm, $tags);
$qb->select('s');
$qb->select('DISTINCT s');
// Set limit and offset
if ($limit !== null) {
@@ -47,20 +47,28 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
protected function processOrderByForList(QueryBuilder $qb, $orderBy)
{
$fieldName = is_array($orderBy) ? key($orderBy) : $orderBy;
$order = is_array($orderBy) ? $orderBy[$fieldName] : 'ASC';
// Map public field names to column names
$fieldNameMap = [
'originalUrl' => 'originalUrl',
'longUrl' => 'originalUrl',
'shortCode' => 'shortCode',
'dateCreated' => 'dateCreated',
];
$fieldName = \is_array($orderBy) ? \key($orderBy) : $orderBy;
$order = \is_array($orderBy) ? $orderBy[$fieldName] : 'ASC';
if (in_array($fieldName, ['visits', 'visitsCount', 'visitCount'], true)) {
$qb->addSelect('COUNT(v) AS totalVisits')
if (\in_array($fieldName, ['visits', 'visitsCount', 'visitCount'], true)) {
$qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
->leftJoin('s.visits', 'v')
->groupBy('s')
->orderBy('totalVisits', $order);
return array_column($qb->getQuery()->getResult(), 0);
} elseif (in_array($fieldName, ['originalUrl', 'shortCode', 'dateCreated'], true)) {
$qb->orderBy('s.' . $fieldName, $order);
return \array_column($qb->getQuery()->getResult(), 0);
}
if (\array_key_exists($fieldName, $fieldNameMap)) {
$qb->orderBy('s.' . $fieldNameMap[$fieldName], $order);
}
return $qb->getQuery()->getResult();
}
@@ -74,7 +82,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
public function countList(string $searchTerm = null, array $tags = []): int
{
$qb = $this->createListQueryBuilder($searchTerm, $tags);
$qb->select('COUNT(s)');
$qb->select('COUNT(DISTINCT s)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
@@ -92,7 +100,10 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
// Apply search term to every searchable field if not empty
if (! empty($searchTerm)) {
$qb->join('s.tags', 't');
// Left join with tags only if no tags were provided. In case of tags, an inner join will be done later
if (empty($tags)) {
$qb->leftJoin('s.tags', 't');
}
$conditions = [
$qb->expr()->like('s.originalUrl', ':searchPattern'),
@@ -102,8 +113,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
// Unpack and apply search conditions
$qb->andWhere($qb->expr()->orX(...$conditions));
$searchTerm = '%' . $searchTerm . '%';
$qb->setParameter('searchPattern', $searchTerm);
$qb->setParameter('searchPattern', '%' . $searchTerm . '%');
}
// Filter by tags if provided
@@ -119,7 +129,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
* @param string $shortCode
* @return ShortUrl|null
*/
public function findOneByShortCode(string $shortCode)
public function findOneByShortCode(string $shortCode): ?ShortUrl
{
$now = new \DateTimeImmutable();

View File

@@ -4,15 +4,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Response;
use Fig\Http\Message\StatusCodeInterface;
use Interop\Http\ServerMiddleware\DelegateInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Zend\Diactoros\Response;
use Zend\Expressive\Template\TemplateRendererInterface;
class NotFoundDelegate implements DelegateInterface
class NotFoundHandler implements RequestHandlerInterface
{
const NOT_FOUND_TEMPLATE = 'notFoundTemplate';
public const NOT_FOUND_TEMPLATE = 'notFoundTemplate';
/**
* @var TemplateRendererInterface
@@ -37,14 +37,14 @@ class NotFoundDelegate implements DelegateInterface
* @return ResponseInterface
* @throws \InvalidArgumentException
*/
public function process(ServerRequestInterface $request): ResponseInterface
public function handle(ServerRequestInterface $request): ResponseInterface
{
$accepts = explode(',', $request->getHeaderLine('Accept'));
$accept = array_shift($accepts);
$accepts = \explode(',', $request->getHeaderLine('Accept'));
$accept = \array_shift($accepts);
$status = StatusCodeInterface::STATUS_NOT_FOUND;
// If the first accepted type is json, return a json response
if (in_array($accept, ['application/json', 'text/json', 'application/x-json'], true)) {
if (\in_array($accept, ['application/json', 'text/json', 'application/x-json'], true)) {
return new Response\JsonResponse([
'error' => 'NOT_FOUND',
'message' => 'Not found',

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions;
class DeleteShortUrlService implements DeleteShortUrlServiceInterface
{
use FindShortCodeTrait;
/**
* @var EntityManagerInterface
*/
private $em;
/**
* @var DeleteShortUrlsOptions
*/
private $deleteShortUrlsOptions;
public function __construct(EntityManagerInterface $em, DeleteShortUrlsOptions $deleteShortUrlsOptions)
{
$this->em = $em;
$this->deleteShortUrlsOptions = $deleteShortUrlsOptions;
}
/**
* @throws Exception\InvalidShortCodeException
* @throws Exception\DeleteShortUrlException
*/
public function deleteByShortCode(string $shortCode, bool $ignoreThreshold = false): void
{
$shortUrl = $this->findByShortCode($this->em, $shortCode);
if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) {
throw Exception\DeleteShortUrlException::fromVisitsThreshold(
$this->deleteShortUrlsOptions->getVisitsThreshold(),
$shortUrl->getShortCode()
);
}
$this->em->remove($shortUrl);
$this->em->flush();
}
private function isThresholdReached(ShortUrl $shortUrl): bool
{
if (! $this->deleteShortUrlsOptions->doCheckVisitsThreshold()) {
return false;
}
return $shortUrl->getVisitsCount() >= $this->deleteShortUrlsOptions->getVisitsThreshold();
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Shlinkio\Shlink\Core\Exception;
interface DeleteShortUrlServiceInterface
{
/**
* @throws Exception\InvalidShortCodeException
* @throws Exception\DeleteShortUrlException
*/
public function deleteByShortCode(string $shortCode, bool $ignoreThreshold = false): void;
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
trait FindShortCodeTrait
{
/**
* @param string $shortCode
* @return ShortUrl
* @throws InvalidShortCodeException
*/
private function findByShortCode(EntityManagerInterface $em, string $shortCode): ShortUrl
{
/** @var ShortUrl|null $shortUrl */
$shortUrl = $em->getRepository(ShortUrl::class)->findOneBy([
'shortCode' => $shortCode,
]);
if ($shortUrl === null) {
throw InvalidShortCodeException::fromNotFoundShortCode($shortCode);
}
return $shortUrl;
}
}

View File

@@ -9,11 +9,13 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Service\ShortUrl\FindShortCodeTrait;
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
use Zend\Paginator\Paginator;
class ShortUrlService implements ShortUrlServiceInterface
{
use FindShortCodeTrait;
use TagManagerTrait;
/**
@@ -27,13 +29,11 @@ class ShortUrlService implements ShortUrlServiceInterface
}
/**
* @param int $page
* @param string $searchQuery
* @param array $tags
* @param null $orderBy
* @param string[] $tags
* @param array|string|null $orderBy
* @return ShortUrl[]|Paginator
*/
public function listShortUrls($page = 1, $searchQuery = null, array $tags = [], $orderBy = null)
public function listShortUrls(int $page = 1, string $searchQuery = null, array $tags = [], $orderBy = null)
{
/** @var ShortUrlRepository $repo */
$repo = $this->em->getRepository(ShortUrl::class);
@@ -45,14 +45,12 @@ class ShortUrlService implements ShortUrlServiceInterface
}
/**
* @param string $shortCode
* @param string[] $tags
* @return ShortUrl
* @throws InvalidShortCodeException
*/
public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl
{
$shortUrl = $this->findByShortCode($shortCode);
$shortUrl = $this->findByShortCode($this->em, $shortCode);
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
$this->em->flush();
@@ -60,14 +58,11 @@ class ShortUrlService implements ShortUrlServiceInterface
}
/**
* @param string $shortCode
* @param ShortUrlMeta $shortCodeMeta
* @return ShortUrl
* @throws InvalidShortCodeException
*/
public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortCodeMeta): ShortUrl
{
$shortUrl = $this->findByShortCode($shortCode);
$shortUrl = $this->findByShortCode($this->em, $shortCode);
if ($shortCodeMeta->hasValidSince()) {
$shortUrl->setValidSince($shortCodeMeta->getValidSince());
}
@@ -81,23 +76,6 @@ class ShortUrlService implements ShortUrlServiceInterface
/** @var ORM\EntityManager $em */
$em = $this->em;
$em->flush($shortUrl);
return $shortUrl;
}
/**
* @param string $shortCode
* @return ShortUrl
* @throws InvalidShortCodeException
*/
private function findByShortCode(string $shortCode): ShortUrl
{
/** @var ShortUrl|null $shortUrl */
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
'shortCode' => $shortCode,
]);
if ($shortUrl === null) {
throw InvalidShortCodeException::fromNotFoundShortCode($shortCode);
}
return $shortUrl;
}

View File

@@ -11,26 +11,19 @@ use Zend\Paginator\Paginator;
interface ShortUrlServiceInterface
{
/**
* @param int $page
* @param string $searchQuery
* @param array $tags
* @param null $orderBy
* @param string[] $tags
* @param array|string|null $orderBy
* @return ShortUrl[]|Paginator
*/
public function listShortUrls($page = 1, $searchQuery = null, array $tags = [], $orderBy = null);
public function listShortUrls(int $page = 1, string $searchQuery = null, array $tags = [], $orderBy = null);
/**
* @param string $shortCode
* @param string[] $tags
* @return ShortUrl
* @throws InvalidShortCodeException
*/
public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl;
/**
* @param string $shortCode
* @param ShortUrlMeta $shortCodeMeta
* @return ShortUrl
* @throws InvalidShortCodeException
*/
public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortCodeMeta): ShortUrl;

View File

@@ -28,16 +28,18 @@ class TagService implements TagServiceInterface
* @return Tag[]
* @throws \UnexpectedValueException
*/
public function listTags()
public function listTags(): array
{
return $this->em->getRepository(Tag::class)->findBy([], ['name' => 'ASC']);
/** @var Tag[] $tags */
$tags = $this->em->getRepository(Tag::class)->findBy([], ['name' => 'ASC']);
return $tags;
}
/**
* @param array $tagNames
* @return void
*/
public function deleteTags(array $tagNames)
public function deleteTags(array $tagNames): void
{
/** @var TagRepository $repo */
$repo = $this->em->getRepository(Tag::class);
@@ -50,7 +52,7 @@ class TagService implements TagServiceInterface
* @param string[] $tagNames
* @return Collection|Tag[]
*/
public function createTags(array $tagNames)
public function createTags(array $tagNames): Collection
{
$tags = $this->tagNamesToEntities($this->em, $tagNames);
$this->em->flush();
@@ -65,7 +67,7 @@ class TagService implements TagServiceInterface
* @throws EntityDoesNotExistException
* @throws ORM\OptimisticLockException
*/
public function renameTag($oldName, $newName)
public function renameTag($oldName, $newName): Tag
{
$criteria = ['name' => $oldName];
/** @var Tag|null $tag */

View File

@@ -12,13 +12,13 @@ interface TagServiceInterface
/**
* @return Tag[]
*/
public function listTags();
public function listTags(): array;
/**
* @param string[] $tagNames
* @return void
*/
public function deleteTags(array $tagNames);
public function deleteTags(array $tagNames): void;
/**
* Provided a list of tag names, creates all that do not exist yet
@@ -26,7 +26,7 @@ interface TagServiceInterface
* @param string[] $tagNames
* @return Collection|Tag[]
*/
public function createTags(array $tagNames);
public function createTags(array $tagNames): Collection;
/**
* @param string $oldName
@@ -34,5 +34,5 @@ interface TagServiceInterface
* @return Tag
* @throws EntityDoesNotExistException
*/
public function renameTag($oldName, $newName);
public function renameTag($oldName, $newName): Tag;
}

View File

@@ -5,7 +5,6 @@ namespace Shlinkio\Shlink\Core\Service;
use Cocur\Slugify\Slugify;
use Cocur\Slugify\SlugifyInterface;
use Doctrine\Common\Cache\Cache;
use Doctrine\ORM\EntityManagerInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
@@ -23,7 +22,8 @@ class UrlShortener implements UrlShortenerInterface
{
use TagManagerTrait;
const DEFAULT_CHARS = '123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ';
public const DEFAULT_CHARS = '123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ';
private const ID_INCREMENT = 200000;
/**
* @var ClientInterface
@@ -37,10 +37,6 @@ class UrlShortener implements UrlShortenerInterface
* @var string
*/
private $chars;
/**
* @var Cache
*/
private $cache;
/**
* @var SlugifyInterface
*/
@@ -53,15 +49,13 @@ class UrlShortener implements UrlShortenerInterface
public function __construct(
ClientInterface $httpClient,
EntityManagerInterface $em,
Cache $cache,
$urlValidationEnabled,
$chars = self::DEFAULT_CHARS,
SlugifyInterface $slugger = null
) {
$this->httpClient = $httpClient;
$this->em = $em;
$this->cache = $cache;
$this->urlValidationEnabled = $urlValidationEnabled;
$this->urlValidationEnabled = (bool) $urlValidationEnabled;
$this->chars = empty($chars) ? self::DEFAULT_CHARS : $chars;
$this->slugger = $slugger ?: new Slugify();
}
@@ -75,7 +69,6 @@ class UrlShortener implements UrlShortenerInterface
* @param \DateTime|null $validUntil
* @param string|null $customSlug
* @param int|null $maxVisits
* @return string
* @throws NonUniqueSlugException
* @throws InvalidUrlException
* @throws RuntimeException
@@ -87,19 +80,9 @@ class UrlShortener implements UrlShortenerInterface
\DateTime $validUntil = null,
string $customSlug = null,
int $maxVisits = null
): string {
// If the url already exists in the database, just return its short code
/** @var ShortUrl|null $shortUrl */
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
'originalUrl' => $url,
]);
if ($shortUrl !== null) {
return $shortUrl->getShortCode();
}
// Check if the validation of url is enabled in the config
if (true === $this->urlValidationEnabled) {
// Check that the URL exists
): ShortUrl {
// If the URL validation is enabled, check that the URL actually exists
if ($this->urlValidationEnabled) {
$this->checkUrlExists($url);
}
$customSlug = $this->processCustomSlug($customSlug);
@@ -124,7 +107,7 @@ class UrlShortener implements UrlShortenerInterface
$this->em->flush();
$this->em->commit();
return $shortCode;
return $shortUrl;
} catch (\Throwable $e) {
if ($this->em->getConnection()->isTransactionActive()) {
$this->em->rollback();
@@ -160,14 +143,14 @@ class UrlShortener implements UrlShortenerInterface
*/
private function convertAutoincrementIdToShortCode(float $id): string
{
$id += 200000; // Increment the Id so that the generated shortcode is not too short
$id += self::ID_INCREMENT; // Increment the Id so that the generated shortcode is not too short
$length = \strlen($this->chars);
$code = '';
while ($id > 0) {
// Determine the value of the next higher character in the short code and prepend it
$code = $this->chars[(int) fmod($id, $length)] . $code;
$id = floor($id / $length);
$code = $this->chars[(int) \fmod($id, $length)] . $code;
$id = \floor($id / $length);
}
return $this->chars[(int) $id] . $code;
@@ -179,7 +162,7 @@ class UrlShortener implements UrlShortenerInterface
return null;
}
// If a custom slug was provided, check it is unique
// If a custom slug was provided, make sure it's unique
$customSlug = $this->slugger->slugify($customSlug);
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy(['shortCode' => $customSlug]);
if ($shortUrl !== null) {
@@ -192,21 +175,13 @@ class UrlShortener implements UrlShortenerInterface
/**
* Tries to find the mapped URL for provided short code. Returns null if not found
*
* @param string $shortCode
* @return string
* @throws InvalidShortCodeException
* @throws EntityDoesNotExistException
*/
public function shortCodeToUrl(string $shortCode): string
public function shortCodeToUrl(string $shortCode): ShortUrl
{
$cacheKey = sprintf('%s_longUrl', $shortCode);
// Check if the short code => URL map is already cached
if ($this->cache->contains($cacheKey)) {
return $this->cache->fetch($cacheKey);
}
// Validate short code format
if (! preg_match('|[' . $this->chars . ']+|', $shortCode)) {
if (! \preg_match('|[' . $this->chars . ']+|', $shortCode)) {
throw InvalidShortCodeException::fromCharset($shortCode, $this->chars);
}
@@ -219,9 +194,6 @@ class UrlShortener implements UrlShortenerInterface
]);
}
// Cache the shortcode
$url = $shortUrl->getOriginalUrl();
$this->cache->save($cacheKey, $url);
return $url;
return $shortUrl;
}
}

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