Compare commits

...

37 Commits

Author SHA1 Message Date
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
41 changed files with 1054 additions and 397 deletions

View File

@@ -1,263 +1,638 @@
## CHANGELOG # CHANGELOG
### 1.9.0 ## 1.10.2 - 2018-08-04
**Features** #### Added
* [147: Allow short URLs to be created on the fly with query param authentication](https://github.com/shlinkio/shlink/issues/147) * *Nothing*
**Bugs:** #### Changed
* [139: Make sure all core actions log exceptions](https://github.com/shlinkio/shlink/issues/139) * *Nothing*
### 1.8.1 #### Deprecated
**Tasks** * *Nothing*
* [141: Remove workaround used in PathVersionMiddleware](https://github.com/shlinkio/shlink/issues/141) #### Removed
**Bugs:** * *Nothing*
* [140: Installation failed. Warning thrown while trying to include doctrine script](https://github.com/shlinkio/shlink/issues/140) #### Fixed
### 1.8.0 * [#177](https://github.com/shlinkio/shlink/issues/177) Fixed `[GET] /short-codes` endpoint returning a 500 status code when trying to filter by `tags` and `searchTerm` at the same time.
* [#175](https://github.com/shlinkio/shlink/issues/175) Fixed error introduced in previous version, where you could end up banned from the service used to resolve IP address locations.
**Features** 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.
* [125: Implement a path which returns a 1px image instead of a redirection](https://github.com/shlinkio/shlink/issues/125)
**Enhancements:** ## 1.10.1 - 2018-08-02
* [130: Update to Expressive 3](https://github.com/shlinkio/shlink/issues/130) #### Added
* [137: Update symfony packages to v4](https://github.com/shlinkio/shlink/issues/137)
**Tasks** * *Nothing*
* [131: Drop support for PHP 7](https://github.com/shlinkio/shlink/issues/131) #### Changed
* [132: Add infection to improve tests](https://github.com/shlinkio/shlink/issues/132)
### 1.7.2 * [#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.
**Bugs:** #### Deprecated
* [135: Fix PathVersionMiddleware being ignored when using expressive 2.2](https://github.com/shlinkio/shlink/issues/135) * *Nothing*
### 1.7.1 #### Removed
**Enhancements:** * *Nothing*
* [128: Upgrade to expressive 2.2](https://github.com/shlinkio/shlink/issues/128) #### Fixed
**Bugs** * [#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.
* [126: Expressive 2.2 causes failures by triggering E_USER_DEPRECATED errors](https://github.com/shlinkio/shlink/issues/126) For example: `https://doma.in/rest/v1/short-urls/abc123/visits?startDate=2017-05-23&endDate=2017-10-05`
### 1.7.0 * [#169](https://github.com/shlinkio/shlink/issues/169) Fixed unhandled error when parsing `ShortUrlMeta` and date fields are already `DateTime` instances.
**Features**
* [88: Allow to disable tracking of the short URL by including a configurable query param](https://github.com/shlinkio/shlink/issues/88) ## 1.10.0 - 2018-07-09
* [108: Allow to edit metadata in created shortcodes](https://github.com/shlinkio/shlink/issues/108)
**Enhancements:** #### Added
* [113: Update CLI commands to use SymfonyStyle](https://github.com/shlinkio/shlink/issues/113) * [#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
* [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) * [#159](https://github.com/shlinkio/shlink/issues/159) Updated CHANGELOG to follow the [keep-a-changelog](https://keepachangelog.com) format
* [115: Add phpstan to build matrix on PHP >=7.1 envs](https://github.com/shlinkio/shlink/issues/115) * [#160](https://github.com/shlinkio/shlink/issues/160) Update infection to v0.9 and phpstan to v 0.10
* [114: Replace vlucas/phpdotenv dev requirement by symfony/env](https://github.com/shlinkio/shlink/issues/114)
### 1.6.2 #### Deprecated
**Bugs** * *Nothing*
* [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.9.1 - 2018-06-18
* [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:** * [#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
}
}
```
* [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) * [#154](https://github.com/shlinkio/shlink/issues/154) Fixed sizes of every result page when filtering by searchTerm
* [59: Add tags CRUD to REST](https://github.com/shlinkio/shlink/issues/59) * [#157](https://github.com/shlinkio/shlink/issues/157) Background commands executed by installation process now respect the originally used php binary
* [66: Allow to import certain information from older app directory when updating](https://github.com/shlinkio/shlink/issues/66)
**Tasks**
* [96: Add namespace to functions](https://github.com/shlinkio/shlink/issues/96) ## 1.9.0 - 2018-05-07
* [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** #### Added
* [92: Fix formatted dates, using an ISO compliant format](https://github.com/shlinkio/shlink/issues/92) * [#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.
### 1.4.0 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.
**Enhancements:** #### Changed
* [89: Update to expressive 2](https://github.com/shlinkio/shlink/issues/89) * *Nothing*
### 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 * [#139](https://github.com/shlinkio/shlink/issues/139) Ensured all core actions log exceptions
**Enhancements:**
* [67: Allow to order the short codes list](https://github.com/shlinkio/shlink/issues/67) ## 1.8.1 - 2018-04-07
* [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** #### Added
* [73: Tag endpoints in swagger file](https://github.com/shlinkio/shlink/issues/73) * *Nothing*
* [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 #### Changed
**Bugs** * [#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

@@ -46,6 +46,9 @@ 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
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"

View File

@@ -15,11 +15,7 @@
"php": "^7.1", "php": "^7.1",
"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 +43,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 +99,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,7 +7,7 @@ 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'),
], ],

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

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

@@ -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,
@@ -116,7 +116,10 @@
], ],
"pagination": { "pagination": {
"currentPage": 5, "currentPage": 5,
"pagesCount": 12 "pagesCount": 12,
"itemsPerPage": 10,
"itemsInCurrentPage": 10,
"totalItems": 115
} }
} }
} }

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": [

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

@@ -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;
@@ -51,7 +51,7 @@ return [
], ],
Command\Visit\ProcessVisitsCommand::class => [ Command\Visit\ProcessVisitsCommand::class => [
Service\VisitService::class, Service\VisitService::class,
IpLocationResolver::class, IpApiLocationResolver::class,
'translator', 'translator',
], ],
Command\Config\GenerateCharsetCommand::class => ['translator'], Command\Config\GenerateCharsetCommand::class => ['translator'],

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-08-04 16:35+0200\n"
"PO-Revision-Date: 2018-01-21 09:39+0100\n" "PO-Revision-Date: 2018-08-04 16:37+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"
@@ -317,6 +317,13 @@ 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"

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,27 @@ 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->writeln('Running "' . sprintf('%s %s', $this->phpBinary, $command) . '"');
$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

@@ -15,8 +15,8 @@ use Zend\I18n\Translator\TranslatorInterface;
class ProcessVisitsCommand extends Command class ProcessVisitsCommand extends Command
{ {
const LOCALHOST = '127.0.0.1'; private const LOCALHOST = '127.0.0.1';
const NAME = 'visit:process'; public 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 === self::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

@@ -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(),
@@ -95,4 +97,41 @@ class ProcessVisitsCommandTest extends TestCase
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$this->assertTrue(strpos($output, 'Ignored localhost address') > 0); $this->assertTrue(strpos($output, 'Ignored localhost address') > 0);
} }
/**
* @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

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

@@ -8,13 +8,16 @@ use Zend\Stdlib\ArrayUtils;
trait PaginatorUtilsTrait trait PaginatorUtilsTrait
{ {
protected function serializePaginator(Paginator $paginator) protected function serializePaginator(Paginator $paginator): array
{ {
return [ return [
'data' => ArrayUtils::iteratorToArray($paginator->getCurrentItems()), 'data' => ArrayUtils::iteratorToArray($paginator->getCurrentItems()),
'pagination' => [ 'pagination' => [
'currentPage' => $paginator->getCurrentPageNumber(), 'currentPage' => $paginator->getCurrentPageNumber(),
'pagesCount' => $paginator->count(), 'pagesCount' => $paginator->count(),
'itemsPerPage' => $paginator->getItemCountPerPage(),
'itemsInCurrentPage' => $paginator->getCurrentItemCount(),
'totalItems' => $paginator->getTotalItemCount(),
], ],
]; ];
} }
@@ -25,7 +28,7 @@ trait PaginatorUtilsTrait
* @param Paginator $paginator * @param Paginator $paginator
* @return bool * @return bool
*/ */
protected function isLastPage(Paginator $paginator) protected 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,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,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

@@ -29,7 +29,7 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
* name="short_code", * name="short_code",
* type="string", * type="string",
* nullable=false, * nullable=false,
* length=10, * length=255,
* unique=true * unique=true
* ) * )
*/ */

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

@@ -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,21 @@ 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; $fieldName = \is_array($orderBy) ? \key($orderBy) : $orderBy;
$order = is_array($orderBy) ? $orderBy[$fieldName] : 'ASC'; $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 (\in_array($fieldName, ['originalUrl', 'shortCode', 'dateCreated'], true)) {
$qb->orderBy('s.' . $fieldName, $order);
}
return $qb->getQuery()->getResult(); return $qb->getQuery()->getResult();
} }
@@ -74,7 +75,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 +93,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 +106,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 +122,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

@@ -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,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,35 @@ 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]);
}
} }

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

@@ -25,7 +25,7 @@ class ApiKeyService implements ApiKeyServiceInterface
* @param \DateTime $expirationDate * @param \DateTime $expirationDate
* @return ApiKey * @return ApiKey
*/ */
public function create(\DateTime $expirationDate = null) public function create(\DateTime $expirationDate = null): ApiKey
{ {
$key = new ApiKey(); $key = new ApiKey();
if ($expirationDate !== null) { if ($expirationDate !== null) {
@@ -44,7 +44,7 @@ class ApiKeyService implements ApiKeyServiceInterface
* @param string $key * @param string $key
* @return bool * @return bool
*/ */
public function check(string $key) public function check(string $key): bool
{ {
/** @var ApiKey|null $apiKey */ /** @var ApiKey|null $apiKey */
$apiKey = $this->getByKey($key); $apiKey = $this->getByKey($key);
@@ -58,7 +58,7 @@ class ApiKeyService implements ApiKeyServiceInterface
* @return ApiKey * @return ApiKey
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
public function disable(string $key) public function disable(string $key): ApiKey
{ {
/** @var ApiKey|null $apiKey */ /** @var ApiKey|null $apiKey */
$apiKey = $this->getByKey($key); $apiKey = $this->getByKey($key);
@@ -77,10 +77,12 @@ class ApiKeyService implements ApiKeyServiceInterface
* @param bool $enabledOnly Tells if only enabled keys should be returned * @param bool $enabledOnly Tells if only enabled keys should be returned
* @return ApiKey[] * @return ApiKey[]
*/ */
public function listKeys(bool $enabledOnly = false) public function listKeys(bool $enabledOnly = false): array
{ {
$conditions = $enabledOnly ? ['enabled' => true] : []; $conditions = $enabledOnly ? ['enabled' => true] : [];
return $this->em->getRepository(ApiKey::class)->findBy($conditions); /** @var ApiKey[] $apiKeys */
$apiKeys = $this->em->getRepository(ApiKey::class)->findBy($conditions);
return $apiKeys;
} }
/** /**
@@ -89,7 +91,7 @@ class ApiKeyService implements ApiKeyServiceInterface
* @param string $key * @param string $key
* @return ApiKey|null * @return ApiKey|null
*/ */
public function getByKey(string $key) public function getByKey(string $key): ?ApiKey
{ {
/** @var ApiKey|null $apiKey */ /** @var ApiKey|null $apiKey */
$apiKey = $this->em->getRepository(ApiKey::class)->findOneBy([ $apiKey = $this->em->getRepository(ApiKey::class)->findOneBy([

View File

@@ -14,7 +14,7 @@ interface ApiKeyServiceInterface
* @param \DateTime $expirationDate * @param \DateTime $expirationDate
* @return ApiKey * @return ApiKey
*/ */
public function create(\DateTime $expirationDate = null); public function create(\DateTime $expirationDate = null): ApiKey;
/** /**
* Checks if provided key is a valid api key * Checks if provided key is a valid api key
@@ -22,7 +22,7 @@ interface ApiKeyServiceInterface
* @param string $key * @param string $key
* @return bool * @return bool
*/ */
public function check(string $key); public function check(string $key): bool;
/** /**
* Disables provided api key * Disables provided api key
@@ -31,7 +31,7 @@ interface ApiKeyServiceInterface
* @return ApiKey * @return ApiKey
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
public function disable(string $key); public function disable(string $key): ApiKey;
/** /**
* Lists all existing api keys * Lists all existing api keys
@@ -39,7 +39,7 @@ interface ApiKeyServiceInterface
* @param bool $enabledOnly Tells if only enabled keys should be returned * @param bool $enabledOnly Tells if only enabled keys should be returned
* @return ApiKey[] * @return ApiKey[]
*/ */
public function listKeys(bool $enabledOnly = false); public function listKeys(bool $enabledOnly = false): array;
/** /**
* Tries to find one API key by its key string * Tries to find one API key by its key string
@@ -47,5 +47,5 @@ interface ApiKeyServiceInterface
* @param string $key * @param string $key
* @return ApiKey|null * @return ApiKey|null
*/ */
public function getByKey(string $key); public function getByKey(string $key): ?ApiKey;
} }

View File

@@ -2,3 +2,5 @@ parameters:
excludes_analyse: excludes_analyse:
- module/Common/src/Template/Extension/TranslatorExtension.php - module/Common/src/Template/Extension/TranslatorExtension.php
- module/Rest/src/Util/RestUtils.php - module/Rest/src/Util/RestUtils.php
ignoreErrors:
- '#is not subtype of Throwable#'