Compare commits

...

78 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
117 changed files with 3050 additions and 1096 deletions

1
.gitignore vendored
View File

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

View File

@@ -1,263 +1,732 @@
## CHANGELOG # CHANGELOG
### 1.9.0 ## 1.12.0 - 2018-09-15
**Features** #### Added
* [147: Allow short URLs to be created on the fly with query param authentication](https://github.com/shlinkio/shlink/issues/147) * [#187](https://github.com/shlinkio/shlink/issues/187) Included an API endpoint and a CLI command to delete short URLs.
**Bugs:** 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.
* [139: Make sure all core actions log exceptions](https://github.com/shlinkio/shlink/issues/139) 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.
### 1.8.1 In order to change it, open the `config/autoload/delete_short_urls.global.php` file, which has this structure:
**Tasks** ```php
return [
* [141: Remove workaround used in PathVersionMiddleware](https://github.com/shlinkio/shlink/issues/141) 'delete_short_urls' => [
'visits_threshold' => 15,
'check_visits_threshold' => true,
],
**Bugs:** ];
```
* [140: Installation failed. Warning thrown while trying to include doctrine script](https://github.com/shlinkio/shlink/issues/140) 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.
### 1.8.0 Once changed, delete the `data/cache/app_config.php` file (if any) to let shlink know about the new values.
**Features** 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.
* [125: Implement a path which returns a 1px image instead of a redirection](https://github.com/shlinkio/shlink/issues/125) * [#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.
**Enhancements:** #### Changed
* [130: Update to Expressive 3](https://github.com/shlinkio/shlink/issues/130) * [#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.
* [137: Update symfony packages to v4](https://github.com/shlinkio/shlink/issues/137)
**Tasks** 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)
* [131: Drop support for PHP 7](https://github.com/shlinkio/shlink/issues/131) * [#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.
* [132: Add infection to improve tests](https://github.com/shlinkio/shlink/issues/132)
### 1.7.2 #### Deprecated
**Bugs:** * *Nothing*
* [135: Fix PathVersionMiddleware being ignored when using expressive 2.2](https://github.com/shlinkio/shlink/issues/135) #### Removed
### 1.7.1 * *Nothing*
**Enhancements:** #### Fixed
* [128: Upgrade to expressive 2.2](https://github.com/shlinkio/shlink/issues/128) * [#188](https://github.com/shlinkio/shlink/issues/188) Shlink now allows multiple short URLs to be created that resolve to the same long URL.
**Bugs**
* [126: Expressive 2.2 causes failures by triggering E_USER_DEPRECATED errors](https://github.com/shlinkio/shlink/issues/126) ## 1.11.0 - 2018-08-13
### 1.7.0 #### Added
**Features** * [#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.
* [88: Allow to disable tracking of the short URL by including a configurable query param](https://github.com/shlinkio/shlink/issues/88) The short URLs are now represented by this object in both cases:
* [108: Allow to edit metadata in created shortcodes](https://github.com/shlinkio/shlink/issues/108)
**Enhancements:** ```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"
}
```
* [113: Update CLI commands to use SymfonyStyle](https://github.com/shlinkio/shlink/issues/113) The `originalUrl` property is considered deprecated and has been kept for backward compatibility purposes. It holds the same value as the `longUrl` property.
* [112: Configure cli commands lazy loading](https://github.com/shlinkio/shlink/issues/112)
**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) * *Nothing*
* [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)
### 1.6.2 #### Deprecated
**Bugs** * The `originalUrl` property in `[GET /short-codes]` and `[GET /short-codes/{shortCode}]` endpoints is now deprecated and replaced by the `longUrl` property.
* [109: Fix installation error due to typo in latest migration](https://github.com/shlinkio/shlink/issues/109) #### Removed
### 1.6.1 * *Nothing*
**Tasks** #### Fixed
* [110: Create gitattributes file to define files to be excluded from distributable package](https://github.com/shlinkio/shlink/issues/110) * *Nothing*
### 1.6.0
**Features** ## 1.10.2 - 2018-08-04
* [44: Consider allowing to set custom slugs instead of generating a short code](https://github.com/shlinkio/shlink/issues/44) #### Added
* [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:** * *Nothing*
* [27: Add repository functional tests with dbunit](https://github.com/shlinkio/shlink/issues/27) #### Changed
* [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)
**Tasks** * *Nothing*
* [99: Replace AnnotatedFactory by ConfigAbstractFactory](https://github.com/shlinkio/shlink/issues/99) #### Deprecated
* [100: Replace twig by plates](https://github.com/shlinkio/shlink/issues/100)
* [102: Improve coding standards strictness](https://github.com/shlinkio/shlink/issues/102)
**Bugs** * *Nothing*
* [103: Make NotFoundDelegate return proper content types based on accepted content](https://github.com/shlinkio/shlink/issues/103) #### Removed
### 1.5.0 * *Nothing*
**Enhancements:** #### Fixed
* [95: Add tags CRUD to CLI](https://github.com/shlinkio/shlink/issues/95) * [#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.
* [59: Add tags CRUD to REST](https://github.com/shlinkio/shlink/issues/59) * [#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.
* [66: Allow to import certain information from older app directory when updating](https://github.com/shlinkio/shlink/issues/66)
**Tasks** 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.
* [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)
**Bugs** ## 1.10.1 - 2018-08-02
* [92: Fix formatted dates, using an ISO compliant format](https://github.com/shlinkio/shlink/issues/92) #### Added
### 1.4.0 * *Nothing*
**Enhancements:** #### Changed
* [89: Update to expressive 2](https://github.com/shlinkio/shlink/issues/89) * [#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.
### 1.3.1 #### Deprecated
**Tasks** * *Nothing*
* [82: Enable FastRoute routes cache](https://github.com/shlinkio/shlink/issues/82) #### Removed
* [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)
**Bugs** * *Nothing*
* [83: Short codes list: search in tags when filtering by query string](https://github.com/shlinkio/shlink/issues/83) #### Fixed
* [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)
### 1.3.0 * [#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.
**Enhancements:** For example: `https://doma.in/rest/v1/short-urls/abc123/visits?startDate=2017-05-23&endDate=2017-10-05`
* [67: Allow to order the short codes list](https://github.com/shlinkio/shlink/issues/67) * [#169](https://github.com/shlinkio/shlink/issues/169) Fixed unhandled error when parsing `ShortUrlMeta` and date fields are already `DateTime` instances.
* [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)
**Tasks**
* [73: Tag endpoints in swagger file](https://github.com/shlinkio/shlink/issues/73) ## 1.10.0 - 2018-07-09
* [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)
### 1.2.2 #### Added
**Bugs** * [#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 * 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) * *Nothing*
* [7: Add website previews while listing available URLs](https://github.com/shlinkio/shlink/issues/7)
**Enhancements:** #### Deprecated
* [57: Add database migrations system to improve updating between versions](https://github.com/shlinkio/shlink/issues/57) * *Nothing*
* [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)
**Tasks** #### Removed
* [55: Create update script which does not try to create a new database](https://github.com/shlinkio/shlink/issues/55) * *Nothing*
* [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)
**Bugs** #### Fixed
* [53: Fix entities database interoperability](https://github.com/shlinkio/shlink/issues/53) * [#62](https://github.com/shlinkio/shlink/issues/62) Fixed cross-domain requests in REST API
* [52: Add missing htaccess file for apache environments](https://github.com/shlinkio/shlink/issues/52)
### 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) #### Changed
* [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)
**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) #### Deprecated
* [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)
**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) * [#53](https://github.com/shlinkio/shlink/issues/53) Fixed entities database interoperability
* [15: Return JSON/HTML responses for errors (4xx and 5xx) based on accept header (content negotiation)](https://github.com/shlinkio/shlink/issues/15) * [#52](https://github.com/shlinkio/shlink/issues/52) Added missing htaccess file for apache environments
* [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)
**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) ## 1.1.0 - 2016-08-09
* [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)
**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) #### Changed
* [8: Create a REST API](https://github.com/shlinkio/shlink/issues/8)
* [10: Add more CLI functionality](https://github.com/shlinkio/shlink/issues/10)
**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) 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 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

150
README.md
View File

@@ -1,9 +1,147 @@
# Shlink # Shlink
[![Build Status](https://travis-ci.org/shlinkio/shlink.svg?branch=master)](https://travis-ci.org/shlinkio/shlink) [![Build Status](https://img.shields.io/travis/shlinkio/shlink.svg?style=flat-square)](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) [![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://scrutinizer-ci.com/g/shlinkio/shlink/badges/quality-score.png?b=master)](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://poser.pugx.org/shlinkio/shlink/v/stable.png)](https://packagist.org/packages/shlinkio/shlink) [![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink)
[![License](https://poser.pugx.org/shlinkio/shlink/license.png)](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 version=$1
builtcontent=$(readlink -f "../shlink_${version}_dist") builtcontent=$(readlink -f "../shlink_${version}_dist")
projectdir=$(pwd) projectdir=$(pwd)
[ -f ./composer.phar ] && composerBin='./composer.phar' || composerBin='composer'
# Copy project content to temp dir # Copy project content to temp dir
echo 'Copying project files...' echo 'Copying project files...'
@@ -20,10 +21,11 @@ cp -R "${projectdir}"/* "${builtcontent}"
cd "${builtcontent}" cd "${builtcontent}"
# Install dependencies # Install dependencies
echo "Installing dependencies with $composerBin..."
rm -rf vendor rm -rf vendor
rm -f composer.lock rm -f composer.lock
composer self-update $composerBin self-update
composer install --no-dev --optimize-autoloader --no-progress --no-interaction $composerBin install --no-dev --optimize-autoloader --no-progress --no-interaction
# Delete development files # Delete development files
echo 'Deleting dev files...' echo 'Deleting dev files...'
@@ -46,7 +48,13 @@ rm -rf data/{cache,log,proxies}/{*,.gitignore}
rm -rf config/params/{*,.gitignore} rm -rf config/params/{*,.gitignore}
rm -rf config/autoload/{{,*.}local.php{,.dist},.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 # Compressing file
echo 'Compressing files...'
rm -f "${projectdir}"/build/shlink_${version}_dist.zip rm -f "${projectdir}"/build/shlink_${version}_dist.zip
zip -ry "${projectdir}"/build/shlink_${version}_dist.zip "../shlink_${version}_dist" zip -ry "${projectdir}"/build/shlink_${version}_dist.zip "../shlink_${version}_dist"
rm -rf "${builtcontent}" rm -rf "${builtcontent}"
echo 'Done!'

View File

@@ -13,13 +13,11 @@
], ],
"require": { "require": {
"php": "^7.1", "php": "^7.1",
"ext-json": "*",
"ext-pdo": "*",
"acelaya/ze-content-based-error-handler": "^2.2", "acelaya/ze-content-based-error-handler": "^2.2",
"cocur/slugify": "^3.0", "cocur/slugify": "^3.0",
"doctrine/annotations": "^1.4",
"doctrine/cache": "^1.6", "doctrine/cache": "^1.6",
"doctrine/collections": "^1.4",
"doctrine/common": "^2.7",
"doctrine/dbal": "^2.5",
"doctrine/migrations": "^1.4", "doctrine/migrations": "^1.4",
"doctrine/orm": "^2.5", "doctrine/orm": "^2.5",
"endroid/qr-code": "^1.7", "endroid/qr-code": "^1.7",
@@ -47,12 +45,12 @@
}, },
"require-dev": { "require-dev": {
"filp/whoops": "^2.0", "filp/whoops": "^2.0",
"infection/infection": "^0.8.1", "infection/infection": "^0.9.0",
"phpstan/phpstan": "0.9", "phpstan/phpstan": "^0.10.0",
"phpunit/phpcov": "^5.0", "phpunit/phpcov": "^5.0",
"phpunit/phpunit": "^7.0", "phpunit/phpunit": "^7.0",
"slevomat/coding-standard": "^4.0", "slevomat/coding-standard": "^4.0",
"squizlabs/php_codesniffer": "^3.1 <3.2", "squizlabs/php_codesniffer": "^3.2.3",
"symfony/dotenv": "^4.0", "symfony/dotenv": "^4.0",
"symfony/var-dumper": "^4.0", "symfony/var-dumper": "^4.0",
"zendframework/zend-component-installer": "^2.1", "zendframework/zend-component-installer": "^2.1",
@@ -103,8 +101,8 @@
"phpcov merge build --html build/html" "phpcov merge build --html build/html"
], ],
"stan": "phpstan analyse module/*/src/ --level=6 -c phpstan.neon", "stan": "phpstan analyse module/*/src/ --level=6 -c phpstan.neon",
"infect": "infection --threads=4 --min-msi=65 --only-covered --log-verbosity=2", "infect": "infection --threads=4 --min-msi=55 --only-covered --log-verbosity=2",
"infect-show": "infection --threads=4 --min-msi=65 --only-covered --log-verbosity=2 --show-mutations", "infect-show": "infection --threads=4 --min-msi=55 --only-covered --log-verbosity=2 --show-mutations",
"expressive": "expressive" "expressive": "expressive"
}, },
"config": { "config": {

View File

@@ -7,8 +7,9 @@ return [
'app_options' => [ 'app_options' => [
'name' => 'Shlink', 'name' => 'Shlink',
'version' => '1.7.0', 'version' => '%SHLINK_VERSION%',
'secret_key' => env('SECRET_KEY'), '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

@@ -24,6 +24,9 @@ return (new ConfigAggregator\ConfigAggregator([
Expressive\Router\FastRouteRouter\ConfigProvider::class, Expressive\Router\FastRouteRouter\ConfigProvider::class,
Expressive\Plates\ConfigProvider::class, Expressive\Plates\ConfigProvider::class,
Expressive\Helper\ConfigProvider::class, Expressive\Helper\ConfigProvider::class,
\class_exists(Expressive\Swoole\ConfigProvider::class)
? Expressive\Swoole\ConfigProvider::class
: new ConfigAggregator\ArrayProvider([]),
ExpressiveErrorHandler\ConfigProvider::class, ExpressiveErrorHandler\ConfigProvider::class,
Common\ConfigProvider::class, Common\ConfigProvider::class,
Core\ConfigProvider::class, Core\ConfigProvider::class,

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": { "properties": {
"currentPage": { "currentPage": {
"type": "integer", "type": "integer",
"description": "The number of current page being displayed." "description": "The number of current page."
}, },
"pagesCount": { "pagesCount": {
"type": "integer", "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", "type": "string",
"description": "The short code for this short URL." "description": "The short code for this short URL."
}, },
"originalUrl": { "shortUrl": {
"type": "string",
"description": "The short URL."
},
"longUrl": {
"type": "string", "type": "string",
"description": "The original long URL." "description": "The original long URL."
}, },
@@ -24,6 +28,11 @@
"type": "string" "type": "string"
}, },
"description": "A list of tags applied to this short URL" "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", "type": "object",
"properties": { "properties": {
"referer": { "referer": {
"type": "string" "type": "string",
"description": "The origin from which the visit was performed"
}, },
"date": { "date": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time",
}, "description": "The date in which the visit was performed"
"remoteAddr": {
"type": "string"
}, },
"userAgent": { "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", "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)", "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, "required": false,
@@ -44,7 +44,7 @@
"schema": { "schema": {
"type": "string", "type": "string",
"enum": [ "enum": [
"originalUrl", "longUrl",
"shortCode", "shortCode",
"dateCreated", "dateCreated",
"visits" "visits"
@@ -89,7 +89,8 @@
"data": [ "data": [
{ {
"shortCode": "12C18", "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", "dateCreated": "2016-08-21T20:34:16+02:00",
"visitsCount": 328, "visitsCount": 328,
"tags": [ "tags": [
@@ -99,7 +100,8 @@
}, },
{ {
"shortCode": "12Kb3", "shortCode": "12Kb3",
"originalUrl": "https://shlink.io", "shortUrl": "https://doma.in/12Kb3",
"longUrl": "https://shlink.io",
"dateCreated": "2016-05-01T20:34:16+02:00", "dateCreated": "2016-05-01T20:34:16+02:00",
"visitsCount": 1029, "visitsCount": 1029,
"tags": [ "tags": [
@@ -108,7 +110,8 @@
}, },
{ {
"shortCode": "123bA", "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", "dateCreated": "2015-10-01T20:34:16+02:00",
"visitsCount": 25, "visitsCount": 25,
"tags": [] "tags": []
@@ -116,7 +119,10 @@
], ],
"pagination": { "pagination": {
"currentPage": 5, "currentPage": 5,
"pagesCount": 12 "pagesCount": 12,
"itemsPerPage": 10,
"itemsInCurrentPage": 10,
"totalItems": 115
} }
} }
} }

View File

@@ -23,23 +23,24 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "The long URL behind a short code.", "description": "The URL info behind a short code.",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"type": "object", "$ref": "../definitions/ShortUrl.json"
"properties": {
"longUrl": {
"type": "string",
"description": "The original long URL behind the short code."
}
}
} }
} }
}, },
"examples": { "examples": {
"application/json": { "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": { "schema": {
"type": "string" "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": [ "security": [
@@ -52,20 +70,28 @@
{ {
"referer": "https://twitter.com", "referer": "https://twitter.com",
"date": "2015-08-20T05:05:03+04:00", "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", "referer": "https://t.co",
"date": "2015-08-20T05:05:03+04:00", "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, "referer": null,
"date": "2015-08-20T05:05:03+04:00", "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

@@ -14,5 +14,9 @@
"tmpDir": "build/infection/temp", "tmpDir": "build/infection/temp",
"phpUnit": { "phpUnit": {
"configDir": "." "configDir": "."
},
"mutators": {
"@default": true,
"IdenticalEqual": false
} }
} }

View File

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

Binary file not shown.

View File

@@ -1,15 +1,15 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Shlink 1.0\n" "Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2018-01-21 09:36+0100\n" "POT-Creation-Date: 2018-09-15 17:57+0200\n"
"PO-Revision-Date: 2018-01-21 09:39+0100\n" "PO-Revision-Date: 2018-09-15 18:02+0200\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n" "Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n" "Language-Team: \n"
"Language: es_ES\n" "Language: es_ES\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.0.4\n" "X-Generator: Poedit 2.0.6\n"
"X-Poedit-Basepath: ..\n" "X-Poedit-Basepath: ..\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Poedit-SourceCharset: UTF-8\n" "X-Poedit-SourceCharset: UTF-8\n"
@@ -80,6 +80,41 @@ msgstr ""
msgid "Secret key: \"%s\"" msgid "Secret key: \"%s\""
msgstr "Clave secreta: \"%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 "" msgid ""
"Processes and generates the previews for every URL, improving performance " "Processes and generates the previews for every URL, improving performance "
"for later web requests." "for later web requests."
@@ -183,12 +218,12 @@ msgstr "Origen"
msgid "Date" msgid "Date"
msgstr "Fecha" msgstr "Fecha"
msgid "Remote Address"
msgstr "Dirección remota"
msgid "User agent" msgid "User agent"
msgstr "Agente de usuario" msgstr "Agente de usuario"
msgid "Country"
msgstr "País"
msgid "List all short URLs" msgid "List all short URLs"
msgstr "Listar todas las URLs cortas" msgstr "Listar todas las URLs cortas"
@@ -218,8 +253,11 @@ msgstr "Si se desea mostrar las etiquetas o no"
msgid "Short code" msgid "Short code"
msgstr "Código corto" msgstr "Código corto"
msgid "Original URL" msgid "Short URL"
msgstr "URL original" msgstr "URL corta"
msgid "Long URL"
msgstr "URL larga"
msgid "Date created" msgid "Date created"
msgstr "Fecha de creación" msgstr "Fecha de creación"
@@ -253,10 +291,6 @@ msgstr "URL larga:"
msgid "Provided short code \"%s\" has an invalid format." msgid "Provided short code \"%s\" has an invalid format."
msgstr "El código corto proporcionado \"%s\" tiene un formato inválido." 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." msgid "Creates one or more tags."
msgstr "Crea una o más etiquetas." msgstr "Crea una o más etiquetas."
@@ -317,9 +351,22 @@ msgstr "Ignorada IP de localhost"
msgid "Address located at \"%s\"" msgid "Address located at \"%s\""
msgstr "Dirección localizada en \"%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" msgid "Finished processing all IPs"
msgstr "Finalizado el procesado de todas las 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" #~ msgid "You have reached last page"
#~ msgstr "Has alcanzado la última página" #~ msgstr "Has alcanzado la última página"

View File

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

View File

@@ -18,6 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Exception\IOException; use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\PhpExecutableFinder;
use Zend\Config\Writer\WriterInterface; use Zend\Config\Writer\WriterInterface;
class InstallCommand extends Command class InstallCommand extends Command
@@ -48,6 +49,14 @@ class InstallCommand extends Command
* @var bool * @var bool
*/ */
private $isUpdate; private $isUpdate;
/**
* @var PhpExecutableFinder
*/
private $phpFinder;
/**
* @var string|bool
*/
private $phpBinary;
/** /**
* InstallCommand constructor. * InstallCommand constructor.
@@ -55,22 +64,25 @@ class InstallCommand extends Command
* @param Filesystem $filesystem * @param Filesystem $filesystem
* @param ConfigCustomizerManagerInterface $configCustomizers * @param ConfigCustomizerManagerInterface $configCustomizers
* @param bool $isUpdate * @param bool $isUpdate
* @param PhpExecutableFinder|null $phpFinder
* @throws LogicException * @throws LogicException
*/ */
public function __construct( public function __construct(
WriterInterface $configWriter, WriterInterface $configWriter,
Filesystem $filesystem, Filesystem $filesystem,
ConfigCustomizerManagerInterface $configCustomizers, ConfigCustomizerManagerInterface $configCustomizers,
$isUpdate = false bool $isUpdate = false,
PhpExecutableFinder $phpFinder = null
) { ) {
parent::__construct(); parent::__construct();
$this->configWriter = $configWriter; $this->configWriter = $configWriter;
$this->isUpdate = $isUpdate; $this->isUpdate = $isUpdate;
$this->filesystem = $filesystem; $this->filesystem = $filesystem;
$this->configCustomizers = $configCustomizers; $this->configCustomizers = $configCustomizers;
$this->phpFinder = $phpFinder ?: new PhpExecutableFinder();
} }
public function configure() protected function configure(): void
{ {
$this $this
->setName('shlink:install') ->setName('shlink:install')
@@ -84,7 +96,7 @@ class InstallCommand extends Command
* @throws ContainerExceptionInterface * @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface * @throws NotFoundExceptionInterface
*/ */
public function execute(InputInterface $input, OutputInterface $output) protected function execute(InputInterface $input, OutputInterface $output): void
{ {
$this->io = new SymfonyStyle($input, $output); $this->io = new SymfonyStyle($input, $output);
@@ -133,8 +145,8 @@ class InstallCommand extends Command
// If current command is not update, generate database // If current command is not update, generate database
if (! $this->isUpdate) { if (! $this->isUpdate) {
$this->io->write('Initializing database...'); $this->io->write('Initializing database...');
if (! $this->runCommand( if (! $this->runPhpCommand(
'php vendor/doctrine/orm/bin/doctrine.php orm:schema-tool:create', 'vendor/doctrine/orm/bin/doctrine.php orm:schema-tool:create',
'Error generating database.', 'Error generating database.',
$output $output
)) { )) {
@@ -144,8 +156,8 @@ class InstallCommand extends Command
// Run database migrations // Run database migrations
$this->io->write('Updating database...'); $this->io->write('Updating database...');
if (! $this->runCommand( if (! $this->runPhpCommand(
'php vendor/doctrine/migrations/bin/doctrine-migrations.php migrations:migrate', 'vendor/doctrine/migrations/bin/doctrine-migrations.php migrations:migrate',
'Error updating database.', 'Error updating database.',
$output $output
)) { )) {
@@ -154,8 +166,8 @@ class InstallCommand extends Command
// Generate proxies // Generate proxies
$this->io->write('Generating proxies...'); $this->io->write('Generating proxies...');
if (! $this->runCommand( if (! $this->runPhpCommand(
'php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies', 'vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies',
'Error generating proxies.', 'Error generating proxies.',
$output $output
)) { )) {
@@ -213,23 +225,31 @@ class InstallCommand extends Command
* @throws LogicException * @throws LogicException
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
private function runCommand($command, $errorMessage, OutputInterface $output): bool private function runPhpCommand($command, $errorMessage, OutputInterface $output): bool
{ {
if ($this->processHelper === null) { if ($this->processHelper === null) {
$this->processHelper = $this->getHelper('process'); $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()) { if ($process->isSuccessful()) {
$this->io->writeln(' <info>Success!</info>'); $this->io->writeln(' <info>Success!</info>');
return true; return true;
} }
if ($this->io->isVerbose()) { if (! $this->io->isVerbose()) {
return false; $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; 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 class GeneratePreviewCommand extends Command
{ {
const NAME = 'shortcode:process-previews'; public const NAME = 'short-code:process-previews';
private const ALIASES = ['shortcode:process-previews'];
/** /**
* @var PreviewGeneratorInterface * @var PreviewGeneratorInterface
@@ -40,17 +41,19 @@ class GeneratePreviewCommand extends Command
parent::__construct(null); parent::__construct(null);
} }
public function configure() protected function configure(): void
{ {
$this->setName(self::NAME) $this
->setDescription( ->setName(self::NAME)
$this->translator->translate( ->setAliases(self::ALIASES)
'Processes and generates the previews for every URL, improving performance for later web requests.' ->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; $page = 1;
do { do {
@@ -65,7 +68,7 @@ class GeneratePreviewCommand extends Command
(new SymfonyStyle($input, $output))->success($this->translator->translate('Finished processing all URLs')); (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 { try {
$output->write(\sprintf($this->translator->translate('Processing URL %s...'), $url)); $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\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Util\ShortUrlBuilderTrait;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@@ -17,7 +18,10 @@ use Zend\I18n\Translator\TranslatorInterface;
class GenerateShortcodeCommand extends Command class GenerateShortcodeCommand extends Command
{ {
const NAME = 'shortcode:generate'; use ShortUrlBuilderTrait;
public const NAME = 'short-code:generate';
private const ALIASES = ['shortcode:generate'];
/** /**
* @var UrlShortenerInterface * @var UrlShortenerInterface
@@ -43,36 +47,38 @@ class GenerateShortcodeCommand extends Command
parent::__construct(null); parent::__construct(null);
} }
public function configure() protected function configure(): void
{ {
$this->setName(self::NAME) $this
->setDescription( ->setName(self::NAME)
$this->translator->translate('Generates a short code for provided URL and returns the short URL') ->setAliases(self::ALIASES)
) ->setDescription(
->addArgument('longUrl', InputArgument::REQUIRED, $this->translator->translate('The long URL to parse')) $this->translator->translate('Generates a short code for provided URL and returns the short URL')
->addOption( )
'tags', ->addArgument('longUrl', InputArgument::REQUIRED, $this->translator->translate('The long URL to parse'))
't', ->addOption(
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'tags',
$this->translator->translate('Tags to apply to the new short URL') 't',
) InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
->addOption('validSince', 's', InputOption::VALUE_REQUIRED, $this->translator->translate( $this->translator->translate('Tags to apply to the new short URL')
'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('validSince', 's', InputOption::VALUE_REQUIRED, $this->translator->translate(
)) 'The date from which this short URL will be valid. '
->addOption('validUntil', 'u', InputOption::VALUE_REQUIRED, $this->translator->translate( . 'If someone tries to access it before this date, it will not be found.'
'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('validUntil', 'u', InputOption::VALUE_REQUIRED, $this->translator->translate(
)) 'The date until which this short URL will be valid. '
->addOption('customSlug', 'c', InputOption::VALUE_REQUIRED, $this->translator->translate( . 'If someone tries to access it after this date, it will not be found.'
'If provided, this slug will be used instead of generating a short code' ))
)) ->addOption('customSlug', 'c', InputOption::VALUE_REQUIRED, $this->translator->translate(
->addOption('maxVisits', 'm', InputOption::VALUE_REQUIRED, $this->translator->translate( 'If provided, this slug will be used instead of generating a short code'
'This will limit the number of visits for this short URL.' ))
)); ->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); $io = new SymfonyStyle($input, $output);
$longUrl = $input->getArgument('longUrl'); $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); $io = new SymfonyStyle($input, $output);
$longUrl = $input->getArgument('longUrl'); $longUrl = $input->getArgument('longUrl');
@@ -115,10 +121,8 @@ class GenerateShortcodeCommand extends Command
$this->getOptionalDate($input, 'validUntil'), $this->getOptionalDate($input, 'validUntil'),
$customSlug, $customSlug,
$maxVisits !== null ? (int) $maxVisits : null $maxVisits !== null ? (int) $maxVisits : null
); )->getShortCode();
$shortUrl = (new Uri())->withPath($shortCode) $shortUrl = $this->buildShortUrl($this->domainConfig, $shortCode);
->withScheme($this->domainConfig['schema'])
->withHost($this->domainConfig['hostname']);
$io->writeln([ $io->writeln([
\sprintf('%s <info>%s</info>', $this->translator->translate('Processed long URL:'), $longUrl), \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); $since = $input->getOption($fieldName);
return $since !== null ? new \DateTime($since) : null; return $since !== null ? new \DateTime($since) : null;

View File

@@ -15,7 +15,8 @@ use Zend\I18n\Translator\TranslatorInterface;
class GetVisitsCommand extends Command class GetVisitsCommand extends Command
{ {
const NAME = 'shortcode:visits'; public const NAME = 'short-code:visits';
private const ALIASES = ['shortcode:visits'];
/** /**
* @var VisitsTrackerInterface * @var VisitsTrackerInterface
@@ -33,9 +34,11 @@ class GetVisitsCommand extends Command
parent::__construct(); parent::__construct();
} }
public function configure() protected function configure(): void
{ {
$this->setName(self::NAME) $this
->setName(self::NAME)
->setAliases(self::ALIASES)
->setDescription( ->setDescription(
$this->translator->translate('Returns the detailed visits information for provided short code') $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'); $shortCode = $input->getArgument('shortCode');
if (! empty($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); $io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode'); $shortCode = $input->getArgument('shortCode');
@@ -85,20 +88,23 @@ class GetVisitsCommand extends Command
$rows = []; $rows = [];
foreach ($visits as $row) { foreach ($visits as $row) {
$rowData = $row->jsonSerialize(); $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); $rows[] = \array_values($rowData);
} }
$io->table([ $io->table([
$this->translator->translate('Referer'), $this->translator->translate('Referer'),
$this->translator->translate('Date'), $this->translator->translate('Date'),
$this->translator->translate('Remote Address'),
$this->translator->translate('User agent'), $this->translator->translate('User agent'),
$this->translator->translate('Country'),
], $rows); ], $rows);
} }
protected function getDateOption(InputInterface $input, $key) private function getDateOption(InputInterface $input, $key)
{ {
$value = $input->getOption($key); $value = $input->getOption($key);
if (! empty($value)) { 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\Adapter\PaginableRepositoryAdapter;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
@@ -17,7 +18,8 @@ class ListShortcodesCommand extends Command
{ {
use PaginatorUtilsTrait; use PaginatorUtilsTrait;
const NAME = 'shortcode:list'; public const NAME = 'short-code:list';
private const ALIASES = ['shortcode:list'];
/** /**
* @var ShortUrlServiceInterface * @var ShortUrlServiceInterface
@@ -27,59 +29,69 @@ class ListShortcodesCommand extends Command
* @var TranslatorInterface * @var TranslatorInterface
*/ */
private $translator; 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->shortUrlService = $shortUrlService;
$this->translator = $translator; $this->translator = $translator;
parent::__construct(); parent::__construct();
$this->domainConfig = $domainConfig;
} }
public function configure() protected function configure(): void
{ {
$this->setName(self::NAME) $this
->setDescription($this->translator->translate('List all short URLs')) ->setName(self::NAME)
->addOption( ->setAliases(self::ALIASES)
'page', ->setDescription($this->translator->translate('List all short URLs'))
'p', ->addOption(
InputOption::VALUE_OPTIONAL, 'page',
sprintf( 'p',
$this->translator->translate('The first page to list (%s items per page)'), InputOption::VALUE_OPTIONAL,
PaginableRepositoryAdapter::ITEMS_PER_PAGE sprintf(
), $this->translator->translate('The first page to list (%s items per page)'),
1 PaginableRepositoryAdapter::ITEMS_PER_PAGE
) ),
->addOption( 1
'searchTerm', )
's', ->addOption(
InputOption::VALUE_OPTIONAL, 'searchTerm',
$this->translator->translate( 's',
'A query used to filter results by searching for it on the longUrl and shortCode fields' 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', ->addOption(
InputOption::VALUE_OPTIONAL, 'tags',
$this->translator->translate('A comma-separated list of tags to filter results') 't',
) InputOption::VALUE_OPTIONAL,
->addOption( $this->translator->translate('A comma-separated list of tags to filter results')
'orderBy', )
'o', ->addOption(
InputOption::VALUE_OPTIONAL, 'orderBy',
$this->translator->translate( 'o',
'The field from which we want to order by. Pass ASC or DESC separated by a comma' 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, ->addOption(
InputOption::VALUE_NONE, 'showTags',
$this->translator->translate('Whether to display the tags or not') 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); $io = new SymfonyStyle($input, $output);
$page = (int) $input->getOption('page'); $page = (int) $input->getOption('page');
@@ -87,6 +99,7 @@ class ListShortcodesCommand extends Command
$tags = $input->getOption('tags'); $tags = $input->getOption('tags');
$tags = ! empty($tags) ? \explode(',', $tags) : []; $tags = ! empty($tags) ? \explode(',', $tags) : [];
$showTags = $input->getOption('showTags'); $showTags = $input->getOption('showTags');
$transformer = new ShortUrlDataTransformer($this->domainConfig);
do { do {
$result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input)); $result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input));
@@ -94,7 +107,8 @@ class ListShortcodesCommand extends Command
$headers = [ $headers = [
$this->translator->translate('Short code'), $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('Date created'),
$this->translator->translate('Visits count'), $this->translator->translate('Visits count'),
]; ];
@@ -104,17 +118,14 @@ class ListShortcodesCommand extends Command
$rows = []; $rows = [];
foreach ($result as $row) { foreach ($result as $row) {
$shortUrl = $row->jsonSerialize(); $shortUrl = $transformer->transform($row);
if ($showTags) { if ($showTags) {
$shortUrl['tags'] = []; $shortUrl['tags'] = \implode(', ', $shortUrl['tags']);
foreach ($row->getTags() as $tag) {
$shortUrl['tags'][] = $tag->getName();
}
$shortUrl['tags'] = implode(', ', $shortUrl['tags']);
} else { } else {
unset($shortUrl['tags']); unset($shortUrl['tags']);
} }
unset($shortUrl['originalUrl']);
$rows[] = \array_values($shortUrl); $rows[] = \array_values($shortUrl);
} }
$io->table($headers, $rows); $io->table($headers, $rows);
@@ -138,7 +149,7 @@ class ListShortcodesCommand extends Command
return null; return null;
} }
$orderBy = explode(',', $orderBy); $orderBy = \explode(',', $orderBy);
return count($orderBy) === 1 ? $orderBy[0] : [$orderBy[0] => $orderBy[1]]; 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 class ResolveUrlCommand extends Command
{ {
const NAME = 'shortcode:parse'; public const NAME = 'short-code:parse';
private const ALIASES = ['shortcode:parse'];
/** /**
* @var UrlShortenerInterface * @var UrlShortenerInterface
@@ -33,18 +34,20 @@ class ResolveUrlCommand extends Command
parent::__construct(null); parent::__construct(null);
} }
public function configure() protected function configure(): void
{ {
$this->setName(self::NAME) $this
->setDescription($this->translator->translate('Returns the long URL behind a short code')) ->setName(self::NAME)
->addArgument( ->setAliases(self::ALIASES)
'shortCode', ->setDescription($this->translator->translate('Returns the long URL behind a short code'))
InputArgument::REQUIRED, ->addArgument(
$this->translator->translate('The short code to parse') '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'); $shortCode = $input->getArgument('shortCode');
if (! empty($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); $io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode'); $shortCode = $input->getArgument('shortCode');
try { try {
$longUrl = $this->urlShortener->shortCodeToUrl($shortCode); $url = $this->urlShortener->shortCodeToUrl($shortCode);
$output->writeln(\sprintf('%s <info>%s</info>', $this->translator->translate('Long URL:'), $longUrl)); $output->writeln(
\sprintf('%s <info>%s</info>', $this->translator->translate('Long URL:'), $url->getLongUrl())
);
} catch (InvalidShortCodeException $e) { } catch (InvalidShortCodeException $e) {
$io->error( $io->error(
\sprintf($this->translator->translate('Provided short code "%s" has an invalid format.'), $shortCode) \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\Exception\WrongIpException;
use Shlinkio\Shlink\Common\Service\IpLocationResolverInterface; use Shlinkio\Shlink\Common\Service\IpLocationResolverInterface;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Entity\VisitLocation; use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Service\VisitServiceInterface; use Shlinkio\Shlink\Core\Service\VisitServiceInterface;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
@@ -15,8 +16,7 @@ use Zend\I18n\Translator\TranslatorInterface;
class ProcessVisitsCommand extends Command class ProcessVisitsCommand extends Command
{ {
const LOCALHOST = '127.0.0.1'; public const NAME = 'visit:process';
const NAME = 'visit:process';
/** /**
* @var VisitServiceInterface * @var VisitServiceInterface
@@ -55,16 +55,18 @@ class ProcessVisitsCommand extends Command
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$visits = $this->visitService->getUnlocatedVisits(); $visits = $this->visitService->getUnlocatedVisits();
$count = 0;
foreach ($visits as $visit) { foreach ($visits as $visit) {
$ipAddr = $visit->getRemoteAddr(); $ipAddr = $visit->getRemoteAddr();
$io->write(sprintf('%s <info>%s</info>', $this->translator->translate('Processing IP'), $ipAddr)); $io->write(\sprintf('%s <info>%s</info>', $this->translator->translate('Processing IP'), $ipAddr));
if ($ipAddr === self::LOCALHOST) { if ($ipAddr === IpAddress::LOCALHOST) {
$io->writeln( $io->writeln(
sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address')) \sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address'))
); );
continue; continue;
} }
$count++;
try { try {
$result = $this->ipLocationResolver->resolveIpLocation($ipAddr); $result = $this->ipLocationResolver->resolveIpLocation($ipAddr);
@@ -73,12 +75,27 @@ class ProcessVisitsCommand extends Command
$visit->setVisitLocation($location); $visit->setVisitLocation($location);
$this->visitService->saveVisit($visit); $this->visitService->saveVisit($visit);
$io->writeln(sprintf( $io->writeln(\sprintf(
' (' . $this->translator->translate('Address located at "%s"') . ')', ' (' . $this->translator->translate('Address located at "%s"') . ')',
$location->getCityName() $location->getCityName()
)); ));
} catch (WrongIpException $e) { } 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\Argument;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand; use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyService; use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Symfony\Component\Console\Application; use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
@@ -37,7 +38,8 @@ class GenerateKeyCommandTest extends TestCase
*/ */
public function noExpirationDateIsDefinedIfNotProvided() public function noExpirationDateIsDefinedIfNotProvided()
{ {
$this->apiKeyService->create(null)->shouldBeCalledTimes(1); $this->apiKeyService->create(null)->shouldBeCalledTimes(1)
->willReturn(new ApiKey());
$this->commandTester->execute([ $this->commandTester->execute([
'command' => 'api-key:generate', 'command' => 'api-key:generate',
]); ]);
@@ -46,9 +48,10 @@ class GenerateKeyCommandTest extends TestCase
/** /**
* @test * @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([ $this->commandTester->execute([
'command' => 'api-key:generate', 'command' => 'api-key:generate',
'--expirationDate' => '2016-01-01', '--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\Console\Tester\CommandTester;
use Symfony\Component\Filesystem\Exception\IOException; use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
use Zend\Config\Writer\WriterInterface; use Zend\Config\Writer\WriterInterface;
@@ -55,6 +56,9 @@ class InstallCommandTest extends TestCase
$configCustomizers = $this->prophesize(ConfigCustomizerManagerInterface::class); $configCustomizers = $this->prophesize(ConfigCustomizerManagerInterface::class);
$configCustomizers->get(Argument::cetera())->willReturn($configCustomizer->reveal()); $configCustomizers->get(Argument::cetera())->willReturn($configCustomizer->reveal());
$finder = $this->prophesize(PhpExecutableFinder::class);
$finder->find(false)->willReturn('php');
$app = new Application(); $app = new Application();
$helperSet = $app->getHelperSet(); $helperSet = $app->getHelperSet();
$helperSet->set($processHelper->reveal()); $helperSet->set($processHelper->reveal());
@@ -62,7 +66,9 @@ class InstallCommandTest extends TestCase
$this->command = new InstallCommand( $this->command = new InstallCommand(
$this->configWriter->reveal(), $this->configWriter->reveal(),
$this->filesystem->reveal(), $this->filesystem->reveal(),
$configCustomizers->reveal() $configCustomizers->reveal(),
false,
$finder->reveal()
); );
$app->add($this->command); $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\Argument;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Shortcode\GenerateShortcodeCommand; use Shlinkio\Shlink\CLI\Command\Shortcode\GenerateShortcodeCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Application; use Symfony\Component\Console\Application;
@@ -41,8 +42,12 @@ class GenerateShortcodeCommandTest extends TestCase
*/ */
public function properShortCodeIsCreatedIfLongUrlIsCorrect() public function properShortCodeIsCreatedIfLongUrlIsCorrect()
{ {
$this->urlShortener->urlToShortCode(Argument::cetera())->willReturn('abc123') $this->urlShortener->urlToShortCode(Argument::cetera())
->shouldBeCalledTimes(1); ->willReturn(
(new ShortUrl())->setShortCode('abc123')
->setLongUrl('')
)
->shouldBeCalledTimes(1);
$this->commandTester->execute([ $this->commandTester->execute([
'command' => 'shortcode:generate', 'command' => 'shortcode:generate',

View File

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

View File

@@ -30,7 +30,7 @@ class ListShortcodesCommandTest extends TestCase
{ {
$this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class); $this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class);
$app = new Application(); $app = new Application();
$command = new ListShortcodesCommand($this->shortUrlService->reveal(), Translator::factory([])); $command = new ListShortcodesCommand($this->shortUrlService->reveal(), Translator::factory([]), []);
$app->add($command); $app->add($command);
$this->commandTester = new CommandTester($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 // The paginator will return more than one page for the first 3 times
$data = []; $data = [];
for ($i = 0; $i < 50; $i++) { for ($i = 0; $i < 50; $i++) {
$data[] = new ShortUrl(); $data[] = (new ShortUrl())->setLongUrl('url_' . $i);
} }
$this->shortUrlService->listShortUrls(Argument::cetera())->will(function () use (&$data) { $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 // The paginator will return more than one page
$data = []; $data = [];
for ($i = 0; $i < 30; $i++) { 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))) $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 PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Shortcode\ResolveUrlCommand; use Shlinkio\Shlink\CLI\Command\Shortcode\ResolveUrlCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\UrlShortener;
@@ -41,7 +42,8 @@ class ResolveUrlCommandTest extends TestCase
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$expectedUrl = 'http://domain.com/foo/bar'; $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); ->shouldBeCalledTimes(1);
$this->commandTester->execute([ $this->commandTester->execute([

View File

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

View File

@@ -7,7 +7,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\ProcessVisitsCommand; 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\Entity\Visit;
use Shlinkio\Shlink\Core\Service\VisitService; use Shlinkio\Shlink\Core\Service\VisitService;
use Symfony\Component\Console\Application; use Symfony\Component\Console\Application;
@@ -32,7 +32,9 @@ class ProcessVisitsCommandTest extends TestCase
public function setUp() public function setUp()
{ {
$this->visitService = $this->prophesize(VisitService::class); $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( $command = new ProcessVisitsCommand(
$this->visitService->reveal(), $this->visitService->reveal(),
$this->ipResolver->reveal(), $this->ipResolver->reveal(),
@@ -65,9 +67,9 @@ class ProcessVisitsCommandTest extends TestCase
'command' => 'visit:process', 'command' => 'visit:process',
]); ]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$this->assertTrue(strpos($output, 'Processing IP 1.2.3.4') === 0); $this->assertEquals(0, \strpos($output, 'Processing IP 1.2.3.0'));
$this->assertTrue(strpos($output, 'Processing IP 4.3.2.1') > 0); $this->assertGreaterThan(0, \strpos($output, 'Processing IP 4.3.2.0'));
$this->assertTrue(strpos($output, 'Processing IP 12.34.56.78') > 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) $this->visitService->getUnlocatedVisits()->willReturn($visits)
->shouldBeCalledTimes(1); ->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([]) $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
->shouldBeCalledTimes(count($visits) - 2); ->shouldBeCalledTimes(\count($visits) - 2);
$this->commandTester->execute([ $this->commandTester->execute([
'command' => 'visit:process', 'command' => 'visit:process',
]); ]);
$output = $this->commandTester->getDisplay(); $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, Image\ImageBuilder::class => Image\ImageBuilderFactory::class,
Service\IpLocationResolver::class => ConfigAbstractFactory::class, Service\IpApiLocationResolver::class => ConfigAbstractFactory::class,
Service\PreviewGenerator::class => ConfigAbstractFactory::class, Service\PreviewGenerator::class => ConfigAbstractFactory::class,
], ],
'aliases' => [ 'aliases' => [
@@ -51,7 +51,7 @@ return [
ConfigAbstractFactory::class => [ ConfigAbstractFactory::class => [
TranslatorExtension::class => ['translator'], TranslatorExtension::class => ['translator'],
LocaleMiddleware::class => ['translator'], LocaleMiddleware::class => ['translator'],
Service\IpLocationResolver::class => ['httpClient'], Service\IpApiLocationResolver::class => ['httpClient'],
Service\PreviewGenerator::class => [ Service\PreviewGenerator::class => [
ImageBuilder::class, ImageBuilder::class,
Filesystem::class, Filesystem::class,

View File

@@ -3,6 +3,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common; 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. * Gets the value of an environment variable. Supports boolean, empty and null.
* This is basically Laravel's env helper * This is basically Laravel's env helper

View File

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

View File

@@ -5,8 +5,8 @@ namespace Shlinkio\Shlink\Common\Exception;
class WrongIpException extends RuntimeException 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

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

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ class PixelResponse extends Response
private function createBody(): StreamInterface private function createBody(): StreamInterface
{ {
$body = new Stream('php://temp', 'wb+'); $body = new Stream('php://temp', 'wb+');
$body->write(\base64_decode(self::BASE_64_IMAGE)); $body->write((string) \base64_decode(self::BASE_64_IMAGE));
$body->rewind(); $body->rewind();
return $body; 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 * @throws WrongIpException
*/ */
public function resolveIpLocation(string $ipAddress): array; 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

@@ -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

@@ -17,6 +17,7 @@ return [
'dependencies' => [ 'dependencies' => [
'factories' => [ 'factories' => [
Options\AppOptions::class => Options\AppOptionsFactory::class, Options\AppOptions::class => Options\AppOptionsFactory::class,
Options\DeleteShortUrlsOptions::class => Options\DeleteShortUrlsOptionsFactory::class,
NotFoundHandler::class => ConfigAbstractFactory::class, NotFoundHandler::class => ConfigAbstractFactory::class,
// Services // Services
@@ -25,6 +26,7 @@ return [
Service\ShortUrlService::class => ConfigAbstractFactory::class, Service\ShortUrlService::class => ConfigAbstractFactory::class,
Service\VisitService::class => ConfigAbstractFactory::class, Service\VisitService::class => ConfigAbstractFactory::class,
Service\Tag\TagService::class => ConfigAbstractFactory::class, Service\Tag\TagService::class => ConfigAbstractFactory::class,
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
// Middleware // Middleware
Action\RedirectAction::class => ConfigAbstractFactory::class, Action\RedirectAction::class => ConfigAbstractFactory::class,
@@ -42,7 +44,6 @@ return [
Service\UrlShortener::class => [ Service\UrlShortener::class => [
'httpClient', 'httpClient',
'em', 'em',
Cache::class,
'config.url_shortener.validate_url', 'config.url_shortener.validate_url',
'config.url_shortener.shortcode_chars', 'config.url_shortener.shortcode_chars',
], ],
@@ -50,6 +51,7 @@ return [
Service\ShortUrlService::class => ['em'], Service\ShortUrlService::class => ['em'],
Service\VisitService::class => ['em'], Service\VisitService::class => ['em'],
Service\Tag\TagService::class => ['em'], Service\Tag\TagService::class => ['em'],
Service\ShortUrl\DeleteShortUrlService::class => ['em', Options\DeleteShortUrlsOptions::class],
// Middleware // Middleware
Action\RedirectAction::class => [ Action\RedirectAction::class => [

View File

@@ -65,14 +65,14 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
$disableTrackParam = $this->appOptions->getDisableTrackParam(); $disableTrackParam = $this->appOptions->getDisableTrackParam();
try { try {
$longUrl = $this->urlShortener->shortCodeToUrl($shortCode); $url = $this->urlShortener->shortCodeToUrl($shortCode);
// Track visit to this short code // Track visit to this short code
if ($disableTrackParam === null || ! \array_key_exists($disableTrackParam, $query)) { if ($disableTrackParam === null || ! \array_key_exists($disableTrackParam, $query)) {
$this->visitTracker->track($shortCode, $request); $this->visitTracker->track($shortCode, $request);
} }
return $this->createResp($longUrl); return $this->createResp($url->getLongUrl());
} catch (InvalidShortCodeException | EntityDoesNotExistException $e) { } catch (InvalidShortCodeException | EntityDoesNotExistException $e) {
$this->logger->warning('An error occurred while tracking short code.' . PHP_EOL . $e); $this->logger->warning('An error occurred while tracking short code.' . PHP_EOL . $e);
return $this->buildErrorResponse($request, $handler); return $this->buildErrorResponse($request, $handler);

View File

@@ -60,7 +60,7 @@ class PreviewAction implements MiddlewareInterface
try { try {
$url = $this->urlShortener->shortCodeToUrl($shortCode); $url = $this->urlShortener->shortCodeToUrl($shortCode);
$imagePath = $this->previewGenerator->generatePreview($url); $imagePath = $this->previewGenerator->generatePreview($url->getLongUrl());
return $this->generateImageResponse($imagePath); return $this->generateImageResponse($imagePath);
} catch (InvalidShortCodeException | EntityDoesNotExistException | PreviewGenerationException $e) { } catch (InvalidShortCodeException | EntityDoesNotExistException | PreviewGenerationException $e) {
$this->logger->warning('An error occurred while generating preview image.' . PHP_EOL . $e); $this->logger->warning('An error occurred while generating preview image.' . PHP_EOL . $e);

View File

@@ -22,6 +22,10 @@ class QrCodeAction implements MiddlewareInterface
{ {
use ErrorResponseBuilderTrait; use ErrorResponseBuilderTrait;
private const DEFAULT_SIZE = 300;
private const MIN_SIZE = 50;
private const MAX_SIZE = 1000;
/** /**
* @var RouterInterface * @var RouterInterface
*/ */
@@ -82,11 +86,11 @@ class QrCodeAction implements MiddlewareInterface
*/ */
private function getSizeParam(Request $request): int private function getSizeParam(Request $request): int
{ {
$size = (int) $request->getAttribute('size', 300); $size = (int) $request->getAttribute('size', self::DEFAULT_SIZE);
if ($size < 50) { if ($size < self::MIN_SIZE) {
return 50; return self::MIN_SIZE;
} }
return $size > 1000 ? 1000 : $size; return $size > self::MAX_SIZE ? self::MAX_SIZE : $size;
} }
} }

View File

@@ -7,43 +7,44 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
/** /**
* Class ShortUrl * Class ShortUrl
* @author * @author
* @link * @link
* *
* @ORM\Entity(repositoryClass="Shlinkio\Shlink\Core\Repository\ShortUrlRepository") * @ORM\Entity(repositoryClass=ShortUrlRepository::class)
* @ORM\Table(name="short_urls") * @ORM\Table(name="short_urls")
*/ */
class ShortUrl extends AbstractEntity implements \JsonSerializable class ShortUrl extends AbstractEntity
{ {
/** /**
* @var string * @var string
* @ORM\Column(name="original_url", type="string", nullable=false, length=1024) * @ORM\Column(name="original_url", type="string", nullable=false, length=1024)
*/ */
protected $originalUrl; private $originalUrl;
/** /**
* @var string * @var string
* @ORM\Column( * @ORM\Column(
* name="short_code", * name="short_code",
* type="string", * type="string",
* nullable=false, * nullable=false,
* length=10, * length=255,
* unique=true * unique=true
* ) * )
*/ */
protected $shortCode; private $shortCode;
/** /**
* @var \DateTime * @var \DateTime
* @ORM\Column(name="date_created", type="datetime") * @ORM\Column(name="date_created", type="datetime")
*/ */
protected $dateCreated; private $dateCreated;
/** /**
* @var Collection|Visit[] * @var Collection|Visit[]
* @ORM\OneToMany(targetEntity=Visit::class, mappedBy="shortUrl", fetch="EXTRA_LAZY") * @ORM\OneToMany(targetEntity=Visit::class, mappedBy="shortUrl", fetch="EXTRA_LAZY")
*/ */
protected $visits; private $visits;
/** /**
* @var Collection|Tag[] * @var Collection|Tag[]
* @ORM\ManyToMany(targetEntity=Tag::class, cascade={"persist"}) * @ORM\ManyToMany(targetEntity=Tag::class, cascade={"persist"})
@@ -53,83 +54,75 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
* @ORM\JoinColumn(name="tag_id", referencedColumnName="id") * @ORM\JoinColumn(name="tag_id", referencedColumnName="id")
* }) * })
*/ */
protected $tags; private $tags;
/** /**
* @var \DateTime * @var \DateTime
* @ORM\Column(name="valid_since", type="datetime", nullable=true) * @ORM\Column(name="valid_since", type="datetime", nullable=true)
*/ */
protected $validSince; private $validSince;
/** /**
* @var \DateTime * @var \DateTime
* @ORM\Column(name="valid_until", type="datetime", nullable=true) * @ORM\Column(name="valid_until", type="datetime", nullable=true)
*/ */
protected $validUntil; private $validUntil;
/** /**
* @var integer * @var integer
* @ORM\Column(name="max_visits", type="integer", nullable=true) * @ORM\Column(name="max_visits", type="integer", nullable=true)
*/ */
protected $maxVisits; private $maxVisits;
/**
* ShortUrl constructor.
*/
public function __construct() public function __construct()
{ {
$this->shortCode = '';
$this->dateCreated = new \DateTime(); $this->dateCreated = new \DateTime();
$this->visits = new ArrayCollection(); $this->visits = new ArrayCollection();
$this->shortCode = '';
$this->tags = new ArrayCollection(); $this->tags = new ArrayCollection();
} }
/** public function getLongUrl(): string
* @return string
*/
public function getOriginalUrl(): string
{ {
return $this->originalUrl; return $this->originalUrl;
} }
/** public function setLongUrl(string $longUrl): self
* @param string $originalUrl
* @return $this
*/
public function setOriginalUrl(string $originalUrl)
{ {
$this->originalUrl = $originalUrl; $this->originalUrl = $longUrl;
return $this; 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 public function getShortCode(): string
{ {
return $this->shortCode; return $this->shortCode;
} }
/** public function setShortCode(string $shortCode): self
* @param string $shortCode
* @return $this
*/
public function setShortCode(string $shortCode)
{ {
$this->shortCode = $shortCode; $this->shortCode = $shortCode;
return $this; return $this;
} }
/**
* @return \DateTime
*/
public function getDateCreated(): \DateTime public function getDateCreated(): \DateTime
{ {
return $this->dateCreated; return $this->dateCreated;
} }
/** public function setDateCreated(\DateTime $dateCreated): self
* @param \DateTime $dateCreated
* @return $this
*/
public function setDateCreated(\DateTime $dateCreated)
{ {
$this->dateCreated = $dateCreated; $this->dateCreated = $dateCreated;
return $this; return $this;
@@ -145,55 +138,36 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
/** /**
* @param Collection|Tag[] $tags * @param Collection|Tag[] $tags
* @return $this
*/ */
public function setTags(Collection $tags) public function setTags(Collection $tags): self
{ {
$this->tags = $tags; $this->tags = $tags;
return $this; return $this;
} }
/** public function addTag(Tag $tag): self
* @param Tag $tag
* @return $this
*/
public function addTag(Tag $tag)
{ {
$this->tags->add($tag); $this->tags->add($tag);
return $this; return $this;
} }
/** public function getValidSince(): ?\DateTime
* @return \DateTime|null
*/
public function getValidSince()
{ {
return $this->validSince; return $this->validSince;
} }
/** public function setValidSince(?\DateTime $validSince): self
* @param \DateTime|null $validSince
* @return $this|self
*/
public function setValidSince($validSince): self
{ {
$this->validSince = $validSince; $this->validSince = $validSince;
return $this; return $this;
} }
/** public function getValidUntil(): ?\DateTime
* @return \DateTime|null
*/
public function getValidUntil()
{ {
return $this->validUntil; return $this->validUntil;
} }
/** public function setValidUntil(?\DateTime $validUntil): self
* @param \DateTime|null $validUntil
* @return $this|self
*/
public function setValidUntil($validUntil): self
{ {
$this->validUntil = $validUntil; $this->validUntil = $validUntil;
return $this; return $this;
@@ -201,11 +175,11 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
public function getVisitsCount(): int public function getVisitsCount(): int
{ {
return count($this->visits); return \count($this->visits);
} }
/** /**
* @param Collection $visits * @param Collection|Visit[] $visits
* @return ShortUrl * @return ShortUrl
* @internal * @internal
*/ */
@@ -215,19 +189,12 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
return $this; return $this;
} }
/** public function getMaxVisits(): ?int
* @return int|null
*/
public function getMaxVisits()
{ {
return $this->maxVisits; return $this->maxVisits;
} }
/** public function setMaxVisits(?int $maxVisits): self
* @param int|null $maxVisits
* @return $this|self
*/
public function setMaxVisits($maxVisits): self
{ {
$this->maxVisits = $maxVisits; $this->maxVisits = $maxVisits;
return $this; return $this;
@@ -237,22 +204,4 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
{ {
return $this->maxVisits !== null && $this->getVisitsCount() >= $this->maxVisits; 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 * @var string
* @ORM\Column(unique=true) * @ORM\Column(unique=true)
*/ */
protected $name; private $name;
public function __construct($name = null) public function __construct($name = null)
{ {
$this->name = $name; $this->name = $name;
} }
/** public function getName(): string
* @return string
*/
public function getName()
{ {
return $this->name; return $this->name;
} }
/** public function setName(string $name)
* @param string $name
* @return $this
*/
public function setName($name)
{ {
$this->name = $name; $this->name = $name;
return $this; return $this;
} }
/** public function jsonSerialize(): string
* 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 $this->name; return $this->name;
} }

View File

@@ -5,13 +5,16 @@ namespace Shlinkio\Shlink\Core\Entity;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Shlinkio\Shlink\Common\Entity\AbstractEntity; 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 * Class Visit
* @author * @author
* @link * @link
* *
* @ORM\Entity(repositoryClass="Shlinkio\Shlink\Core\Repository\VisitRepository") * @ORM\Entity(repositoryClass=VisitRepository::class)
* @ORM\Table(name="visits") * @ORM\Table(name="visits")
*/ */
class Visit extends AbstractEntity implements \JsonSerializable class Visit extends AbstractEntity implements \JsonSerializable
@@ -20,143 +23,115 @@ class Visit extends AbstractEntity implements \JsonSerializable
* @var string * @var string
* @ORM\Column(type="string", length=256, nullable=true) * @ORM\Column(type="string", length=256, nullable=true)
*/ */
protected $referer; private $referer;
/** /**
* @var \DateTime * @var \DateTime
* @ORM\Column(type="datetime", nullable=false) * @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) * @ORM\Column(type="string", length=256, name="remote_addr", nullable=true)
*/ */
protected $remoteAddr; private $remoteAddr;
/** /**
* @var string * @var string
* @ORM\Column(type="string", length=256, name="user_agent", nullable=true) * @ORM\Column(type="string", length=256, name="user_agent", nullable=true)
*/ */
protected $userAgent; private $userAgent;
/** /**
* @var ShortUrl * @var ShortUrl
* @ORM\ManyToOne(targetEntity=ShortUrl::class) * @ORM\ManyToOne(targetEntity=ShortUrl::class)
* @ORM\JoinColumn(name="short_url_id", referencedColumnName="id") * @ORM\JoinColumn(name="short_url_id", referencedColumnName="id")
*/ */
protected $shortUrl; private $shortUrl;
/** /**
* @var VisitLocation * @var VisitLocation
* @ORM\ManyToOne(targetEntity=VisitLocation::class, cascade={"persist"}) * @ORM\ManyToOne(targetEntity=VisitLocation::class, cascade={"persist"})
* @ORM\JoinColumn(name="visit_location_id", referencedColumnName="id", nullable=true) * @ORM\JoinColumn(name="visit_location_id", referencedColumnName="id", nullable=true)
*/ */
protected $visitLocation; private $visitLocation;
public function __construct() public function __construct()
{ {
$this->date = new \DateTime(); $this->date = new \DateTime();
} }
/**
* @return string
*/
public function getReferer(): string public function getReferer(): string
{ {
return $this->referer; return $this->referer;
} }
/** public function setReferer(string $referer): self
* @param string $referer
* @return $this
*/
public function setReferer($referer): self
{ {
$this->referer = $referer; $this->referer = $referer;
return $this; return $this;
} }
/**
* @return \DateTime
*/
public function getDate(): \DateTime public function getDate(): \DateTime
{ {
return $this->date; return $this->date;
} }
/** public function setDate(\DateTime $date): self
* @param \DateTime $date
* @return $this
*/
public function setDate($date): self
{ {
$this->date = $date; $this->date = $date;
return $this; return $this;
} }
/**
* @return ShortUrl
*/
public function getShortUrl(): ShortUrl public function getShortUrl(): ShortUrl
{ {
return $this->shortUrl; return $this->shortUrl;
} }
/** public function setShortUrl(ShortUrl $shortUrl): self
* @param ShortUrl $shortUrl
* @return $this
*/
public function setShortUrl($shortUrl): self
{ {
$this->shortUrl = $shortUrl; $this->shortUrl = $shortUrl;
return $this; return $this;
} }
/** public function getRemoteAddr(): ?string
* @return string
*/
public function getRemoteAddr(): string
{ {
return $this->remoteAddr; return $this->remoteAddr;
} }
/** public function setRemoteAddr(?string $remoteAddr): self
* @param string $remoteAddr
* @return $this
*/
public function setRemoteAddr($remoteAddr): self
{ {
$this->remoteAddr = $remoteAddr; $this->remoteAddr = $this->obfuscateAddress($remoteAddr);
return $this; return $this;
} }
/** private function obfuscateAddress(?string $address): ?string
* @return 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 public function getUserAgent(): string
{ {
return $this->userAgent; return $this->userAgent;
} }
/** public function setUserAgent(string $userAgent): self
* @param string $userAgent
* @return $this
*/
public function setUserAgent($userAgent): self
{ {
$this->userAgent = $userAgent; $this->userAgent = $userAgent;
return $this; return $this;
} }
/**
* @return VisitLocation
*/
public function getVisitLocation(): VisitLocation public function getVisitLocation(): VisitLocation
{ {
return $this->visitLocation; return $this->visitLocation;
} }
/** public function setVisitLocation(VisitLocation $visitLocation): self
* @param VisitLocation $visitLocation
* @return $this
*/
public function setVisitLocation($visitLocation): self
{ {
$this->visitLocation = $visitLocation; $this->visitLocation = $visitLocation;
return $this; return $this;
@@ -174,9 +149,11 @@ class Visit extends AbstractEntity implements \JsonSerializable
return [ return [
'referer' => $this->referer, 'referer' => $this->referer,
'date' => isset($this->date) ? $this->date->format(\DateTime::ATOM) : null, 'date' => isset($this->date) ? $this->date->format(\DateTime::ATOM) : null,
'remoteAddr' => $this->remoteAddr,
'userAgent' => $this->userAgent, 'userAgent' => $this->userAgent,
'visitLocation' => $this->visitLocation, 'visitLocation' => $this->visitLocation,
// Deprecated
'remoteAddr' => null,
]; ];
} }
} }

View File

@@ -21,159 +21,110 @@ class VisitLocation extends AbstractEntity implements ArraySerializableInterface
* @var string * @var string
* @ORM\Column(nullable=true) * @ORM\Column(nullable=true)
*/ */
protected $countryCode; private $countryCode;
/** /**
* @var string * @var string
* @ORM\Column(nullable=true) * @ORM\Column(nullable=true)
*/ */
protected $countryName; private $countryName;
/** /**
* @var string * @var string
* @ORM\Column(nullable=true) * @ORM\Column(nullable=true)
*/ */
protected $regionName; private $regionName;
/** /**
* @var string * @var string
* @ORM\Column(nullable=true) * @ORM\Column(nullable=true)
*/ */
protected $cityName; private $cityName;
/** /**
* @var string * @var string
* @ORM\Column(nullable=true) * @ORM\Column(nullable=true)
*/ */
protected $latitude; private $latitude;
/** /**
* @var string * @var string
* @ORM\Column(nullable=true) * @ORM\Column(nullable=true)
*/ */
protected $longitude; private $longitude;
/** /**
* @var string * @var string
* @ORM\Column(nullable=true) * @ORM\Column(nullable=true)
*/ */
protected $timezone; private $timezone;
/** public function getCountryCode(): string
* @return string
*/
public function getCountryCode()
{ {
return $this->countryCode; return $this->countryCode ?? '';
} }
/** public function setCountryCode(string $countryCode)
* @param string $countryCode
* @return $this
*/
public function setCountryCode($countryCode)
{ {
$this->countryCode = $countryCode; $this->countryCode = $countryCode;
return $this; return $this;
} }
/** public function getCountryName(): string
* @return string
*/
public function getCountryName()
{ {
return $this->countryName; return $this->countryName ?? '';
} }
/** public function setCountryName(string $countryName): self
* @param string $countryName
* @return $this
*/
public function setCountryName($countryName)
{ {
$this->countryName = $countryName; $this->countryName = $countryName;
return $this; return $this;
} }
/** public function getRegionName(): string
* @return string
*/
public function getRegionName()
{ {
return $this->regionName; return $this->regionName ?? '';
} }
/** public function setRegionName(string $regionName): self
* @param string $regionName
* @return $this
*/
public function setRegionName($regionName)
{ {
$this->regionName = $regionName; $this->regionName = $regionName;
return $this; return $this;
} }
/** public function getCityName(): string
* @return string
*/
public function getCityName()
{ {
return $this->cityName; return $this->cityName ?? '';
} }
/** public function setCityName(string $cityName): self
* @param string $cityName
* @return $this
*/
public function setCityName($cityName)
{ {
$this->cityName = $cityName; $this->cityName = $cityName;
return $this; return $this;
} }
/** public function getLatitude(): string
* @return string
*/
public function getLatitude()
{ {
return $this->latitude; return $this->latitude ?? '';
} }
/** public function setLatitude(string $latitude): self
* @param string $latitude
* @return $this
*/
public function setLatitude($latitude)
{ {
$this->latitude = $latitude; $this->latitude = $latitude;
return $this; return $this;
} }
/** public function getLongitude(): string
* @return string
*/
public function getLongitude()
{ {
return $this->longitude; return $this->longitude ?? '';
} }
/** public function setLongitude(string $longitude): self
* @param string $longitude
* @return $this
*/
public function setLongitude($longitude)
{ {
$this->longitude = $longitude; $this->longitude = $longitude;
return $this; return $this;
} }
/** public function getTimezone(): string
* @return string
*/
public function getTimezone()
{ {
return $this->timezone; return $this->timezone ?? '';
} }
/** public function setTimezone(string $timezone): self
* @param string $timezone
* @return $this
*/
public function setTimezone($timezone)
{ {
$this->timezone = $timezone; $this->timezone = $timezone;
return $this; return $this;
@@ -181,41 +132,36 @@ class VisitLocation extends AbstractEntity implements ArraySerializableInterface
/** /**
* Exchange internal values from provided array * 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']); $this->setCountryCode($array['country_code']);
} }
if (array_key_exists('country_name', $array)) { if (\array_key_exists('country_name', $array)) {
$this->setCountryName($array['country_name']); $this->setCountryName($array['country_name']);
} }
if (array_key_exists('region_name', $array)) { if (\array_key_exists('region_name', $array)) {
$this->setRegionName($array['region_name']); $this->setRegionName($array['region_name']);
} }
if (array_key_exists('city', $array)) { if (\array_key_exists('city', $array)) {
$this->setCityName($array['city']); $this->setCityName($array['city']);
} }
if (array_key_exists('latitude', $array)) { if (\array_key_exists('latitude', $array)) {
$this->setLatitude($array['latitude']); $this->setLatitude($array['latitude']);
} }
if (array_key_exists('longitude', $array)) { if (\array_key_exists('longitude', $array)) {
$this->setLongitude($array['longitude']); $this->setLongitude($array['longitude']);
} }
if (array_key_exists('time_zone', $array)) { if (\array_key_exists('time_zone', $array)) {
$this->setTimezone($array['time_zone']); $this->setTimezone($array['time_zone']);
} }
} }
/** /**
* Return an array representation of the object * Return an array representation of the object
*
* @return array
*/ */
public function getArrayCopy() public function getArrayCopy(): array
{ {
return [ return [
'countryCode' => $this->countryCode, 'countryCode' => $this->countryCode,
@@ -228,14 +174,7 @@ class VisitLocation extends AbstractEntity implements ArraySerializableInterface
]; ];
} }
/** public function jsonSerialize(): array
* 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 $this->getArrayCopy(); 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) public static function fromCharset($shortCode, $charSet, \Exception $previous = null)
{ {
$code = isset($previous) ? $previous->getCode() : -1; $code = $previous !== null ? $previous->getCode() : -1;
return new static( 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, $code,
$previous $previous
); );
@@ -17,6 +17,6 @@ class InvalidShortCodeException extends RuntimeException
public static function fromNotFoundShortCode($shortCode) 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

@@ -78,19 +78,34 @@ final class ShortUrlMeta
throw ValidationException::fromInputFilter($inputFilter); throw ValidationException::fromInputFilter($inputFilter);
} }
$this->validSince = $inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE); $this->validSince = $this->parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
$this->validSince = $this->validSince !== null ? new \DateTime($this->validSince) : null; $this->validUntil = $this->parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
$this->validUntil = $inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL);
$this->validUntil = $this->validUntil !== null ? new \DateTime($this->validUntil) : null;
$this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG); $this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
$this->maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS); $this->maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS);
$this->maxVisits = $this->maxVisits !== null ? (int) $this->maxVisits : null; $this->maxVisits = $this->maxVisits !== null ? (int) $this->maxVisits : null;
} }
/** /**
* @param string|\DateTime|null $date
* @return \DateTime|null * @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; return $this->validSince;
} }
@@ -103,7 +118,7 @@ final class ShortUrlMeta
/** /**
* @return \DateTime|null * @return \DateTime|null
*/ */
public function getValidUntil() public function getValidUntil(): ?\DateTime
{ {
return $this->validUntil; return $this->validUntil;
} }

View File

@@ -26,6 +26,6 @@ class AppOptionsFactory implements FactoryInterface
public function __invoke(ContainerInterface $container, $requestedName, array $options = null) public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{ {
$config = $container->has('config') ? $container->get('config') : []; $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 $orderBy = null
): array { ): array {
$qb = $this->createListQueryBuilder($searchTerm, $tags); $qb = $this->createListQueryBuilder($searchTerm, $tags);
$qb->select('s'); $qb->select('DISTINCT s');
// Set limit and offset // Set limit and offset
if ($limit !== null) { if ($limit !== null) {
@@ -47,20 +47,28 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
protected function processOrderByForList(QueryBuilder $qb, $orderBy) protected function processOrderByForList(QueryBuilder $qb, $orderBy)
{ {
$fieldName = is_array($orderBy) ? key($orderBy) : $orderBy; // Map public field names to column names
$order = is_array($orderBy) ? $orderBy[$fieldName] : 'ASC'; $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)) { if (\in_array($fieldName, ['visits', 'visitsCount', 'visitCount'], true)) {
$qb->addSelect('COUNT(v) AS totalVisits') $qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
->leftJoin('s.visits', 'v') ->leftJoin('s.visits', 'v')
->groupBy('s') ->groupBy('s')
->orderBy('totalVisits', $order); ->orderBy('totalVisits', $order);
return array_column($qb->getQuery()->getResult(), 0); return \array_column($qb->getQuery()->getResult(), 0);
} elseif (in_array($fieldName, ['originalUrl', 'shortCode', 'dateCreated'], true)) {
$qb->orderBy('s.' . $fieldName, $order);
} }
if (\array_key_exists($fieldName, $fieldNameMap)) {
$qb->orderBy('s.' . $fieldNameMap[$fieldName], $order);
}
return $qb->getQuery()->getResult(); return $qb->getQuery()->getResult();
} }
@@ -74,7 +82,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
public function countList(string $searchTerm = null, array $tags = []): int public function countList(string $searchTerm = null, array $tags = []): int
{ {
$qb = $this->createListQueryBuilder($searchTerm, $tags); $qb = $this->createListQueryBuilder($searchTerm, $tags);
$qb->select('COUNT(s)'); $qb->select('COUNT(DISTINCT s)');
return (int) $qb->getQuery()->getSingleScalarResult(); 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 // Apply search term to every searchable field if not empty
if (! empty($searchTerm)) { 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 = [ $conditions = [
$qb->expr()->like('s.originalUrl', ':searchPattern'), $qb->expr()->like('s.originalUrl', ':searchPattern'),
@@ -102,8 +113,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
// Unpack and apply search conditions // Unpack and apply search conditions
$qb->andWhere($qb->expr()->orX(...$conditions)); $qb->andWhere($qb->expr()->orX(...$conditions));
$searchTerm = '%' . $searchTerm . '%'; $qb->setParameter('searchPattern', '%' . $searchTerm . '%');
$qb->setParameter('searchPattern', $searchTerm);
} }
// Filter by tags if provided // Filter by tags if provided
@@ -119,7 +129,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
* @param string $shortCode * @param string $shortCode
* @return ShortUrl|null * @return ShortUrl|null
*/ */
public function findOneByShortCode(string $shortCode) public function findOneByShortCode(string $shortCode): ?ShortUrl
{ {
$now = new \DateTimeImmutable(); $now = new \DateTimeImmutable();

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

View File

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

View File

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

View File

@@ -12,13 +12,13 @@ interface TagServiceInterface
/** /**
* @return Tag[] * @return Tag[]
*/ */
public function listTags(); public function listTags(): array;
/** /**
* @param string[] $tagNames * @param string[] $tagNames
* @return void * @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 * Provided a list of tag names, creates all that do not exist yet
@@ -26,7 +26,7 @@ interface TagServiceInterface
* @param string[] $tagNames * @param string[] $tagNames
* @return Collection|Tag[] * @return Collection|Tag[]
*/ */
public function createTags(array $tagNames); public function createTags(array $tagNames): Collection;
/** /**
* @param string $oldName * @param string $oldName
@@ -34,5 +34,5 @@ interface TagServiceInterface
* @return Tag * @return Tag
* @throws EntityDoesNotExistException * @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\Slugify;
use Cocur\Slugify\SlugifyInterface; use Cocur\Slugify\SlugifyInterface;
use Doctrine\Common\Cache\Cache;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use GuzzleHttp\ClientInterface; use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\GuzzleException;
@@ -23,7 +22,8 @@ class UrlShortener implements UrlShortenerInterface
{ {
use TagManagerTrait; use TagManagerTrait;
const DEFAULT_CHARS = '123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ'; public const DEFAULT_CHARS = '123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ';
private const ID_INCREMENT = 200000;
/** /**
* @var ClientInterface * @var ClientInterface
@@ -37,10 +37,6 @@ class UrlShortener implements UrlShortenerInterface
* @var string * @var string
*/ */
private $chars; private $chars;
/**
* @var Cache
*/
private $cache;
/** /**
* @var SlugifyInterface * @var SlugifyInterface
*/ */
@@ -53,15 +49,13 @@ class UrlShortener implements UrlShortenerInterface
public function __construct( public function __construct(
ClientInterface $httpClient, ClientInterface $httpClient,
EntityManagerInterface $em, EntityManagerInterface $em,
Cache $cache,
$urlValidationEnabled, $urlValidationEnabled,
$chars = self::DEFAULT_CHARS, $chars = self::DEFAULT_CHARS,
SlugifyInterface $slugger = null SlugifyInterface $slugger = null
) { ) {
$this->httpClient = $httpClient; $this->httpClient = $httpClient;
$this->em = $em; $this->em = $em;
$this->cache = $cache; $this->urlValidationEnabled = (bool) $urlValidationEnabled;
$this->urlValidationEnabled = $urlValidationEnabled;
$this->chars = empty($chars) ? self::DEFAULT_CHARS : $chars; $this->chars = empty($chars) ? self::DEFAULT_CHARS : $chars;
$this->slugger = $slugger ?: new Slugify(); $this->slugger = $slugger ?: new Slugify();
} }
@@ -75,7 +69,6 @@ class UrlShortener implements UrlShortenerInterface
* @param \DateTime|null $validUntil * @param \DateTime|null $validUntil
* @param string|null $customSlug * @param string|null $customSlug
* @param int|null $maxVisits * @param int|null $maxVisits
* @return string
* @throws NonUniqueSlugException * @throws NonUniqueSlugException
* @throws InvalidUrlException * @throws InvalidUrlException
* @throws RuntimeException * @throws RuntimeException
@@ -87,19 +80,9 @@ class UrlShortener implements UrlShortenerInterface
\DateTime $validUntil = null, \DateTime $validUntil = null,
string $customSlug = null, string $customSlug = null,
int $maxVisits = null int $maxVisits = null
): string { ): ShortUrl {
// If the url already exists in the database, just return its short code // If the URL validation is enabled, check that the URL actually exists
/** @var ShortUrl|null $shortUrl */ if ($this->urlValidationEnabled) {
$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
$this->checkUrlExists($url); $this->checkUrlExists($url);
} }
$customSlug = $this->processCustomSlug($customSlug); $customSlug = $this->processCustomSlug($customSlug);
@@ -124,7 +107,7 @@ class UrlShortener implements UrlShortenerInterface
$this->em->flush(); $this->em->flush();
$this->em->commit(); $this->em->commit();
return $shortCode; return $shortUrl;
} catch (\Throwable $e) { } catch (\Throwable $e) {
if ($this->em->getConnection()->isTransactionActive()) { if ($this->em->getConnection()->isTransactionActive()) {
$this->em->rollback(); $this->em->rollback();
@@ -160,14 +143,14 @@ class UrlShortener implements UrlShortenerInterface
*/ */
private function convertAutoincrementIdToShortCode(float $id): string 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); $length = \strlen($this->chars);
$code = ''; $code = '';
while ($id > 0) { while ($id > 0) {
// Determine the value of the next higher character in the short code and prepend it // Determine the value of the next higher character in the short code and prepend it
$code = $this->chars[(int) fmod($id, $length)] . $code; $code = $this->chars[(int) \fmod($id, $length)] . $code;
$id = floor($id / $length); $id = \floor($id / $length);
} }
return $this->chars[(int) $id] . $code; return $this->chars[(int) $id] . $code;
@@ -179,7 +162,7 @@ class UrlShortener implements UrlShortenerInterface
return null; 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); $customSlug = $this->slugger->slugify($customSlug);
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy(['shortCode' => $customSlug]); $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy(['shortCode' => $customSlug]);
if ($shortUrl !== null) { 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 * Tries to find the mapped URL for provided short code. Returns null if not found
* *
* @param string $shortCode
* @return string
* @throws InvalidShortCodeException * @throws InvalidShortCodeException
* @throws EntityDoesNotExistException * @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 // Validate short code format
if (! preg_match('|[' . $this->chars . ']+|', $shortCode)) { if (! \preg_match('|[' . $this->chars . ']+|', $shortCode)) {
throw InvalidShortCodeException::fromCharset($shortCode, $this->chars); throw InvalidShortCodeException::fromCharset($shortCode, $this->chars);
} }
@@ -219,9 +194,6 @@ class UrlShortener implements UrlShortenerInterface
]); ]);
} }
// Cache the shortcode return $shortUrl;
$url = $shortUrl->getOriginalUrl();
$this->cache->save($cacheKey, $url);
return $url;
} }
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service; namespace Shlinkio\Shlink\Core\Service;
use Psr\Http\Message\UriInterface; use Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
@@ -21,7 +22,6 @@ interface UrlShortenerInterface
* @param \DateTime|null $validUntil * @param \DateTime|null $validUntil
* @param string|null $customSlug * @param string|null $customSlug
* @param int|null $maxVisits * @param int|null $maxVisits
* @return string
* @throws NonUniqueSlugException * @throws NonUniqueSlugException
* @throws InvalidUrlException * @throws InvalidUrlException
* @throws RuntimeException * @throws RuntimeException
@@ -33,15 +33,13 @@ interface UrlShortenerInterface
\DateTime $validUntil = null, \DateTime $validUntil = null,
string $customSlug = null, string $customSlug = null,
int $maxVisits = null int $maxVisits = null
): string; ): ShortUrl;
/** /**
* Tries to find the mapped URL for provided short code. Returns null if not found * Tries to find the mapped URL for provided short code. Returns null if not found
* *
* @param string $shortCode
* @return string
* @throws InvalidShortCodeException * @throws InvalidShortCodeException
* @throws EntityDoesNotExistException * @throws EntityDoesNotExistException
*/ */
public function shortCodeToUrl(string $shortCode): string; public function shortCodeToUrl(string $shortCode): ShortUrl;
} }

View File

@@ -31,7 +31,7 @@ class VisitsTracker implements VisitsTrackerInterface
* @throws ORM\ORMInvalidArgumentException * @throws ORM\ORMInvalidArgumentException
* @throws ORM\OptimisticLockException * @throws ORM\OptimisticLockException
*/ */
public function track($shortCode, ServerRequestInterface $request) public function track($shortCode, ServerRequestInterface $request): void
{ {
/** @var ShortUrl $shortUrl */ /** @var ShortUrl $shortUrl */
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
@@ -52,9 +52,8 @@ class VisitsTracker implements VisitsTrackerInterface
/** /**
* @param ServerRequestInterface $request * @param ServerRequestInterface $request
* @return string|null
*/ */
private function findOutRemoteAddr(ServerRequestInterface $request) private function findOutRemoteAddr(ServerRequestInterface $request): ?string
{ {
$forwardedFor = $request->getHeaderLine('X-Forwarded-For'); $forwardedFor = $request->getHeaderLine('X-Forwarded-For');
if (empty($forwardedFor)) { if (empty($forwardedFor)) {
@@ -62,7 +61,7 @@ class VisitsTracker implements VisitsTrackerInterface
return $serverParams['REMOTE_ADDR'] ?? null; return $serverParams['REMOTE_ADDR'] ?? null;
} }
$ips = explode(',', $forwardedFor); $ips = \explode(',', $forwardedFor);
return $ips[0] ?? null; return $ips[0] ?? null;
} }

View File

@@ -16,7 +16,7 @@ interface VisitsTrackerInterface
* @param string $shortCode * @param string $shortCode
* @param ServerRequestInterface $request * @param ServerRequestInterface $request
*/ */
public function track($shortCode, ServerRequestInterface $request); public function track($shortCode, ServerRequestInterface $request): void;
/** /**
* Returns the visits on certain short code * Returns the visits on certain short code

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Transformer;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Util\ShortUrlBuilderTrait;
class ShortUrlDataTransformer implements DataTransformerInterface
{
use ShortUrlBuilderTrait;
/**
* @var array
*/
private $domainConfig;
public function __construct(array $domainConfig)
{
$this->domainConfig = $domainConfig;
}
/**
* @param ShortUrl $value
* @return array
*/
public function transform($value): array
{
$dateCreated = $value->getDateCreated();
$longUrl = $value->getLongUrl();
$shortCode = $value->getShortCode();
return [
'shortCode' => $shortCode,
'shortUrl' => $this->buildShortUrl($this->domainConfig, $shortCode),
'longUrl' => $longUrl,
'dateCreated' => $dateCreated !== null ? $dateCreated->format(\DateTime::ATOM) : null,
'visitsCount' => $value->getVisitsCount(),
'tags' => \array_map([$this, 'serializeTag'], $value->getTags()->toArray()),
// Deprecated
'originalUrl' => $longUrl,
];
}
private function serializeTag(Tag $tag): string
{
return $tag->getName();
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Util;
use Zend\Diactoros\Uri;
trait ShortUrlBuilderTrait
{
private function buildShortUrl(array $domainConfig, string $shortCode): string
{
return (string) (new Uri())->withPath($shortCode)
->withScheme($domainConfig['schema'] ?? 'http')
->withHost($domainConfig['hostname'] ?? '');
}
}

View File

@@ -5,15 +5,17 @@ namespace ShlinkioTest\Shlink\Core\Repository;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use ShlinkioTest\Shlink\Common\DbUnit\DatabaseTestCase; use ShlinkioTest\Shlink\Common\DbUnit\DatabaseTestCase;
class ShortUrlRepositoryTest extends DatabaseTestCase class ShortUrlRepositoryTest extends DatabaseTestCase
{ {
const ENTITIES_TO_EMPTY = [ protected const ENTITIES_TO_EMPTY = [
ShortUrl::class, ShortUrl::class,
Visit::class, Visit::class,
Tag::class,
]; ];
/** /**
@@ -38,7 +40,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$bar = new ShortUrl(); $bar = new ShortUrl();
$bar->setOriginalUrl('bar') $bar->setOriginalUrl('bar')
->setShortCode('bar') ->setShortCode('bar_very_long_text')
->setValidSince((new \DateTime())->add(new \DateInterval('P1M'))); ->setValidSince((new \DateTime())->add(new \DateInterval('P1M')));
$this->getEntityManager()->persist($bar); $this->getEntityManager()->persist($bar);
@@ -79,4 +81,59 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->assertEquals($count, $this->repo->countList()); $this->assertEquals($count, $this->repo->countList());
} }
/**
* @test
*/
public function findListProperlyFiltersByTagAndSearchTerm()
{
$tag = new Tag('bar');
$this->getEntityManager()->persist($tag);
$foo = new ShortUrl();
$foo->setOriginalUrl('foo')
->setShortCode('foo')
->setTags(new ArrayCollection([$tag]));
$this->getEntityManager()->persist($foo);
$bar = new ShortUrl();
$bar->setOriginalUrl('bar')
->setShortCode('bar_very_long_text');
$this->getEntityManager()->persist($bar);
$foo2 = new ShortUrl();
$foo2->setOriginalUrl('foo_2')
->setShortCode('foo_2');
$this->getEntityManager()->persist($foo2);
$this->getEntityManager()->flush();
$result = $this->repo->findList(null, null, 'foo', ['bar']);
$this->assertCount(1, $result);
$this->assertSame($foo, $result[0]);
}
/**
* @test
*/
public function findListProperlyMapsFieldNamesToColumnNamesWhenOrdering()
{
$urls = ['a', 'z', 'c', 'b'];
foreach ($urls as $url) {
$this->getEntityManager()->persist(
(new ShortUrl())->setShortCode($url)
->setLongUrl($url)
);
}
$this->getEntityManager()->flush();
$result = $this->repo->findList(null, null, null, [], ['longUrl' => 'ASC']);
$this->assertCount(\count($urls), $result);
$this->assertEquals('a', $result[0]->getLongUrl());
$this->assertEquals('b', $result[1]->getLongUrl());
$this->assertEquals('c', $result[2]->getLongUrl());
$this->assertEquals('z', $result[3]->getLongUrl());
}
} }

View File

@@ -9,7 +9,7 @@ use ShlinkioTest\Shlink\Common\DbUnit\DatabaseTestCase;
class TagRepositoryTest extends DatabaseTestCase class TagRepositoryTest extends DatabaseTestCase
{ {
const ENTITIES_TO_EMPTY = [ protected const ENTITIES_TO_EMPTY = [
Tag::class, Tag::class,
]; ];

View File

@@ -12,7 +12,7 @@ use ShlinkioTest\Shlink\Common\DbUnit\DatabaseTestCase;
class VisitRepositoryTest extends DatabaseTestCase class VisitRepositoryTest extends DatabaseTestCase
{ {
const ENTITIES_TO_EMPTY = [ protected const ENTITIES_TO_EMPTY = [
VisitLocation::class, VisitLocation::class,
Visit::class, Visit::class,
ShortUrl::class, ShortUrl::class,

View File

@@ -9,6 +9,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Response\PixelResponse; use Shlinkio\Shlink\Common\Response\PixelResponse;
use Shlinkio\Shlink\Core\Action\PixelAction; use Shlinkio\Shlink\Core\Action\PixelAction;
use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\VisitsTracker; use Shlinkio\Shlink\Core\Service\VisitsTracker;
@@ -48,10 +49,10 @@ class PixelActionTest extends TestCase
public function imageIsReturned() public function imageIsReturned()
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn('http://domain.com/foo/bar') $this->urlShortener->shortCodeToUrl($shortCode)->willReturn(
->shouldBeCalledTimes(1); (new ShortUrl())->setLongUrl('http://domain.com/foo/bar')
$this->visitTracker->track(Argument::cetera())->willReturn(null) )->shouldBeCalledTimes(1);
->shouldBeCalledTimes(1); $this->visitTracker->track(Argument::cetera())->shouldBeCalledTimes(1);
$request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode); $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
$response = $this->action->process($request, TestUtils::createReqHandlerMock()->reveal()); $response = $this->action->process($request, TestUtils::createReqHandlerMock()->reveal());

View File

@@ -10,6 +10,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Common\Service\PreviewGenerator; use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Core\Action\PreviewAction; use Shlinkio\Shlink\Core\Action\PreviewAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\UrlShortener;
@@ -64,8 +65,9 @@ class PreviewActionTest extends TestCase
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$url = 'foobar.com'; $url = 'foobar.com';
$shortUrl = (new ShortUrl())->setLongUrl($url);
$path = __FILE__; $path = __FILE__;
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($url)->shouldBeCalledTimes(1); $this->urlShortener->shortCodeToUrl($shortCode)->willReturn($shortUrl)->shouldBeCalledTimes(1);
$this->previewGenerator->generatePreview($url)->willReturn($path)->shouldBeCalledTimes(1); $this->previewGenerator->generatePreview($url)->willReturn($path)->shouldBeCalledTimes(1);
$resp = $this->action->process( $resp = $this->action->process(

View File

@@ -10,6 +10,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Common\Response\QrCodeResponse; use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Action\QrCodeAction; use Shlinkio\Shlink\Core\Action\QrCodeAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\UrlShortener;
@@ -83,7 +84,8 @@ class QrCodeActionTest extends TestCase
public function aCorrectRequestReturnsTheQrCodeResponse() public function aCorrectRequestReturnsTheQrCodeResponse()
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn('')->shouldBeCalledTimes(1); $this->urlShortener->shortCodeToUrl($shortCode)->willReturn((new ShortUrl())->setLongUrl(''))
->shouldBeCalledTimes(1);
$delegate = $this->prophesize(RequestHandlerInterface::class); $delegate = $this->prophesize(RequestHandlerInterface::class);
$resp = $this->action->process( $resp = $this->action->process(

View File

@@ -9,6 +9,7 @@ use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\UrlShortener;
@@ -51,10 +52,10 @@ class RedirectActionTest extends TestCase
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$expectedUrl = 'http://domain.com/foo/bar'; $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); ->shouldBeCalledTimes(1);
$this->visitTracker->track(Argument::cetera())->willReturn(null) $this->visitTracker->track(Argument::cetera())->shouldBeCalledTimes(1);
->shouldBeCalledTimes(1);
$request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode); $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
$response = $this->action->process($request, TestUtils::createReqHandlerMock()->reveal()); $response = $this->action->process($request, TestUtils::createReqHandlerMock()->reveal());
@@ -73,8 +74,7 @@ class RedirectActionTest extends TestCase
$shortCode = 'abc123'; $shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class) $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class)
->shouldBeCalledTimes(1); ->shouldBeCalledTimes(1);
$this->visitTracker->track(Argument::cetera())->willReturn(null) $this->visitTracker->track(Argument::cetera())->shouldNotBeCalled();
->shouldNotBeCalled();
$delegate = $this->prophesize(RequestHandlerInterface::class); $delegate = $this->prophesize(RequestHandlerInterface::class);
/** @var MethodProphecy $process */ /** @var MethodProphecy $process */
@@ -93,10 +93,10 @@ class RedirectActionTest extends TestCase
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$expectedUrl = 'http://domain.com/foo/bar'; $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); ->shouldBeCalledTimes(1);
$this->visitTracker->track(Argument::cetera())->willReturn(null) $this->visitTracker->track(Argument::cetera())->shouldNotBeCalled();
->shouldNotBeCalled();
$request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode) $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode)
->withQueryParams(['foobar' => true]); ->withQueryParams(['foobar' => true]);

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException;
class DeleteShortUrlExceptionTest extends TestCase
{
/**
* @test
* @dataProvider provideMessages
*/
public function fromVisitsThresholdGeneratesMessageProperly(
int $threshold,
string $shortCode,
string $expectedMessage
) {
$e = DeleteShortUrlException::fromVisitsThreshold($threshold, $shortCode);
$this->assertEquals($expectedMessage, $e->getMessage());
}
public function provideMessages(): array
{
return [
[
50,
'abc123',
'Impossible to delete short URL with short code "abc123" since it has more than "50" visits.',
],
[
33,
'def456',
'Impossible to delete short URL with short code "def456" since it has more than "33" visits.',
],
[
5713,
'foobar',
'Impossible to delete short URL with short code "foobar" since it has more than "5713" visits.',
],
];
}
/**
* @test
* @dataProvider provideThresholds
*/
public function visitsThresholdIsProperlyReturned(int $threshold)
{
$e = new DeleteShortUrlException($threshold);
$this->assertEquals($threshold, $e->getVisitsThreshold());
}
public function provideThresholds(): array
{
return \array_map(function (int $number) {
return [$number];
}, \range(5, 50, 5));
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Service\ShortUrl;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException;
use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlService;
class DeleteShortUrlServiceTest extends TestCase
{
/**
* @var DeleteShortUrlService
*/
private $service;
/**
* @var ObjectProphecy
*/
private $em;
public function setUp()
{
$shortUrl = (new ShortUrl())->setShortCode('abc123')
->setVisits(new ArrayCollection(\array_map(function () {
return new Visit();
}, \range(0, 10))));
$this->em = $this->prophesize(EntityManagerInterface::class);
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$repo->findOneBy(Argument::type('array'))->willReturn($shortUrl);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
}
/**
* @test
*/
public function deleteByShortCodeThrowsExceptionWhenThresholdIsReached()
{
$service = $this->createService();
$this->expectException(DeleteShortUrlException::class);
$this->expectExceptionMessage(
'Impossible to delete short URL with short code "abc123" since it has more than "5" visits.'
);
$service->deleteByShortCode('abc123');
}
/**
* @test
*/
public function deleteByShortCodeDeletesUrlWhenThresholdIsReachedButExplicitlyIgnored()
{
$service = $this->createService();
$remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null);
$flush = $this->em->flush()->willReturn(null);
$service->deleteByShortCode('abc123', true);
$remove->shouldHaveBeenCalledTimes(1);
$flush->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function deleteByShortCodeDeletesUrlWhenThresholdIsReachedButCheckIsDisabled()
{
$service = $this->createService(false);
$remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null);
$flush = $this->em->flush()->willReturn(null);
$service->deleteByShortCode('abc123');
$remove->shouldHaveBeenCalledTimes(1);
$flush->shouldHaveBeenCalledTimes(1);
}
/**
* @test
*/
public function deleteByShortCodeDeletesUrlWhenThresholdIsNotReached()
{
$service = $this->createService(true, 100);
$remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null);
$flush = $this->em->flush()->willReturn(null);
$service->deleteByShortCode('abc123');
$remove->shouldHaveBeenCalledTimes(1);
$flush->shouldHaveBeenCalledTimes(1);
}
private function createService(bool $checkVisitsThreshold = true, int $visitsThreshold = 5): DeleteShortUrlService
{
return new DeleteShortUrlService($this->em->reveal(), new DeleteShortUrlsOptions([
'visitsThreshold' => $visitsThreshold,
'checkVisitsThreshold' => $checkVisitsThreshold,
]));
}
}

View File

@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Service; namespace ShlinkioTest\Shlink\Core\Service;
use Cocur\Slugify\SlugifyInterface; use Cocur\Slugify\SlugifyInterface;
use Doctrine\Common\Cache\ArrayCache;
use Doctrine\Common\Cache\Cache;
use Doctrine\Common\Persistence\ObjectRepository; use Doctrine\Common\Persistence\ObjectRepository;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -37,10 +35,6 @@ class UrlShortenerTest extends TestCase
* @var ObjectProphecy * @var ObjectProphecy
*/ */
protected $httpClient; protected $httpClient;
/**
* @var Cache
*/
protected $cache;
/** /**
* @var ObjectProphecy * @var ObjectProphecy
*/ */
@@ -60,13 +54,12 @@ class UrlShortenerTest extends TestCase
$this->em->persist(Argument::any())->will(function ($arguments) { $this->em->persist(Argument::any())->will(function ($arguments) {
/** @var ShortUrl $shortUrl */ /** @var ShortUrl $shortUrl */
$shortUrl = $arguments[0]; $shortUrl = $arguments[0];
$shortUrl->setId(10); $shortUrl->setId('10');
}); });
$repo = $this->prophesize(ObjectRepository::class); $repo = $this->prophesize(ObjectRepository::class);
$repo->findOneBy(Argument::any())->willReturn(null); $repo->findOneBy(Argument::any())->willReturn(null);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->cache = new ArrayCache();
$this->slugger = $this->prophesize(SlugifyInterface::class); $this->slugger = $this->prophesize(SlugifyInterface::class);
$this->setUrlShortener(false); $this->setUrlShortener(false);
@@ -80,7 +73,6 @@ class UrlShortenerTest extends TestCase
$this->urlShortener = new UrlShortener( $this->urlShortener = new UrlShortener(
$this->httpClient->reveal(), $this->httpClient->reveal(),
$this->em->reveal(), $this->em->reveal(),
$this->cache,
$urlValidationEnabled, $urlValidationEnabled,
UrlShortener::DEFAULT_CHARS, UrlShortener::DEFAULT_CHARS,
$this->slugger->reveal() $this->slugger->reveal()
@@ -93,8 +85,8 @@ class UrlShortenerTest extends TestCase
public function urlIsProperlyShortened() public function urlIsProperlyShortened()
{ {
// 10 -> 12C1c // 10 -> 12C1c
$shortCode = $this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar')); $shortUrl = $this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar'));
$this->assertEquals('12C1c', $shortCode); $this->assertEquals('12C1c', $shortUrl->getShortCode());
} }
/** /**
@@ -127,21 +119,6 @@ class UrlShortenerTest extends TestCase
$this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar')); $this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar'));
} }
/**
* @test
*/
public function whenShortUrlExistsItsShortcodeIsReturned()
{
$shortUrl = new ShortUrl();
$shortUrl->setShortCode('expected_shortcode');
$repo = $this->prophesize(ObjectRepository::class);
$repo->findOneBy(Argument::any())->willReturn($shortUrl);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$shortCode = $this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar'));
$this->assertEquals($shortUrl->getShortCode(), $shortCode);
}
/** /**
* @test * @test
*/ */
@@ -205,10 +182,8 @@ class UrlShortenerTest extends TestCase
$repo->findOneByShortCode($shortCode)->willReturn($shortUrl); $repo->findOneByShortCode($shortCode)->willReturn($shortUrl);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->assertFalse($this->cache->contains($shortCode . '_longUrl'));
$url = $this->urlShortener->shortCodeToUrl($shortCode); $url = $this->urlShortener->shortCodeToUrl($shortCode);
$this->assertEquals($shortUrl->getOriginalUrl(), $url); $this->assertSame($shortUrl, $url);
$this->assertTrue($this->cache->contains($shortCode . '_longUrl'));
} }
/** /**
@@ -219,18 +194,4 @@ class UrlShortenerTest extends TestCase
{ {
$this->urlShortener->shortCodeToUrl('&/('); $this->urlShortener->shortCodeToUrl('&/(');
} }
/**
* @test
*/
public function cachedShortCodeDoesNotHitDatabase()
{
$shortCode = '12C1c';
$expectedUrl = 'expected_url';
$this->cache->save($shortCode . '_longUrl', $expectedUrl);
$this->em->getRepository(ShortUrl::class)->willReturn(null)->shouldBeCalledTimes(0);
$url = $this->urlShortener->shortCodeToUrl($shortCode);
$this->assertEquals($expectedUrl, $url);
}
} }

View File

@@ -61,7 +61,7 @@ class VisitsTrackerTest extends TestCase
$this->em->persist(Argument::any())->will(function ($args) use ($test) { $this->em->persist(Argument::any())->will(function ($args) use ($test) {
/** @var Visit $visit */ /** @var Visit $visit */
$visit = $args[0]; $visit = $args[0];
$test->assertEquals('4.3.2.1', $visit->getRemoteAddr()); $test->assertEquals('4.3.2.0', $visit->getRemoteAddr());
})->shouldBeCalledTimes(1); })->shouldBeCalledTimes(1);
$this->em->flush(Argument::type(Visit::class))->shouldBeCalledTimes(1); $this->em->flush(Argument::type(Visit::class))->shouldBeCalledTimes(1);

View File

@@ -23,6 +23,7 @@ return [
Action\ShortCode\CreateShortCodeAction::class => ConfigAbstractFactory::class, Action\ShortCode\CreateShortCodeAction::class => ConfigAbstractFactory::class,
Action\ShortCode\SingleStepCreateShortCodeAction::class => ConfigAbstractFactory::class, Action\ShortCode\SingleStepCreateShortCodeAction::class => ConfigAbstractFactory::class,
Action\ShortCode\EditShortCodeAction::class => ConfigAbstractFactory::class, Action\ShortCode\EditShortCodeAction::class => ConfigAbstractFactory::class,
Action\ShortCode\DeleteShortCodeAction::class => ConfigAbstractFactory::class,
Action\ShortCode\ResolveUrlAction::class => ConfigAbstractFactory::class, Action\ShortCode\ResolveUrlAction::class => ConfigAbstractFactory::class,
Action\Visit\GetVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\GetVisitsAction::class => ConfigAbstractFactory::class,
Action\ShortCode\ListShortCodesAction::class => ConfigAbstractFactory::class, Action\ShortCode\ListShortCodesAction::class => ConfigAbstractFactory::class,
@@ -58,10 +59,24 @@ return [
'config.url_shortener.domain', 'config.url_shortener.domain',
'Logger_Shlink', 'Logger_Shlink',
], ],
Action\ShortCode\EditShortCodeAction::class => [Service\ShortUrlService::class, 'translator', 'Logger_Shlink',], Action\ShortCode\EditShortCodeAction::class => [Service\ShortUrlService::class, 'translator', 'Logger_Shlink'],
Action\ShortCode\ResolveUrlAction::class => [Service\UrlShortener::class, 'translator'], Action\ShortCode\DeleteShortCodeAction::class => [
Service\ShortUrl\DeleteShortUrlService::class,
'translator',
'Logger_Shlink',
],
Action\ShortCode\ResolveUrlAction::class => [
Service\UrlShortener::class,
'translator',
'config.url_shortener.domain',
],
Action\Visit\GetVisitsAction::class => [Service\VisitsTracker::class, 'translator', 'Logger_Shlink'], Action\Visit\GetVisitsAction::class => [Service\VisitsTracker::class, 'translator', 'Logger_Shlink'],
Action\ShortCode\ListShortCodesAction::class => [Service\ShortUrlService::class, 'translator', 'Logger_Shlink'], Action\ShortCode\ListShortCodesAction::class => [
Service\ShortUrlService::class,
'translator',
'config.url_shortener.domain',
'Logger_Shlink',
],
Action\ShortCode\EditShortCodeTagsAction::class => [ Action\ShortCode\EditShortCodeTagsAction::class => [
Service\ShortUrlService::class, Service\ShortUrlService::class,
'translator', 'translator',

View File

@@ -18,6 +18,7 @@ return [
Middleware\ShortCode\CreateShortCodeContentNegotiationMiddleware::class, Middleware\ShortCode\CreateShortCodeContentNegotiationMiddleware::class,
]), ]),
Action\ShortCode\EditShortCodeAction::getRouteDef(), Action\ShortCode\EditShortCodeAction::getRouteDef(),
Action\ShortCode\DeleteShortCodeAction::getRouteDef(),
Action\ShortCode\ResolveUrlAction::getRouteDef(), Action\ShortCode\ResolveUrlAction::getRouteDef(),
Action\ShortCode\ListShortCodesAction::getRouteDef(), Action\ShortCode\ListShortCodesAction::getRouteDef(),
Action\ShortCode\EditShortCodeTagsAction::getRouteDef(), Action\ShortCode\EditShortCodeTagsAction::getRouteDef(),

Binary file not shown.

View File

@@ -1,8 +1,8 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Shlink 1.0\n" "Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2018-05-06 12:34+0200\n" "POT-Creation-Date: 2018-09-15 18:02+0200\n"
"PO-Revision-Date: 2018-05-06 12:35+0200\n" "PO-Revision-Date: 2018-09-15 18:03+0200\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n" "Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n" "Language-Team: \n"
"Language: es_ES\n" "Language: es_ES\n"
@@ -43,6 +43,14 @@ msgstr "No se ha proporcionado una URL"
msgid "No URL found for short code \"%s\"" msgid "No URL found for short code \"%s\""
msgstr "No se ha encontrado ninguna URL para el código corto \"%s\"" msgstr "No se ha encontrado ninguna URL para el código corto \"%s\""
#, php-format
msgid ""
"It is not possible to delete URL with short code \"%s\" because it has "
"reached more than \"%s\" visits."
msgstr ""
"No es posible eliminar la URL con el código corto \"%s\" porque ha alcanzado "
"más de \"%s\" visitas."
msgid "Provided data is invalid." msgid "Provided data is invalid."
msgstr "Los datos proporcionados son inválidos." msgstr "Los datos proporcionados son inválidos."

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