mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-03 22:03:13 +08:00
Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92858aebd6 | ||
|
|
1910724a98 | ||
|
|
ff8441fa95 | ||
|
|
9d8fb055b1 | ||
|
|
9651b3d692 | ||
|
|
9d10c8627a | ||
|
|
929d7670cb | ||
|
|
5714a8f884 | ||
|
|
159529937d | ||
|
|
394d9ff4d2 | ||
|
|
07165f344f | ||
|
|
4f2146dd9c | ||
|
|
5b9784cd9e | ||
|
|
9d9b61cf14 | ||
|
|
9d7db96e4b | ||
|
|
3d0bca2781 | ||
|
|
ffb54c4f7a | ||
|
|
a01031303f | ||
|
|
7808f6d182 | ||
|
|
a0c3b9412f | ||
|
|
c32e2053c3 | ||
|
|
a33151248d | ||
|
|
038ba3b006 | ||
|
|
f3c92f4110 | ||
|
|
17779dbbc6 | ||
|
|
c2dd5b8c47 | ||
|
|
fcb9121e5a | ||
|
|
0af1004860 | ||
|
|
917f668cf3 | ||
|
|
436499b7c4 | ||
|
|
66af9866f0 | ||
|
|
3703dedad9 | ||
|
|
37502151ef | ||
|
|
3816a10de3 | ||
|
|
bdda6067ab | ||
|
|
0f62af241f | ||
|
|
987919e87a | ||
|
|
0c03a4b7ff | ||
|
|
5d6d13c95f | ||
|
|
563021bdc1 | ||
|
|
2d6d35a398 | ||
|
|
30297ac5ac | ||
|
|
416c56dee2 | ||
|
|
6b968a6843 | ||
|
|
080965e166 | ||
|
|
c7239aaca2 | ||
|
|
110e8cb78d | ||
|
|
ed859767a8 | ||
|
|
d54b823c88 | ||
|
|
0ae44d3331 | ||
|
|
8063e643a3 | ||
|
|
3883ed15c4 | ||
|
|
a79c1f580e | ||
|
|
f4b569c245 | ||
|
|
899771cc2e | ||
|
|
863803b614 | ||
|
|
5be5e0bc60 | ||
|
|
0b8e305533 | ||
|
|
39d79366a3 | ||
|
|
d5b78f2a7e | ||
|
|
b2a63f734a | ||
|
|
82f41de87b | ||
|
|
af4c66d40a | ||
|
|
975260f126 | ||
|
|
bd678b41f7 | ||
|
|
66898b6ddc | ||
|
|
5eee683978 | ||
|
|
e92446df9b | ||
|
|
63a69b05a1 | ||
|
|
c79ca1d13c | ||
|
|
87c4851d7e | ||
|
|
a125c93ca3 | ||
|
|
a8465094c1 | ||
|
|
16f7359ac6 | ||
|
|
f9f4817ee2 | ||
|
|
c7e49f223f | ||
|
|
6e79b4ba7b | ||
|
|
f78a7f12a9 | ||
|
|
b3664597b0 | ||
|
|
8cfb4f61ca | ||
|
|
b0dbb2dae4 | ||
|
|
7c6da4985d | ||
|
|
386b0dfb7b | ||
|
|
1437ff48ce | ||
|
|
63294f20ee | ||
|
|
d8acc3c247 | ||
|
|
52d8ffa212 | ||
|
|
98ad2816e8 | ||
|
|
9d890f4227 | ||
|
|
0932d04907 | ||
|
|
1f78b5c524 | ||
|
|
59f10619ba | ||
|
|
334710e92c | ||
|
|
75b8175824 | ||
|
|
8a74ef2a33 | ||
|
|
d05ac5ce9d | ||
|
|
3100fffa2b | ||
|
|
6bbacb1017 | ||
|
|
4403dc5df9 | ||
|
|
fdc637c23d | ||
|
|
b99d662417 | ||
|
|
eb9a964c66 | ||
|
|
e5ef8d7f8c | ||
|
|
28650aee2b | ||
|
|
a2294704e6 | ||
|
|
e5e1aa2ff4 | ||
|
|
2f5290b9d3 | ||
|
|
ef3c4aadf2 | ||
|
|
c9ce56eea5 | ||
|
|
4fee656f96 | ||
|
|
d2a04259f5 | ||
|
|
e504daa1ba | ||
|
|
8793a67ce9 | ||
|
|
b4ded374e9 | ||
|
|
91d350b12f | ||
|
|
b3e25f28fd | ||
|
|
aca89f9abe | ||
|
|
243075dd78 | ||
|
|
7130425896 | ||
|
|
fe9ab20cbb | ||
|
|
6935b2ebe2 | ||
|
|
3dcc510da1 | ||
|
|
2f26c82fa6 | ||
|
|
9ddb60a882 | ||
|
|
210b08b61f | ||
|
|
42fe4bd5ce | ||
|
|
1b2a0820e5 | ||
|
|
6cf0155417 | ||
|
|
9b8be3e5b8 | ||
|
|
a27b01b895 | ||
|
|
16dd1838aa | ||
|
|
f788d6872f | ||
|
|
d0df007812 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
.idea
|
||||
build
|
||||
composer.lock
|
||||
composer.phar
|
||||
vendor/
|
||||
.env
|
||||
data/database.sqlite
|
||||
|
||||
@@ -5,7 +5,6 @@ branches:
|
||||
- /.*/
|
||||
|
||||
php:
|
||||
- 7
|
||||
- 7.1
|
||||
- 7.2
|
||||
|
||||
@@ -16,12 +15,10 @@ before_install:
|
||||
before_script:
|
||||
- composer self-update
|
||||
- composer install --no-interaction
|
||||
- if [[ $TRAVIS_PHP_VERSION = 7.1 ]] || [[ $TRAVIS_PHP_VERSION = 7.2 ]]; then composer global require --dev phpstan/phpstan:0.9.*; fi
|
||||
|
||||
script:
|
||||
- mkdir build
|
||||
- composer check
|
||||
- if [[ $TRAVIS_PHP_VERSION = 7.1 ]] || [[ $TRAVIS_PHP_VERSION = 7.2 ]]; then ~/.composer/vendor/bin/phpstan analyse module/*/src/ --level=6 -c phpstan.neon; fi
|
||||
|
||||
after_script:
|
||||
- vendor/bin/phpcov merge build --clover build/clover.xml
|
||||
|
||||
785
CHANGELOG.md
785
CHANGELOG.md
@@ -1,227 +1,732 @@
|
||||
## CHANGELOG
|
||||
# CHANGELOG
|
||||
|
||||
### 1.7.2
|
||||
## 1.12.0 - 2018-09-15
|
||||
|
||||
**Bugs:**
|
||||
#### Added
|
||||
|
||||
* [135: Fix PathVersionMiddleware being ignored when using expressive 2.2](https://github.com/shlinkio/shlink/issues/135)
|
||||
* [#187](https://github.com/shlinkio/shlink/issues/187) Included an API endpoint and a CLI command to delete short URLs.
|
||||
|
||||
### 1.7.1
|
||||
Due to the implicit danger of this operation, the deletion includes a safety check. URLs cannot be deleted if they have more than a specific amount of visits.
|
||||
|
||||
**Enhancements:**
|
||||
The visits threshold is set to **15** by default and currently it has to be manually changed. In future versions the installation/update process will ask you about the value of the visits threshold.
|
||||
|
||||
* [128: Upgrade to expressive 2.2](https://github.com/shlinkio/shlink/issues/128)
|
||||
In order to change it, open the `config/autoload/delete_short_urls.global.php` file, which has this structure:
|
||||
|
||||
**Bugs**
|
||||
```php
|
||||
return [
|
||||
|
||||
* [126: Expressive 2.2 causes failures by triggering E_USER_DEPRECATED errors](https://github.com/shlinkio/shlink/issues/126)
|
||||
'delete_short_urls' => [
|
||||
'visits_threshold' => 15,
|
||||
'check_visits_threshold' => true,
|
||||
],
|
||||
|
||||
### 1.7.0
|
||||
];
|
||||
```
|
||||
|
||||
**Features**
|
||||
Properties are self explanatory. Change `check_visits_threshold` to `false` to completely disable this safety check, and change the value of `visits_threshold` to allow short URLs with a different number of visits to be deleted.
|
||||
|
||||
* [88: Allow to disable tracking of the short URL by including a configurable query param](https://github.com/shlinkio/shlink/issues/88)
|
||||
* [108: Allow to edit metadata in created shortcodes](https://github.com/shlinkio/shlink/issues/108)
|
||||
Once changed, delete the `data/cache/app_config.php` file (if any) to let shlink know about the new values.
|
||||
|
||||
**Enhancements:**
|
||||
This check is implicit for the API endpoint, but can be "disabled" for the CLI command, which will ask you when trying to delete a URL which has reached to threshold in order to force the deletion.
|
||||
|
||||
* [113: Update CLI commands to use SymfonyStyle](https://github.com/shlinkio/shlink/issues/113)
|
||||
* [112: Configure cli commands lazy loading](https://github.com/shlinkio/shlink/issues/112)
|
||||
* [#183](https://github.com/shlinkio/shlink/issues/183) and [#190](https://github.com/shlinkio/shlink/issues/190) Included important documentation improvements in the repository itself. You no longer need to go to the website in order to see how to install or use shlink.
|
||||
* [#186](https://github.com/shlinkio/shlink/issues/186) Added a small robots.txt file that prevents 404 errors to be logged due to search engines trying to index the domain where shlink is located. Thanks to [@robwent](https://github.com/robwent) for the contribution.
|
||||
|
||||
**Tasks**
|
||||
#### Changed
|
||||
|
||||
* [117: Make every module which throws exceptions have its own ExceptionInterface, and make them all extend Throwable](https://github.com/shlinkio/shlink/issues/117)
|
||||
* [115: Add phpstan to build matrix on PHP >=7.1 envs](https://github.com/shlinkio/shlink/issues/115)
|
||||
* [114: Replace vlucas/phpdotenv dev requirement by symfony/env](https://github.com/shlinkio/shlink/issues/114)
|
||||
* [#145](https://github.com/shlinkio/shlink/issues/145) Shlink now obfuscates IP addresses from visitors by replacing the latest octet by `0`, which does not affect geolocation and allows it to fulfil the GDPR.
|
||||
|
||||
### 1.6.2
|
||||
Other known services follow this same approach, like [Google Analytics](https://support.google.com/analytics/answer/2763052?hl=en) or [Matomo](https://matomo.org/docs/privacy/#step-1-automatically-anonymize-visitor-ips)
|
||||
|
||||
**Bugs**
|
||||
* [#182](https://github.com/shlinkio/shlink/issues/182) The short URL creation API endpoints now return the same model used for lists and details endpoints.
|
||||
|
||||
* [109: Fix installation error due to typo in latest migration](https://github.com/shlinkio/shlink/issues/109)
|
||||
#### Deprecated
|
||||
|
||||
### 1.6.1
|
||||
* *Nothing*
|
||||
|
||||
**Tasks**
|
||||
#### Removed
|
||||
|
||||
* [110: Create gitattributes file to define files to be excluded from distributable package](https://github.com/shlinkio/shlink/issues/110)
|
||||
* *Nothing*
|
||||
|
||||
### 1.6.0
|
||||
#### Fixed
|
||||
|
||||
**Features**
|
||||
* [#188](https://github.com/shlinkio/shlink/issues/188) Shlink now allows multiple short URLs to be created that resolve to the same long URL.
|
||||
|
||||
* [44: Consider allowing to set custom slugs instead of generating a short code](https://github.com/shlinkio/shlink/issues/44)
|
||||
* [47: Allow to limit short codes availability by date range](https://github.com/shlinkio/shlink/issues/47)
|
||||
* [48: Allow to limit the number of visits to a short code](https://github.com/shlinkio/shlink/issues/48)
|
||||
* [105: Added option to enable/disable URL validation by response status code.](https://github.com/shlinkio/shlink/pull/105)
|
||||
|
||||
**Enhancements:**
|
||||
## 1.11.0 - 2018-08-13
|
||||
|
||||
* [27: Add repository functional tests with dbunit](https://github.com/shlinkio/shlink/issues/27)
|
||||
* [86: Drop support for PHP 5](https://github.com/shlinkio/shlink/issues/86)
|
||||
* [101: Make actions just capture very specific exceptions, and let the ErrorHandler catch any other exception](https://github.com/shlinkio/shlink/issues/101)
|
||||
* [104: Use different templates for requested-short-code-does-not-exist and route-could-not-be-match](https://github.com/shlinkio/shlink/issues/104)
|
||||
#### Added
|
||||
|
||||
**Tasks**
|
||||
* [#170](https://github.com/shlinkio/shlink/issues/170) and [#171](https://github.com/shlinkio/shlink/issues/171) Updated `[GET /short-codes]` and `[GET /short-codes/{shortCode}]` endpoints to return more meaningful information and make their response consistent.
|
||||
|
||||
* [99: Replace AnnotatedFactory by ConfigAbstractFactory](https://github.com/shlinkio/shlink/issues/99)
|
||||
* [100: Replace twig by plates](https://github.com/shlinkio/shlink/issues/100)
|
||||
* [102: Improve coding standards strictness](https://github.com/shlinkio/shlink/issues/102)
|
||||
The short URLs are now represented by this object in both cases:
|
||||
|
||||
**Bugs**
|
||||
```json
|
||||
{
|
||||
"shortCode": "12Kb3",
|
||||
"shortUrl": "https://doma.in/12Kb3",
|
||||
"longUrl": "https://shlink.io",
|
||||
"dateCreated": "2016-05-01T20:34:16+02:00",
|
||||
"visitsCount": 1029,
|
||||
"tags": [
|
||||
"shlink"
|
||||
],
|
||||
"originalUrl": "https://shlink.io"
|
||||
}
|
||||
```
|
||||
|
||||
* [103: Make NotFoundDelegate return proper content types based on accepted content](https://github.com/shlinkio/shlink/issues/103)
|
||||
The `originalUrl` property is considered deprecated and has been kept for backward compatibility purposes. It holds the same value as the `longUrl` property.
|
||||
|
||||
### 1.5.0
|
||||
#### Changed
|
||||
|
||||
**Enhancements:**
|
||||
* *Nothing*
|
||||
|
||||
* [95: Add tags CRUD to CLI](https://github.com/shlinkio/shlink/issues/95)
|
||||
* [59: Add tags CRUD to REST](https://github.com/shlinkio/shlink/issues/59)
|
||||
* [66: Allow to import certain information from older app directory when updating](https://github.com/shlinkio/shlink/issues/66)
|
||||
#### Deprecated
|
||||
|
||||
**Tasks**
|
||||
* The `originalUrl` property in `[GET /short-codes]` and `[GET /short-codes/{shortCode}]` endpoints is now deprecated and replaced by the `longUrl` property.
|
||||
|
||||
* [96: Add namespace to functions](https://github.com/shlinkio/shlink/issues/96)
|
||||
* [76: Add response examples to swagger docs](https://github.com/shlinkio/shlink/issues/76)
|
||||
* [93: Improve cross domain management by using the ImplicitOptionsMiddleware](https://github.com/shlinkio/shlink/issues/93)
|
||||
#### Removed
|
||||
|
||||
**Bugs**
|
||||
* *Nothing*
|
||||
|
||||
* [92: Fix formatted dates, using an ISO compliant format](https://github.com/shlinkio/shlink/issues/92)
|
||||
#### Fixed
|
||||
|
||||
### 1.4.0
|
||||
* *Nothing*
|
||||
|
||||
**Enhancements:**
|
||||
|
||||
* [89: Update to expressive 2](https://github.com/shlinkio/shlink/issues/89)
|
||||
## 1.10.2 - 2018-08-04
|
||||
|
||||
### 1.3.1
|
||||
#### Added
|
||||
|
||||
**Tasks**
|
||||
* *Nothing*
|
||||
|
||||
* [82: Enable FastRoute routes cache](https://github.com/shlinkio/shlink/issues/82)
|
||||
* [85: Update year in license file](https://github.com/shlinkio/shlink/issues/85)
|
||||
* [81: Add docker containers config](https://github.com/shlinkio/shlink/issues/81)
|
||||
#### Changed
|
||||
|
||||
**Bugs**
|
||||
* *Nothing*
|
||||
|
||||
* [83: Short codes list: search in tags when filtering by query string](https://github.com/shlinkio/shlink/issues/83)
|
||||
* [79: Increase the number of followed redirects](https://github.com/shlinkio/shlink/issues/79)
|
||||
* [75: Apply PathVersionMiddleware only to rest routes defining it by configuration instead of code](https://github.com/shlinkio/shlink/issues/75)
|
||||
* [77: Allow defining database server hostname and port](https://github.com/shlinkio/shlink/issues/77)
|
||||
#### Deprecated
|
||||
|
||||
### 1.3.0
|
||||
* *Nothing*
|
||||
|
||||
**Enhancements:**
|
||||
#### Removed
|
||||
|
||||
* [67: Allow to order the short codes list](https://github.com/shlinkio/shlink/issues/67)
|
||||
* [60: Accept JSON requests in REST and use a body parser middleware to set the parsedBody](https://github.com/shlinkio/shlink/issues/60)
|
||||
* [72: When listing API keys from CLI, display in yellow color enabled keys that have expired](https://github.com/shlinkio/shlink/issues/72)
|
||||
* [58: Allow to filter short URLs by tag](https://github.com/shlinkio/shlink/issues/58)
|
||||
* [69: Allow to filter short codes by text query](https://github.com/shlinkio/shlink/issues/69)
|
||||
* *Nothing*
|
||||
|
||||
**Tasks**
|
||||
#### Fixed
|
||||
|
||||
* [73: Tag endpoints in swagger file](https://github.com/shlinkio/shlink/issues/73)
|
||||
* [71: Separate swagger docs into multiple files](https://github.com/shlinkio/shlink/issues/71)
|
||||
* [63: Add path versioning to REST API routes](https://github.com/shlinkio/shlink/issues/63)
|
||||
* [#177](https://github.com/shlinkio/shlink/issues/177) Fixed `[GET] /short-codes` endpoint returning a 500 status code when trying to filter by `tags` and `searchTerm` at the same time.
|
||||
* [#175](https://github.com/shlinkio/shlink/issues/175) Fixed error introduced in previous version, where you could end up banned from the service used to resolve IP address locations.
|
||||
|
||||
### 1.2.2
|
||||
In order to fix that, just fill [this form](http://ip-api.com/docs/unban) including your server's IP address and your server should be unbanned.
|
||||
|
||||
In order to prevent this, after resolving 150 IP addresses, shlink now waits 1 minute before trying to resolve any more addresses.
|
||||
|
||||
**Bugs**
|
||||
|
||||
## 1.10.1 - 2018-08-02
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#167](https://github.com/shlinkio/shlink/issues/167) Shlink version is now set at build time to avoid older version numbers to be kept in newer builds.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#165](https://github.com/shlinkio/shlink/issues/165) Fixed custom slugs failing when they are longer than 10 characters.
|
||||
* [#166](https://github.com/shlinkio/shlink/issues/166) Fixed unusual edge case in which visits were not properly counted when ordering by visit and filtering by search term in `[GET] /short-codes` API endpoint.
|
||||
* [#174](https://github.com/shlinkio/shlink/issues/174) Fixed geolocation not working due to a deprecation on used service.
|
||||
* [#172](https://github.com/shlinkio/shlink/issues/172) Documented missing filtering params for `[GET] /short-codes/{shortCode}/visits` API endpoint, which allow the list to be filtered by date range.
|
||||
|
||||
For example: `https://doma.in/rest/v1/short-urls/abc123/visits?startDate=2017-05-23&endDate=2017-10-05`
|
||||
|
||||
* [#169](https://github.com/shlinkio/shlink/issues/169) Fixed unhandled error when parsing `ShortUrlMeta` and date fields are already `DateTime` instances.
|
||||
|
||||
|
||||
## 1.10.0 - 2018-07-09
|
||||
|
||||
#### Added
|
||||
|
||||
* [#161](https://github.com/shlinkio/shlink/issues/161) AddED support for shlink to be run with [swoole](https://www.swoole.co.uk/) via [zend-expressive-swoole](https://github.com/zendframework/zend-expressive-swoole) package
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#159](https://github.com/shlinkio/shlink/issues/159) Updated CHANGELOG to follow the [keep-a-changelog](https://keepachangelog.com) format
|
||||
* [#160](https://github.com/shlinkio/shlink/issues/160) Update infection to v0.9 and phpstan to v 0.10
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## 1.9.1 - 2018-06-18
|
||||
|
||||
#### Added
|
||||
|
||||
* [#155](https://github.com/shlinkio/shlink/issues/155) Improved the pagination object returned in lists, including more meaningful properties.
|
||||
|
||||
* Old structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"pagination": {
|
||||
"currentPage": 1,
|
||||
"pagesCount": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* New structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"pagination": {
|
||||
"currentPage": 2,
|
||||
"pagesCount": 13,
|
||||
"itemsPerPage": 10,
|
||||
"itemsInCurrentPage": 10,
|
||||
"totalItems": 126
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#154](https://github.com/shlinkio/shlink/issues/154) Fixed sizes of every result page when filtering by searchTerm
|
||||
* [#157](https://github.com/shlinkio/shlink/issues/157) Background commands executed by installation process now respect the originally used php binary
|
||||
|
||||
|
||||
## 1.9.0 - 2018-05-07
|
||||
|
||||
#### Added
|
||||
|
||||
* [#147](https://github.com/shlinkio/shlink/issues/147) Allowed short URLs to be created on the fly using a single API request, including the API key in a query param.
|
||||
|
||||
This eases integration with third party services.
|
||||
|
||||
With this feature, a simple request to a URL like `https://doma.in/rest/v1/short-codes/shorten?apiKey=[YOUR_API_KEY]&longUrl=[URL_TO_BE_SHORTENED]` would return the shortened one in JSON or plain text format.
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#139](https://github.com/shlinkio/shlink/issues/139) Ensured all core actions log exceptions
|
||||
|
||||
|
||||
## 1.8.1 - 2018-04-07
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#141](https://github.com/shlinkio/shlink/issues/141) Removed workaround used in `PathVersionMiddleware`, since the bug in zend-stratigility has been fixed.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#140](https://github.com/shlinkio/shlink/issues/140) Fixed warning thrown during installation while trying to include doctrine script
|
||||
|
||||
|
||||
## 1.8.0 - 2018-03-29
|
||||
|
||||
#### Added
|
||||
|
||||
* [#125](https://github.com/shlinkio/shlink/issues/125) Implemented a path which returns a 1px image instead of a redirection.
|
||||
|
||||
Useful to track emails. Just add an image pointing to a URL like `https://doma.in/abc123/track` to any email and an invisible image will be generated tracking every time the email is opened.
|
||||
|
||||
* [#132](https://github.com/shlinkio/shlink/issues/132) Added infection to improve tests
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#130](https://github.com/shlinkio/shlink/issues/130) Updated to Expressive 3
|
||||
* [#137](https://github.com/shlinkio/shlink/issues/137) Updated symfony components to v4
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* [#131](https://github.com/shlinkio/shlink/issues/131) Dropped support for PHP 7
|
||||
|
||||
#### Fixed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## 1.7.2 - 2018-03-26
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#135](https://github.com/shlinkio/shlink/issues/135) Fixed `PathVersionMiddleware` being ignored when using expressive 2.2
|
||||
|
||||
|
||||
## 1.7.1 - 2018-03-21
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#128](https://github.com/shlinkio/shlink/issues/128) Upgraded to expressive 2.2
|
||||
|
||||
This will ease the upcoming update to expressive 3
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#126](https://github.com/shlinkio/shlink/issues/126) Fixed `E_USER_DEPRECATED` errors triggered when using Expressive 2.2
|
||||
|
||||
|
||||
## 1.7.0 - 2018-01-21
|
||||
|
||||
#### Added
|
||||
|
||||
* [#88](https://github.com/shlinkio/shlink/issues/88) Allowed tracking of short URLs to be disabled by including a configurable query param
|
||||
* [#108](https://github.com/shlinkio/shlink/issues/108) Allowed metadata to be defined when creating short codes
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#113](https://github.com/shlinkio/shlink/issues/113) Updated CLI commands to use `SymfonyStyle`
|
||||
* [#112](https://github.com/shlinkio/shlink/issues/112) Enabled Lazy loading in CLI commands
|
||||
* [#117](https://github.com/shlinkio/shlink/issues/117) Every module which throws exceptions has now its own `ExceptionInterface` extending `Throwable`
|
||||
* [#115](https://github.com/shlinkio/shlink/issues/115) Added phpstan to build matrix on PHP >=7.1 envs
|
||||
* [#114](https://github.com/shlinkio/shlink/issues/114) Replaced [vlucas/phpdotenv](https://github.com/vlucas/phpdotenv) dev requirement by [symfony/dotenv](https://github.com/symfony/dotenv)
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## 1.6.2 - 2017-10-25
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#109](https://github.com/shlinkio/shlink/issues/109) Fixed installation error due to typo in latest migration
|
||||
|
||||
|
||||
## 1.6.1 - 2017-10-24
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#110](https://github.com/shlinkio/shlink/issues/110) Created `.gitattributes` file to define files to be excluded from distributable package
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## 1.6.0 - 2017-10-23
|
||||
|
||||
#### Added
|
||||
|
||||
* [#44](https://github.com/shlinkio/shlink/issues/44) Now it is possible to set custom slugs for short URLs instead of using a generated short code
|
||||
* [#47](https://github.com/shlinkio/shlink/issues/47) Allowed to limit short URLs availability by date range
|
||||
* [#48](https://github.com/shlinkio/shlink/issues/48) Allowed to limit the number of visits to a short URL
|
||||
* [#105](https://github.com/shlinkio/shlink/pull/105) Added option to enable/disable URL validation by response status code
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#27](https://github.com/shlinkio/shlink/issues/27) Added repository functional tests with dbunit
|
||||
* [#101](https://github.com/shlinkio/shlink/issues/101) Now specific actions just capture very specific exceptions, and let the `ErrorHandler` catch any other unhandled exception
|
||||
* [#104](https://github.com/shlinkio/shlink/issues/104) Used different templates for *requested-short-code-does-not-exist* and *route-could-not-be-match*
|
||||
* [#99](https://github.com/shlinkio/shlink/issues/99) Replaced usages of `AnnotatedFactory` by `ConfigAbstractFactory`
|
||||
* [#100](https://github.com/shlinkio/shlink/issues/100) Updated templates engine. Replaced twig by plates
|
||||
* [#102](https://github.com/shlinkio/shlink/issues/102) Improved coding standards strictness
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* [#86](https://github.com/shlinkio/shlink/issues/86) Dropped support for PHP 5
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#103](https://github.com/shlinkio/shlink/issues/103) `NotFoundDelegate` now returns proper content types based on accepted content
|
||||
|
||||
|
||||
## 1.5.0 - 2017-07-16
|
||||
|
||||
#### Added
|
||||
|
||||
* [#95](https://github.com/shlinkio/shlink/issues/95) Added tags CRUD to CLI
|
||||
* [#59](https://github.com/shlinkio/shlink/issues/59) Added tags CRUD to REST
|
||||
* [#66](https://github.com/shlinkio/shlink/issues/66) Allowed certain information to be imported from and older shlink instance directory when updating
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#96](https://github.com/shlinkio/shlink/issues/96) Added namespace to functions
|
||||
* [#76](https://github.com/shlinkio/shlink/issues/76) Added response examples to swagger docs
|
||||
* [#93](https://github.com/shlinkio/shlink/issues/93) Improved cross domain management by using the `ImplicitOptionsMiddleware`
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#92](https://github.com/shlinkio/shlink/issues/92) Fixed formatted dates, using an ISO compliant format
|
||||
|
||||
|
||||
## 1.4.0 - 2017-03-25
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#89](https://github.com/shlinkio/shlink/issues/89) Updated to expressive 2
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## 1.3.1 - 2017-01-22
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#82](https://github.com/shlinkio/shlink/issues/82) Enabled `FastRoute` routes cache
|
||||
* [#85](https://github.com/shlinkio/shlink/issues/85) Updated year in license file
|
||||
* [#81](https://github.com/shlinkio/shlink/issues/81) Added docker containers config
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#83](https://github.com/shlinkio/shlink/issues/83) Fixed short codes list: search in tags when filtering by query string
|
||||
* [#79](https://github.com/shlinkio/shlink/issues/79) Increased the number of followed redirects
|
||||
* [#75](https://github.com/shlinkio/shlink/issues/75) Applied `PathVersionMiddleware` only to rest routes defining it by configuration instead of code
|
||||
* [#77](https://github.com/shlinkio/shlink/issues/77) Allowed defining database server hostname and port
|
||||
|
||||
|
||||
## 1.3.0 - 2016-10-23
|
||||
|
||||
#### Added
|
||||
|
||||
* [#67](https://github.com/shlinkio/shlink/issues/67) Allowed to order the short codes list
|
||||
* [#60](https://github.com/shlinkio/shlink/issues/60) Accepted JSON requests in REST and used a body parser middleware to set the request's `parsedBody`
|
||||
* [#72](https://github.com/shlinkio/shlink/issues/72) When listing API keys from CLI, use yellow color for enabled keys that have expired
|
||||
* [#58](https://github.com/shlinkio/shlink/issues/58) Allowed to filter short URLs by tag
|
||||
* [#69](https://github.com/shlinkio/shlink/issues/69) Allowed to filter short URLs by text query
|
||||
* [#73](https://github.com/shlinkio/shlink/issues/73) Added tag-related endpoints to swagger file
|
||||
* [#63](https://github.com/shlinkio/shlink/issues/63) Added path versioning to REST API routes
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#71](https://github.com/shlinkio/shlink/issues/71) Separated swagger docs into multiple files
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## 1.2.2 - 2016-08-29
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* Fixed minor bugs on CORS requests
|
||||
|
||||
### 1.2.1
|
||||
|
||||
**Bugs**
|
||||
## 1.2.1 - 2016-08-21
|
||||
|
||||
* [62: Fix cross-domain requests in REST API](https://github.com/shlinkio/shlink/issues/62)
|
||||
#### Added
|
||||
|
||||
### 1.2.0
|
||||
* *Nothing*
|
||||
|
||||
**Features**
|
||||
#### Changed
|
||||
|
||||
* [45: Allow to define tags on short codes, to improve filtering and classification](https://github.com/shlinkio/shlink/issues/45)
|
||||
* [7: Add website previews while listing available URLs](https://github.com/shlinkio/shlink/issues/7)
|
||||
* *Nothing*
|
||||
|
||||
**Enhancements:**
|
||||
#### Deprecated
|
||||
|
||||
* [57: Add database migrations system to improve updating between versions](https://github.com/shlinkio/shlink/issues/57)
|
||||
* [31: Add support for other database management systems by improving the EntityManager factory](https://github.com/shlinkio/shlink/issues/31)
|
||||
* [51: Generate build process to paquetize the app and ease distribution](https://github.com/shlinkio/shlink/issues/51)
|
||||
* [38: Define installation script. It will request dynamic data on the fly so that there is no need to define env vars](https://github.com/shlinkio/shlink/issues/38)
|
||||
* *Nothing*
|
||||
|
||||
**Tasks**
|
||||
#### Removed
|
||||
|
||||
* [55: Create update script which does not try to create a new database](https://github.com/shlinkio/shlink/issues/55)
|
||||
* [54: Add cache namespace to prevent name collisions with other apps in the same environment](https://github.com/shlinkio/shlink/issues/54)
|
||||
* [29: Use the acelaya/ze-content-based-error-handler package instead of custom error handler implementation](https://github.com/shlinkio/shlink/issues/29)
|
||||
* *Nothing*
|
||||
|
||||
**Bugs**
|
||||
#### Fixed
|
||||
|
||||
* [53: Fix entities database interoperability](https://github.com/shlinkio/shlink/issues/53)
|
||||
* [52: Add missing htaccess file for apache environments](https://github.com/shlinkio/shlink/issues/52)
|
||||
* [#62](https://github.com/shlinkio/shlink/issues/62) Fixed cross-domain requests in REST API
|
||||
|
||||
### 1.1.0
|
||||
|
||||
**Features**
|
||||
## 1.2.0 - 2016-08-21
|
||||
|
||||
* [46: Define a route that returns a QR code representing the shortened URL](https://github.com/shlinkio/shlink/issues/46)
|
||||
#### Added
|
||||
|
||||
**Enhancements:**
|
||||
* [#45](https://github.com/shlinkio/shlink/issues/45) Allowed to define tags on short codes, to improve filtering and classification
|
||||
* [#7](https://github.com/shlinkio/shlink/issues/7) Added website previews while listing available URLs
|
||||
* [#57](https://github.com/shlinkio/shlink/issues/57) Added database migrations system to improve updating between versions
|
||||
* [#31](https://github.com/shlinkio/shlink/issues/31) Added support for other database management systems by improving the `EntityManager` factory
|
||||
* [#51](https://github.com/shlinkio/shlink/issues/51) Generated build process to create app package and ease distribution
|
||||
* [#38](https://github.com/shlinkio/shlink/issues/38) Defined installation script. It will request dynamic data on the fly so that there is no need to define env vars
|
||||
* [#55](https://github.com/shlinkio/shlink/issues/55) Created update script which does not try to create a new database
|
||||
|
||||
* [32: Add support for other cache adapters by improving the Cache factory](https://github.com/shlinkio/shlink/issues/32)
|
||||
* [14: https://github.com/shlinkio/shlink/issues/14](https://github.com/shlinkio/shlink/issues/14)
|
||||
* [41: Cache the "short code" => "URL" map to prevent extra DB hits](https://github.com/shlinkio/shlink/issues/41)
|
||||
* [13: Improve REST authentication](https://github.com/shlinkio/shlink/issues/13)
|
||||
#### Changed
|
||||
|
||||
**Tasks**
|
||||
* [#54](https://github.com/shlinkio/shlink/issues/54) Added cache namespace to prevent name collisions with other apps in the same environment
|
||||
* [#29](https://github.com/shlinkio/shlink/issues/29) Used the [acelaya/ze-content-based-error-handler](https://github.com/acelaya/ze-content-based-error-handler) package instead of custom error handler implementation
|
||||
|
||||
* [39: Change copyright from "Alejandro Celaya" to "Shlink" in error pages](https://github.com/shlinkio/shlink/issues/39)
|
||||
* [42: Make REST endpoints that need to find something return a 404 when "something" is not found](https://github.com/shlinkio/shlink/issues/42)
|
||||
* [35: Make CLI commands to use the same PHP namespace as the one used for the command name](https://github.com/shlinkio/shlink/issues/35)
|
||||
#### Deprecated
|
||||
|
||||
**Bugs**
|
||||
* *Nothing*
|
||||
|
||||
* [40: Take into account the X-Forwarded-For header in order to get the visitor information, in case the server is behind a load balancer or proxy](https://github.com/shlinkio/shlink/issues/40)
|
||||
#### Removed
|
||||
|
||||
### 1.0.0
|
||||
* *Nothing*
|
||||
|
||||
**Enhancements:**
|
||||
#### Fixed
|
||||
|
||||
* [33: Create a command to generate a short code charset by randomizing the default one](https://github.com/shlinkio/shlink/issues/33)
|
||||
* [15: Return JSON/HTML responses for errors (4xx and 5xx) based on accept header (content negotiation)](https://github.com/shlinkio/shlink/issues/15)
|
||||
* [23: Translate application literals](https://github.com/shlinkio/shlink/issues/23)
|
||||
* [21: Allow to filter visits by date range](https://github.com/shlinkio/shlink/issues/21)
|
||||
* [22: Save visits locations data on a visit_locations table](https://github.com/shlinkio/shlink/issues/22)
|
||||
* [20: Inject cross domain headers in response only if the Origin header is present in the request](https://github.com/shlinkio/shlink/issues/20)
|
||||
* [11: Separate code into multiple modules](https://github.com/shlinkio/shlink/issues/11)
|
||||
* [18: Group routable middleware in an Action namespace](https://github.com/shlinkio/shlink/issues/18)
|
||||
* [#53](https://github.com/shlinkio/shlink/issues/53) Fixed entities database interoperability
|
||||
* [#52](https://github.com/shlinkio/shlink/issues/52) Added missing htaccess file for apache environments
|
||||
|
||||
**Tasks**
|
||||
|
||||
* [36: Remove hhvm from the CI matrix since it doesn't support array constants and will fail](https://github.com/shlinkio/shlink/issues/36)
|
||||
* [4: Installation steps](https://github.com/shlinkio/shlink/issues/4)
|
||||
* [6: Remove dependency on expressive helpers package](https://github.com/shlinkio/shlink/issues/6)
|
||||
* [30: Replace the "services" first level config entry by "dependencies", in order to fulfill default Expressive name](https://github.com/shlinkio/shlink/issues/30)
|
||||
* [12: Improve code coverage](https://github.com/shlinkio/shlink/issues/12)
|
||||
* [25: Replace "Middleware" suffix on routable middlewares by "Action"](https://github.com/shlinkio/shlink/issues/25)
|
||||
* [19: Update the vendor and app namespace from Acelaya\UrlShortener to Shlinkio\Shlink](https://github.com/shlinkio/shlink/issues/19)
|
||||
## 1.1.0 - 2016-08-09
|
||||
|
||||
**Bugs**
|
||||
#### Added
|
||||
|
||||
* [24: Prevent duplicated shortcodes errors because of the case insensitive behavior on MySQL](https://github.com/shlinkio/shlink/issues/24)
|
||||
* [#46](https://github.com/shlinkio/shlink/issues/46) Defined a route that returns a QR code representing the shortened URL.
|
||||
|
||||
### 0.2.0
|
||||
In order to get the QR code URL, use a pattern like `https://doma.in/abc123/qr-code`
|
||||
|
||||
**Enhancements:**
|
||||
* [#32](https://github.com/shlinkio/shlink/issues/32) Added support for other cache adapters by improving the Cache factory
|
||||
* [#14](https://github.com/shlinkio/shlink/issues/14) Added logger and enabled errors logging
|
||||
* [#13](https://github.com/shlinkio/shlink/issues/13) Improved REST authentication
|
||||
|
||||
* [9: Use symfony/console to dispatch console requests, instead of trying to integrate the process with expressive](https://github.com/shlinkio/shlink/issues/9)
|
||||
* [8: Create a REST API](https://github.com/shlinkio/shlink/issues/8)
|
||||
* [10: Add more CLI functionality](https://github.com/shlinkio/shlink/issues/10)
|
||||
#### Changed
|
||||
|
||||
**Tasks**
|
||||
* [#41](https://github.com/shlinkio/shlink/issues/41) Cached the "short code" => "URL" map to prevent extra DB hits
|
||||
* [#39](https://github.com/shlinkio/shlink/issues/39) Changed copyright from "Alejandro Celaya" to "Shlink" in error pages
|
||||
* [#42](https://github.com/shlinkio/shlink/issues/42) REST endpoints that need to find *something* now return a 404 when it is not found
|
||||
* [#35](https://github.com/shlinkio/shlink/issues/35) Updated CLI commands to use the same PHP namespace as the one used for the command name
|
||||
|
||||
* [5: Create CHANGELOG file](https://github.com/shlinkio/shlink/issues/5)
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#40](https://github.com/shlinkio/shlink/issues/40) Taken into account the `X-Forwarded-For` header in order to get the visitor information, in case the server is behind a load balancer or proxy
|
||||
|
||||
|
||||
## 1.0.0 - 2016-08-01
|
||||
|
||||
#### Added
|
||||
|
||||
* [#33](https://github.com/shlinkio/shlink/issues/33) Created a command that generates a short code charset by randomizing the default one
|
||||
* [#23](https://github.com/shlinkio/shlink/issues/23) Translated application literals
|
||||
* [#21](https://github.com/shlinkio/shlink/issues/21) Allowed to filter visits by date range
|
||||
* [#4](https://github.com/shlinkio/shlink/issues/4) Added installation steps
|
||||
* [#12](https://github.com/shlinkio/shlink/issues/12) Improved code coverage
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#15](https://github.com/shlinkio/shlink/issues/15) HTTP requests now return JSON/HTML responses for errors (4xx and 5xx) based on `Accept` header
|
||||
* [#22](https://github.com/shlinkio/shlink/issues/22) Now visits locations data is saved on a `visit_locations` table
|
||||
* [#20](https://github.com/shlinkio/shlink/issues/20) Injected cross domain headers in response only if the `Origin` header is present in the request
|
||||
* [#11](https://github.com/shlinkio/shlink/issues/11) Separated code into multiple modules
|
||||
* [#18](https://github.com/shlinkio/shlink/issues/18) Grouped routable middleware in an Action namespace
|
||||
* [#6](https://github.com/shlinkio/shlink/issues/6) Project no longer depends on [zendframework/zend-expressive-helpers](https://github.com/zendframework/zend-expressive-helpers) package
|
||||
* [#30](https://github.com/shlinkio/shlink/issues/30) Replaced the "services" first level config entry by "dependencies", in order to fulfill default Expressive naming
|
||||
* [#25](https://github.com/shlinkio/shlink/issues/25) Replaced "Middleware" suffix on routable middlewares by "Action"
|
||||
* [#19](https://github.com/shlinkio/shlink/issues/19) Changed the vendor and app namespace from `Acelaya\UrlShortener` to `Shlinkio\Shlink`
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* [#36](https://github.com/shlinkio/shlink/issues/36) Removed hhvm from the CI matrix since it doesn't support array constants and will fail
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#24](https://github.com/shlinkio/shlink/issues/24) Prevented duplicated short codes errors because of the case insensitive behavior on MySQL
|
||||
|
||||
|
||||
## 0.2.0 - 2016-08-01
|
||||
|
||||
#### Added
|
||||
|
||||
* [#8](https://github.com/shlinkio/shlink/issues/8) Created a REST API
|
||||
* [#10](https://github.com/shlinkio/shlink/issues/10) Added more CLI functionality
|
||||
* [#5](https://github.com/shlinkio/shlink/issues/5) Created a CHANGELOG file
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#9](https://github.com/shlinkio/shlink/issues/9) Used [symfony/console](https://github.com/symfony/console) to dispatch console requests, instead of trying to integrate the process with expressive
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2017 Alejandro Celaya
|
||||
Copyright (c) 2018 Alejandro Celaya
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
150
README.md
150
README.md
@@ -1,9 +1,147 @@
|
||||
# Shlink
|
||||
|
||||
[](https://travis-ci.org/shlinkio/shlink)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
|
||||
[](https://packagist.org/packages/shlinkio/shlink)
|
||||
[](https://packagist.org/packages/shlinkio/shlink)
|
||||
[](https://travis-ci.org/shlinkio/shlink)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
|
||||
[](https://packagist.org/packages/shlinkio/shlink)
|
||||
[](https://github.com/shlinkio/shlink/blob/master/LICENSE)
|
||||
[](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.
|
||||
|
||||
16
build.sh
16
build.sh
@@ -10,6 +10,7 @@ fi
|
||||
version=$1
|
||||
builtcontent=$(readlink -f "../shlink_${version}_dist")
|
||||
projectdir=$(pwd)
|
||||
[ -f ./composer.phar ] && composerBin='./composer.phar' || composerBin='composer'
|
||||
|
||||
# Copy project content to temp dir
|
||||
echo 'Copying project files...'
|
||||
@@ -20,10 +21,11 @@ cp -R "${projectdir}"/* "${builtcontent}"
|
||||
cd "${builtcontent}"
|
||||
|
||||
# Install dependencies
|
||||
echo "Installing dependencies with $composerBin..."
|
||||
rm -rf vendor
|
||||
rm -f composer.lock
|
||||
composer self-update
|
||||
composer install --no-dev --optimize-autoloader --no-progress --no-interaction
|
||||
$composerBin self-update
|
||||
$composerBin install --no-dev --optimize-autoloader --no-progress --no-interaction
|
||||
|
||||
# Delete development files
|
||||
echo 'Deleting dev files...'
|
||||
@@ -33,8 +35,12 @@ rm composer.*
|
||||
rm LICENSE
|
||||
rm indocker
|
||||
rm docker-compose.yml
|
||||
rm docker-compose.override.yml
|
||||
rm docker-compose.override.yml.dist
|
||||
rm func_tests_bootstrap.php
|
||||
rm php*
|
||||
rm README.md
|
||||
rm infection.json
|
||||
rm -rf build
|
||||
rm -ff data/database.sqlite
|
||||
rm -rf data/infra
|
||||
@@ -42,7 +48,13 @@ rm -rf data/{cache,log,proxies}/{*,.gitignore}
|
||||
rm -rf config/params/{*,.gitignore}
|
||||
rm -rf config/autoload/{{,*.}local.php{,.dist},.gitignore}
|
||||
|
||||
# Update shlink version in config
|
||||
sed -i "s/%SHLINK_VERSION%/${version}/g" config/autoload/app_options.global.php
|
||||
|
||||
# Compressing file
|
||||
echo 'Compressing files...'
|
||||
rm -f "${projectdir}"/build/shlink_${version}_dist.zip
|
||||
zip -ry "${projectdir}"/build/shlink_${version}_dist.zip "../shlink_${version}_dist"
|
||||
rm -rf "${builtcontent}"
|
||||
|
||||
echo 'Done!'
|
||||
|
||||
@@ -12,33 +12,31 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^7.0",
|
||||
"acelaya/ze-content-based-error-handler": "^2.0",
|
||||
"php": "^7.1",
|
||||
"ext-json": "*",
|
||||
"ext-pdo": "*",
|
||||
"acelaya/ze-content-based-error-handler": "^2.2",
|
||||
"cocur/slugify": "^3.0",
|
||||
"doctrine/annotations": "^1.4",
|
||||
"doctrine/cache": "^1.6",
|
||||
"doctrine/collections": "^1.4",
|
||||
"doctrine/common": "^2.7",
|
||||
"doctrine/dbal": "^2.5",
|
||||
"doctrine/migrations": "^1.4",
|
||||
"doctrine/orm": "^2.5",
|
||||
"endroid/qrcode": "^1.7",
|
||||
"endroid/qr-code": "^1.7",
|
||||
"firebase/php-jwt": "^4.0",
|
||||
"guzzlehttp/guzzle": "^6.2",
|
||||
"http-interop/http-middleware": "^0.4.1",
|
||||
"mikehaertl/phpwkhtmltopdf": "^2.2",
|
||||
"monolog/monolog": "^1.21",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"symfony/console": "^3.4",
|
||||
"symfony/filesystem": "^3.0",
|
||||
"symfony/process": "^3.0",
|
||||
"symfony/console": "^4.0",
|
||||
"symfony/filesystem": "^4.0",
|
||||
"symfony/process": "^4.0",
|
||||
"theorchard/monolog-cascade": "^0.4",
|
||||
"zendframework/zend-config": "^3.0",
|
||||
"zendframework/zend-config-aggregator": "^1.0",
|
||||
"zendframework/zend-expressive": "^2.0",
|
||||
"zendframework/zend-expressive-fastroute": "^2.0",
|
||||
"zendframework/zend-expressive-helpers": "^4.2",
|
||||
"zendframework/zend-expressive-platesrenderer": "^1.3",
|
||||
"zendframework/zend-diactoros": "^1.7",
|
||||
"zendframework/zend-expressive": "^3.0",
|
||||
"zendframework/zend-expressive-fastroute": "^3.0",
|
||||
"zendframework/zend-expressive-helpers": "^5.0",
|
||||
"zendframework/zend-expressive-platesrenderer": "^2.0",
|
||||
"zendframework/zend-i18n": "^2.7",
|
||||
"zendframework/zend-inputfilter": "^2.8",
|
||||
"zendframework/zend-paginator": "^2.6",
|
||||
@@ -47,14 +45,16 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"filp/whoops": "^2.0",
|
||||
"phpunit/dbunit": "^3.0",
|
||||
"phpunit/phpcov": "^4.0",
|
||||
"phpunit/phpunit": "^6.0",
|
||||
"infection/infection": "^0.9.0",
|
||||
"phpstan/phpstan": "^0.10.0",
|
||||
"phpunit/phpcov": "^5.0",
|
||||
"phpunit/phpunit": "^7.0",
|
||||
"slevomat/coding-standard": "^4.0",
|
||||
"squizlabs/php_codesniffer": "^3.1 <3.2",
|
||||
"symfony/dotenv": "^3.4",
|
||||
"symfony/var-dumper": "^3.0",
|
||||
"zendframework/zend-expressive-tooling": "^0.4"
|
||||
"squizlabs/php_codesniffer": "^3.2.3",
|
||||
"symfony/dotenv": "^4.0",
|
||||
"symfony/var-dumper": "^4.0",
|
||||
"zendframework/zend-component-installer": "^2.1",
|
||||
"zendframework/zend-expressive-tooling": "^1.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -84,8 +84,10 @@
|
||||
"scripts": {
|
||||
"check": [
|
||||
"@cs",
|
||||
"@stan",
|
||||
"@test",
|
||||
"@func-test"
|
||||
"@func-test",
|
||||
"@infect"
|
||||
],
|
||||
"cs": "phpcs",
|
||||
"cs-fix": "phpcbf",
|
||||
@@ -97,13 +99,17 @@
|
||||
"@test",
|
||||
"@func-test",
|
||||
"phpcov merge build --html build/html"
|
||||
]
|
||||
],
|
||||
"stan": "phpstan analyse module/*/src/ --level=6 -c phpstan.neon",
|
||||
"infect": "infection --threads=4 --min-msi=55 --only-covered --log-verbosity=2",
|
||||
"infect-show": "infection --threads=4 --min-msi=55 --only-covered --log-verbosity=2 --show-mutations",
|
||||
"expressive": "expressive"
|
||||
},
|
||||
"config": {
|
||||
"process-timeout": 0,
|
||||
"sort-packages": true,
|
||||
"platform": {
|
||||
"php": "7.0.8"
|
||||
"php": "7.1.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ return [
|
||||
|
||||
'app_options' => [
|
||||
'name' => 'Shlink',
|
||||
'version' => '1.7.0',
|
||||
'version' => '%SHLINK_VERSION%',
|
||||
'secret_key' => env('SECRET_KEY'),
|
||||
'disable_track_param' => null,
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
13
config/autoload/delete_short_urls.global.php
Normal file
13
config/autoload/delete_short_urls.global.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink;
|
||||
|
||||
return [
|
||||
|
||||
'delete_short_urls' => [
|
||||
'visits_threshold' => 15,
|
||||
'check_visits_threshold' => true,
|
||||
],
|
||||
|
||||
];
|
||||
@@ -5,30 +5,23 @@ use Shlinkio\Shlink\Common\Factory\EmptyResponseImplicitOptionsMiddlewareFactory
|
||||
use Zend\Expressive;
|
||||
use Zend\Expressive\Container;
|
||||
use Zend\Expressive\Helper;
|
||||
use Zend\Expressive\Middleware;
|
||||
use Zend\Expressive\Plates;
|
||||
use Zend\Expressive\Router;
|
||||
use Zend\Expressive\Router\Middleware\ImplicitOptionsMiddleware;
|
||||
use Zend\Expressive\Template;
|
||||
use Zend\ServiceManager\Factory\InvokableFactory;
|
||||
use Zend\Stratigility\Middleware\ErrorHandler;
|
||||
|
||||
return [
|
||||
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
Expressive\Application::class => Container\ApplicationFactory::class,
|
||||
Template\TemplateRendererInterface::class => Plates\PlatesRendererFactory::class,
|
||||
Router\RouterInterface::class => Router\FastRouteRouterFactory::class,
|
||||
ErrorHandler::class => Container\ErrorHandlerFactory::class,
|
||||
ImplicitOptionsMiddleware::class => EmptyResponseImplicitOptionsMiddlewareFactory::class,
|
||||
|
||||
Helper\UrlHelper::class => Helper\UrlHelperFactory::class,
|
||||
Helper\ServerUrlHelper::class => InvokableFactory::class,
|
||||
],
|
||||
|
||||
'aliases' => [
|
||||
Middleware\ImplicitOptionsMiddleware::class => ImplicitOptionsMiddleware::class,
|
||||
'delegators' => [
|
||||
Expressive\Application::class => [
|
||||
Container\ApplicationConfigInjectionDelegator::class,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use Shlinkio\Shlink\Common\Middleware\LocaleMiddleware;
|
||||
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
|
||||
use Shlinkio\Shlink\Rest\Middleware\BodyParserMiddleware;
|
||||
use Shlinkio\Shlink\Rest\Middleware\CheckAuthenticationMiddleware;
|
||||
use Shlinkio\Shlink\Rest\Middleware\CrossDomainMiddleware;
|
||||
@@ -15,12 +16,13 @@ return [
|
||||
'pre-routing' => [
|
||||
'middleware' => [
|
||||
ErrorHandler::class,
|
||||
Expressive\Helper\ContentLengthMiddleware::class,
|
||||
LocaleMiddleware::class,
|
||||
],
|
||||
'priority' => 11,
|
||||
],
|
||||
'pre-routing-rest' => [
|
||||
// 'path' => '/rest',
|
||||
'path' => '/rest',
|
||||
'middleware' => [
|
||||
PathVersionMiddleware::class,
|
||||
],
|
||||
@@ -48,6 +50,7 @@ return [
|
||||
'post-routing' => [
|
||||
'middleware' => [
|
||||
Expressive\Router\Middleware\DispatchMiddleware::class,
|
||||
NotFoundHandler::class,
|
||||
],
|
||||
'priority' => 1,
|
||||
],
|
||||
|
||||
@@ -7,6 +7,7 @@ use Shlinkio\Shlink\Common;
|
||||
use Shlinkio\Shlink\Core;
|
||||
use Shlinkio\Shlink\Rest;
|
||||
use Zend\ConfigAggregator;
|
||||
use Zend\Expressive;
|
||||
|
||||
/**
|
||||
* Configuration files are loaded in a specific order. First ``global.php``, then ``*.global.php``.
|
||||
@@ -18,8 +19,14 @@ use Zend\ConfigAggregator;
|
||||
*/
|
||||
|
||||
return (new ConfigAggregator\ConfigAggregator([
|
||||
Zend\Expressive\ConfigProvider::class,
|
||||
Zend\Expressive\Router\ConfigProvider::class,
|
||||
Expressive\ConfigProvider::class,
|
||||
Expressive\Router\ConfigProvider::class,
|
||||
Expressive\Router\FastRouteRouter\ConfigProvider::class,
|
||||
Expressive\Plates\ConfigProvider::class,
|
||||
Expressive\Helper\ConfigProvider::class,
|
||||
\class_exists(Expressive\Swoole\ConfigProvider::class)
|
||||
? Expressive\Swoole\ConfigProvider::class
|
||||
: new ConfigAggregator\ArrayProvider([]),
|
||||
ExpressiveErrorHandler\ConfigProvider::class,
|
||||
Common\ConfigProvider::class,
|
||||
Core\ConfigProvider::class,
|
||||
|
||||
0
data/infra/database/.gitignore
vendored
Normal file → Executable file
0
data/infra/database/.gitignore
vendored
Normal file → Executable file
0
data/infra/nginx/.gitignore
vendored
Normal file → Executable file
0
data/infra/nginx/.gitignore
vendored
Normal file → Executable file
45
data/migrations/Version20180801183328.php
Normal file
45
data/migrations/Version20180801183328.php
Normal 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);
|
||||
}
|
||||
}
|
||||
74
data/migrations/Version20180913205455.php
Normal file
74
data/migrations/Version20180913205455.php
Normal 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
|
||||
}
|
||||
}
|
||||
50
data/migrations/Version20180915110857.php
Normal file
50
data/migrations/Version20180915110857.php
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,23 @@
|
||||
"properties": {
|
||||
"currentPage": {
|
||||
"type": "integer",
|
||||
"description": "The number of current page being displayed."
|
||||
"description": "The number of current page."
|
||||
},
|
||||
"pagesCount": {
|
||||
"type": "integer",
|
||||
"description": "The total number of pages that can be displayed."
|
||||
"description": "The total number of pages that can be obtained."
|
||||
},
|
||||
"itemsPerPage": {
|
||||
"type": "integer",
|
||||
"description": "The number of items for every page."
|
||||
},
|
||||
"itemsInCurrentPage": {
|
||||
"type": "integer",
|
||||
"description": "The number of items in current page (could be smaller than itemsPerPage)."
|
||||
},
|
||||
"totalItems": {
|
||||
"type": "integer",
|
||||
"description": "The total number of items among all pages."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
"type": "string",
|
||||
"description": "The short code for this short URL."
|
||||
},
|
||||
"originalUrl": {
|
||||
"shortUrl": {
|
||||
"type": "string",
|
||||
"description": "The short URL."
|
||||
},
|
||||
"longUrl": {
|
||||
"type": "string",
|
||||
"description": "The original long URL."
|
||||
},
|
||||
@@ -24,6 +28,11 @@
|
||||
"type": "string"
|
||||
},
|
||||
"description": "A list of tags applied to this short URL"
|
||||
},
|
||||
"originalUrl": {
|
||||
"deprecated": true,
|
||||
"type": "string",
|
||||
"description": "The original long URL. [DEPRECATED. Use longUrl instead]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,25 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"referer": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "The origin from which the visit was performed"
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"remoteAddr": {
|
||||
"type": "string"
|
||||
"format": "date-time",
|
||||
"description": "The date in which the visit was performed"
|
||||
},
|
||||
"userAgent": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "The user agent from which the visit was performed"
|
||||
},
|
||||
"visitLocation": {
|
||||
"$ref": "./VisitLocation.json"
|
||||
},
|
||||
"remoteAddr": {
|
||||
"type": "string",
|
||||
"description": "This value is deprecated and will always be null",
|
||||
"deprecated": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
26
docs/swagger/definitions/VisitLocation.json
Normal file
26
docs/swagger/definitions/VisitLocation.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tags",
|
||||
"name": "tags[]",
|
||||
"in": "query",
|
||||
"description": "A list of tags used to filter the resultset. Only short URLs tagged with at least one of the provided tags will be returned. (Since v1.3.0)",
|
||||
"required": false,
|
||||
@@ -44,7 +44,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"originalUrl",
|
||||
"longUrl",
|
||||
"shortCode",
|
||||
"dateCreated",
|
||||
"visits"
|
||||
@@ -89,7 +89,8 @@
|
||||
"data": [
|
||||
{
|
||||
"shortCode": "12C18",
|
||||
"originalUrl": "https://store.steampowered.com",
|
||||
"shortUrl": "https://doma.in/12C18",
|
||||
"longUrl": "https://store.steampowered.com",
|
||||
"dateCreated": "2016-08-21T20:34:16+02:00",
|
||||
"visitsCount": 328,
|
||||
"tags": [
|
||||
@@ -99,7 +100,8 @@
|
||||
},
|
||||
{
|
||||
"shortCode": "12Kb3",
|
||||
"originalUrl": "https://shlink.io",
|
||||
"shortUrl": "https://doma.in/12Kb3",
|
||||
"longUrl": "https://shlink.io",
|
||||
"dateCreated": "2016-05-01T20:34:16+02:00",
|
||||
"visitsCount": 1029,
|
||||
"tags": [
|
||||
@@ -108,7 +110,8 @@
|
||||
},
|
||||
{
|
||||
"shortCode": "123bA",
|
||||
"originalUrl": "https://www.google.com",
|
||||
"shortUrl": "https://doma.in/123bA",
|
||||
"longUrl": "https://www.google.com",
|
||||
"dateCreated": "2015-10-01T20:34:16+02:00",
|
||||
"visitsCount": 25,
|
||||
"tags": []
|
||||
@@ -116,7 +119,10 @@
|
||||
],
|
||||
"pagination": {
|
||||
"currentPage": 5,
|
||||
"pagesCount": 12
|
||||
"pagesCount": 12,
|
||||
"itemsPerPage": 10,
|
||||
"itemsInCurrentPage": 10,
|
||||
"totalItems": 115
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
125
docs/swagger/paths/v1_short-codes_shorten.json
Normal file
125
docs/swagger/paths/v1_short-codes_shorten.json
Normal file
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"get": {
|
||||
"tags": [
|
||||
"ShortCodes"
|
||||
],
|
||||
"summary": "Create a short URL",
|
||||
"description": "Creates a short URL in a single API call. Useful for third party integrations",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "apiKey",
|
||||
"in": "query",
|
||||
"description": "The API key used to authenticate the request",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "longUrl",
|
||||
"in": "query",
|
||||
"description": "The URL to be shortened",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "format",
|
||||
"in": "query",
|
||||
"description": "The format in which you want the response to be returned. You can also use the \"Accept\" header instead of this",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"txt",
|
||||
"json"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The list of short URLs",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"longUrl": {
|
||||
"type": "string",
|
||||
"description": "The original long URL that has been shortened"
|
||||
},
|
||||
"shortUrl": {
|
||||
"type": "string",
|
||||
"description": "The generated short URL"
|
||||
},
|
||||
"shortCode": {
|
||||
"type": "string",
|
||||
"description": "the short code that is being used in the short URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"application/json": {
|
||||
"longUrl": "https://github.com/shlinkio/shlink",
|
||||
"shortUrl": "https://dom.ain/abc123",
|
||||
"shortCode": "abc123"
|
||||
},
|
||||
"text/plain": "https://dom.ain/abc123"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "The long URL was not provided or is invalid.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
},
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"application/json": {
|
||||
"error": "INVALID_URL",
|
||||
"message": "Provided URL foo is invalid. Try with a different one."
|
||||
},
|
||||
"text/plain": "INVALID_URL"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Unexpected error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
},
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"application/json": {
|
||||
"error": "UNKNOWN_ERROR",
|
||||
"message": "Unexpected error occurred"
|
||||
},
|
||||
"text/plain": "UNKNOWN_ERROR"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,23 +23,24 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The long URL behind a short code.",
|
||||
"description": "The URL info behind a short code.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"longUrl": {
|
||||
"type": "string",
|
||||
"description": "The original long URL behind the short code."
|
||||
}
|
||||
}
|
||||
"$ref": "../definitions/ShortUrl.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"application/json": {
|
||||
"longUrl": "https://shlink.io"
|
||||
"shortCode": "12Kb3",
|
||||
"shortUrl": "https://doma.in/12Kb3",
|
||||
"longUrl": "https://shlink.io",
|
||||
"dateCreated": "2016-05-01T20:34:16+02:00",
|
||||
"visitsCount": 1029,
|
||||
"tags": [
|
||||
"shlink"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -158,5 +159,70 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"delete": {
|
||||
"tags": [
|
||||
"ShortCodes"
|
||||
],
|
||||
"summary": "Delete short code",
|
||||
"description": "Deletes the short URL for provided short code.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "shortCode",
|
||||
"in": "path",
|
||||
"description": "The short code to edit.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "The short code has been properly deleted."
|
||||
},
|
||||
"400": {
|
||||
"description": "The visits threshold in shlink does not allow this short URL to be deleted.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"application/json": {
|
||||
"error": "INVALID_SHORTCODE_DELETION",
|
||||
"message": "It is not possible to delete URL with short code \"abc123\" because it has reached more than \"15\" visits."
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No short URL was found for provided short code.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Unexpected error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,24 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "startDate",
|
||||
"in": "query",
|
||||
"description": "The date (in ISO-8601 format) from which we want to get visits.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "endDate",
|
||||
"in": "query",
|
||||
"description": "The date (in ISO-8601 format) until which we want to get visits.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
@@ -52,20 +70,28 @@
|
||||
{
|
||||
"referer": "https://twitter.com",
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"remoteAddr": "10.20.30.40",
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0"
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
|
||||
"visitLocation": null
|
||||
},
|
||||
{
|
||||
"referer": "https://t.co",
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"remoteAddr": "11.22.33.44",
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36"
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
|
||||
"visitLocation": {
|
||||
"cityName": "Cupertino",
|
||||
"countryCode": "US",
|
||||
"countryName": "United States",
|
||||
"latitude": "37.3042",
|
||||
"longitude": "-122.0946",
|
||||
"regionName": "California",
|
||||
"timezone": "America/Los_Angeles"
|
||||
}
|
||||
},
|
||||
{
|
||||
"referer": null,
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"remoteAddr": "110.220.5.6",
|
||||
"userAgent": "some_web_crawler/1.4"
|
||||
"userAgent": "some_web_crawler/1.4",
|
||||
"visitLocation": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
"/v1/short-codes": {
|
||||
"$ref": "paths/v1_short-codes.json"
|
||||
},
|
||||
"/v1/short-codes/shorten": {
|
||||
"$ref": "paths/v1_short-codes_shorten.json"
|
||||
},
|
||||
"/v1/short-codes/{shortCode}": {
|
||||
"$ref": "paths/v1_short-codes_{shortCode}.json"
|
||||
},
|
||||
|
||||
22
infection.json
Normal file
22
infection.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"source": {
|
||||
"directories": [
|
||||
"module/*/src"
|
||||
],
|
||||
"excludes": []
|
||||
},
|
||||
"timeout": 10,
|
||||
"logs": {
|
||||
"text": "build/infection/infection-log.txt",
|
||||
"summary": "build/infection/summary-log.txt",
|
||||
"debug": "build/infection/debug-log.txt"
|
||||
},
|
||||
"tmpDir": "build/infection/temp",
|
||||
"phpUnit": {
|
||||
"configDir": "."
|
||||
},
|
||||
"mutators": {
|
||||
"@default": true,
|
||||
"IdenticalEqual": false
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ return [
|
||||
Command\Shortcode\ListShortcodesCommand::NAME => Command\Shortcode\ListShortcodesCommand::class,
|
||||
Command\Shortcode\GetVisitsCommand::NAME => Command\Shortcode\GetVisitsCommand::class,
|
||||
Command\Shortcode\GeneratePreviewCommand::NAME => Command\Shortcode\GeneratePreviewCommand::class,
|
||||
Command\Shortcode\DeleteShortCodeCommand::NAME => Command\Shortcode\DeleteShortCodeCommand::class,
|
||||
|
||||
Command\Visit\ProcessVisitsCommand::NAME => Command\Visit\ProcessVisitsCommand::class,
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command;
|
||||
use Shlinkio\Shlink\CLI\Factory\ApplicationFactory;
|
||||
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
|
||||
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
|
||||
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
|
||||
use Shlinkio\Shlink\Core\Service;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||
@@ -22,12 +22,17 @@ return [
|
||||
Command\Shortcode\ListShortcodesCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Shortcode\GetVisitsCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Shortcode\GeneratePreviewCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Shortcode\DeleteShortCodeCommand::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\Visit\ProcessVisitsCommand::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\Config\GenerateCharsetCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Config\GenerateSecretCommand::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Tag\CreateTagCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class,
|
||||
@@ -42,23 +47,35 @@ return [
|
||||
'config.url_shortener.domain',
|
||||
],
|
||||
Command\Shortcode\ResolveUrlCommand::class => [Service\UrlShortener::class, 'translator'],
|
||||
Command\Shortcode\ListShortcodesCommand::class => [Service\ShortUrlService::class, 'translator'],
|
||||
Command\Shortcode\ListShortcodesCommand::class => [
|
||||
Service\ShortUrlService::class,
|
||||
'translator',
|
||||
'config.url_shortener.domain',
|
||||
],
|
||||
Command\Shortcode\GetVisitsCommand::class => [Service\VisitsTracker::class, 'translator'],
|
||||
Command\Shortcode\GeneratePreviewCommand::class => [
|
||||
Service\ShortUrlService::class,
|
||||
PreviewGenerator::class,
|
||||
'translator',
|
||||
],
|
||||
Command\Visit\ProcessVisitsCommand::class => [
|
||||
Service\VisitService::class,
|
||||
IpLocationResolver::class,
|
||||
Command\Shortcode\DeleteShortCodeCommand::class => [
|
||||
Service\ShortUrl\DeleteShortUrlService::class,
|
||||
'translator',
|
||||
],
|
||||
|
||||
Command\Visit\ProcessVisitsCommand::class => [
|
||||
Service\VisitService::class,
|
||||
IpApiLocationResolver::class,
|
||||
'translator',
|
||||
],
|
||||
|
||||
Command\Config\GenerateCharsetCommand::class => ['translator'],
|
||||
Command\Config\GenerateSecretCommand::class => ['translator'],
|
||||
|
||||
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, 'translator'],
|
||||
Command\Api\DisableKeyCommand::class => [ApiKeyService::class, 'translator'],
|
||||
Command\Api\ListKeysCommand::class => [ApiKeyService::class, 'translator'],
|
||||
|
||||
Command\Tag\ListTagsCommand::class => [Service\Tag\TagService::class, Translator::class],
|
||||
Command\Tag\CreateTagCommand::class => [Service\Tag\TagService::class, Translator::class],
|
||||
Command\Tag\RenameTagCommand::class => [Service\Tag\TagService::class, Translator::class],
|
||||
|
||||
Binary file not shown.
@@ -1,15 +1,15 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Shlink 1.0\n"
|
||||
"POT-Creation-Date: 2018-01-21 09:36+0100\n"
|
||||
"PO-Revision-Date: 2018-01-21 09:39+0100\n"
|
||||
"POT-Creation-Date: 2018-09-15 17:57+0200\n"
|
||||
"PO-Revision-Date: 2018-09-15 18:02+0200\n"
|
||||
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: es_ES\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: Poedit 2.0.4\n"
|
||||
"X-Generator: Poedit 2.0.6\n"
|
||||
"X-Poedit-Basepath: ..\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Poedit-SourceCharset: UTF-8\n"
|
||||
@@ -80,6 +80,41 @@ msgstr ""
|
||||
msgid "Secret key: \"%s\""
|
||||
msgstr "Clave secreta: \"%s\""
|
||||
|
||||
msgid "Deletes a short URL"
|
||||
msgstr "Elimina una URL"
|
||||
|
||||
msgid "The short code to be deleted"
|
||||
msgstr "El código corto a eliminar"
|
||||
|
||||
msgid ""
|
||||
"Ignores the safety visits threshold check, which could make short URLs with "
|
||||
"many visits to be accidentally deleted"
|
||||
msgstr ""
|
||||
"Ignora el límite de seguridad de visitas, pudiendo resultar en el borrado "
|
||||
"accidental de URLs con muchas visitas"
|
||||
|
||||
#, php-format
|
||||
msgid "Provided short code \"%s\" could not be found."
|
||||
msgstr "El código corto proporcionado \"%s\" no ha podido ser encontrado."
|
||||
|
||||
#, php-format
|
||||
msgid ""
|
||||
"It was not possible to delete the short URL with short code \"%s\" because "
|
||||
"it has more than %s visits."
|
||||
msgstr ""
|
||||
"No se pudo eliminar la URL acortada con código corto \"%s\" porque tiene más "
|
||||
"de %s visitas."
|
||||
|
||||
msgid "Do you want to delete it anyway?"
|
||||
msgstr "¿Aún así quieres eliminarla?"
|
||||
|
||||
msgid "Short URL was not deleted."
|
||||
msgstr "La URL corta no ha sido eliminada."
|
||||
|
||||
#, php-format
|
||||
msgid "Short URL with short code \"%s\" successfully deleted."
|
||||
msgstr "La URL acortada con el código corto \"%s\" eliminada correctamente."
|
||||
|
||||
msgid ""
|
||||
"Processes and generates the previews for every URL, improving performance "
|
||||
"for later web requests."
|
||||
@@ -183,12 +218,12 @@ msgstr "Origen"
|
||||
msgid "Date"
|
||||
msgstr "Fecha"
|
||||
|
||||
msgid "Remote Address"
|
||||
msgstr "Dirección remota"
|
||||
|
||||
msgid "User agent"
|
||||
msgstr "Agente de usuario"
|
||||
|
||||
msgid "Country"
|
||||
msgstr "País"
|
||||
|
||||
msgid "List all short URLs"
|
||||
msgstr "Listar todas las URLs cortas"
|
||||
|
||||
@@ -218,8 +253,11 @@ msgstr "Si se desea mostrar las etiquetas o no"
|
||||
msgid "Short code"
|
||||
msgstr "Código corto"
|
||||
|
||||
msgid "Original URL"
|
||||
msgstr "URL original"
|
||||
msgid "Short URL"
|
||||
msgstr "URL corta"
|
||||
|
||||
msgid "Long URL"
|
||||
msgstr "URL larga"
|
||||
|
||||
msgid "Date created"
|
||||
msgstr "Fecha de creación"
|
||||
@@ -253,10 +291,6 @@ msgstr "URL larga:"
|
||||
msgid "Provided short code \"%s\" has an invalid format."
|
||||
msgstr "El código corto proporcionado \"%s\" tiene un formato inválido."
|
||||
|
||||
#, php-format
|
||||
msgid "Provided short code \"%s\" could not be found."
|
||||
msgstr "El código corto proporcionado \"%s\" no ha podido ser encontrado."
|
||||
|
||||
msgid "Creates one or more tags."
|
||||
msgstr "Crea una o más etiquetas."
|
||||
|
||||
@@ -317,9 +351,22 @@ msgstr "Ignorada IP de localhost"
|
||||
msgid "Address located at \"%s\""
|
||||
msgstr "Dirección localizada en \"%s\""
|
||||
|
||||
msgid "An error occurred while locating IP"
|
||||
msgstr "Se produjo un error al localizar la IP"
|
||||
|
||||
#, php-format
|
||||
msgid "IP location resolver limit reached. Waiting %s seconds..."
|
||||
msgstr "Limite del localizador de IPs alcanzado. Esperando %s segundos..."
|
||||
|
||||
msgid "Finished processing all IPs"
|
||||
msgstr "Finalizado el procesado de todas las IPs"
|
||||
|
||||
#~ msgid "Remote Address"
|
||||
#~ msgstr "Dirección remota"
|
||||
|
||||
#~ msgid "Original URL"
|
||||
#~ msgstr "URL original"
|
||||
|
||||
#~ msgid "You have reached last page"
|
||||
#~ msgstr "Has alcanzado la última página"
|
||||
|
||||
|
||||
@@ -14,7 +14,11 @@ use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class ListKeysCommand extends Command
|
||||
{
|
||||
const NAME = 'api-key:list';
|
||||
private const ERROR_STRING_PATTERN = '<fg=red>%s</>';
|
||||
private const SUCCESS_STRING_PATTERN = '<info>%s</info>';
|
||||
private const WARNING_STRING_PATTERN = '<comment>%s</comment>';
|
||||
|
||||
public const NAME = 'api-key:list';
|
||||
|
||||
/**
|
||||
* @var ApiKeyServiceInterface
|
||||
@@ -55,59 +59,32 @@ class ListKeysCommand extends Command
|
||||
foreach ($list as $row) {
|
||||
$key = $row->getKey();
|
||||
$expiration = $row->getExpirationDate();
|
||||
$formatMethod = $this->determineFormatMethod($row);
|
||||
$messagePattern = $this->determineMessagePattern($row);
|
||||
|
||||
// Set columns for this row
|
||||
$rowData = [$formatMethod($key)];
|
||||
$rowData = [\sprintf($messagePattern, $key)];
|
||||
if (! $enabledOnly) {
|
||||
$rowData[] = $formatMethod($this->getEnabledSymbol($row));
|
||||
$rowData[] = \sprintf($messagePattern, $this->getEnabledSymbol($row));
|
||||
}
|
||||
$rowData[] = $expiration !== null ? $expiration->format(\DateTime::ATOM) : '-';
|
||||
|
||||
$rows[] = $rowData;
|
||||
}
|
||||
|
||||
$io->table(array_filter([
|
||||
$io->table(\array_filter([
|
||||
$this->translator->translate('Key'),
|
||||
! $enabledOnly ? $this->translator->translate('Is enabled') : null,
|
||||
$this->translator->translate('Expiration date'),
|
||||
]), $rows);
|
||||
}
|
||||
|
||||
private function determineFormatMethod(ApiKey $apiKey): callable
|
||||
private function determineMessagePattern(ApiKey $apiKey): string
|
||||
{
|
||||
if (! $apiKey->isEnabled()) {
|
||||
return [$this, 'getErrorString'];
|
||||
return self::ERROR_STRING_PATTERN;
|
||||
}
|
||||
|
||||
return $apiKey->isExpired() ? [$this, 'getWarningString'] : [$this, 'getSuccessString'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $value
|
||||
* @return string
|
||||
*/
|
||||
private function getErrorString(string $value): string
|
||||
{
|
||||
return sprintf('<fg=red>%s</>', $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $value
|
||||
* @return string
|
||||
*/
|
||||
private function getSuccessString(string $value): string
|
||||
{
|
||||
return sprintf('<info>%s</info>', $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $value
|
||||
* @return string
|
||||
*/
|
||||
private function getWarningString(string $value): string
|
||||
{
|
||||
return sprintf('<comment>%s</comment>', $value);
|
||||
return $apiKey->isExpired() ? self::WARNING_STRING_PATTERN : self::SUCCESS_STRING_PATTERN;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Filesystem\Exception\IOException;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Zend\Config\Writer\WriterInterface;
|
||||
|
||||
class InstallCommand extends Command
|
||||
@@ -48,6 +49,14 @@ class InstallCommand extends Command
|
||||
* @var bool
|
||||
*/
|
||||
private $isUpdate;
|
||||
/**
|
||||
* @var PhpExecutableFinder
|
||||
*/
|
||||
private $phpFinder;
|
||||
/**
|
||||
* @var string|bool
|
||||
*/
|
||||
private $phpBinary;
|
||||
|
||||
/**
|
||||
* InstallCommand constructor.
|
||||
@@ -55,22 +64,25 @@ class InstallCommand extends Command
|
||||
* @param Filesystem $filesystem
|
||||
* @param ConfigCustomizerManagerInterface $configCustomizers
|
||||
* @param bool $isUpdate
|
||||
* @param PhpExecutableFinder|null $phpFinder
|
||||
* @throws LogicException
|
||||
*/
|
||||
public function __construct(
|
||||
WriterInterface $configWriter,
|
||||
Filesystem $filesystem,
|
||||
ConfigCustomizerManagerInterface $configCustomizers,
|
||||
$isUpdate = false
|
||||
bool $isUpdate = false,
|
||||
PhpExecutableFinder $phpFinder = null
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->configWriter = $configWriter;
|
||||
$this->isUpdate = $isUpdate;
|
||||
$this->filesystem = $filesystem;
|
||||
$this->configCustomizers = $configCustomizers;
|
||||
$this->phpFinder = $phpFinder ?: new PhpExecutableFinder();
|
||||
}
|
||||
|
||||
public function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('shlink:install')
|
||||
@@ -84,7 +96,7 @@ class InstallCommand extends Command
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
*/
|
||||
public function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$this->io = new SymfonyStyle($input, $output);
|
||||
|
||||
@@ -133,8 +145,8 @@ class InstallCommand extends Command
|
||||
// If current command is not update, generate database
|
||||
if (! $this->isUpdate) {
|
||||
$this->io->write('Initializing database...');
|
||||
if (! $this->runCommand(
|
||||
'php vendor/bin/doctrine.php orm:schema-tool:create',
|
||||
if (! $this->runPhpCommand(
|
||||
'vendor/doctrine/orm/bin/doctrine.php orm:schema-tool:create',
|
||||
'Error generating database.',
|
||||
$output
|
||||
)) {
|
||||
@@ -144,8 +156,8 @@ class InstallCommand extends Command
|
||||
|
||||
// Run database migrations
|
||||
$this->io->write('Updating database...');
|
||||
if (! $this->runCommand(
|
||||
'php vendor/bin/doctrine-migrations migrations:migrate',
|
||||
if (! $this->runPhpCommand(
|
||||
'vendor/doctrine/migrations/bin/doctrine-migrations.php migrations:migrate',
|
||||
'Error updating database.',
|
||||
$output
|
||||
)) {
|
||||
@@ -154,8 +166,8 @@ class InstallCommand extends Command
|
||||
|
||||
// Generate proxies
|
||||
$this->io->write('Generating proxies...');
|
||||
if (! $this->runCommand(
|
||||
'php vendor/bin/doctrine.php orm:generate-proxies',
|
||||
if (! $this->runPhpCommand(
|
||||
'vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies',
|
||||
'Error generating proxies.',
|
||||
$output
|
||||
)) {
|
||||
@@ -213,23 +225,31 @@ class InstallCommand extends Command
|
||||
* @throws LogicException
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function runCommand($command, $errorMessage, OutputInterface $output): bool
|
||||
private function runPhpCommand($command, $errorMessage, OutputInterface $output): bool
|
||||
{
|
||||
if ($this->processHelper === null) {
|
||||
$this->processHelper = $this->getHelper('process');
|
||||
}
|
||||
|
||||
$process = $this->processHelper->run($output, $command);
|
||||
if ($this->phpBinary === null) {
|
||||
$this->phpBinary = $this->phpFinder->find(false) ?: 'php';
|
||||
}
|
||||
|
||||
$this->io->write(
|
||||
' <options=bold>[Running "' . sprintf('%s %s', $this->phpBinary, $command) . '"]</> ',
|
||||
false,
|
||||
OutputInterface::VERBOSITY_VERBOSE
|
||||
);
|
||||
$process = $this->processHelper->run($output, sprintf('%s %s', $this->phpBinary, $command));
|
||||
if ($process->isSuccessful()) {
|
||||
$this->io->writeln(' <info>Success!</info>');
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->io->isVerbose()) {
|
||||
return false;
|
||||
if (! $this->io->isVerbose()) {
|
||||
$this->io->error($errorMessage . ' Run this command with -vvv to see specific error info.');
|
||||
}
|
||||
|
||||
$this->io->error($errorMessage . ' Run this command with -vvv to see specific error info.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
101
module/CLI/src/Command/Shortcode/DeleteShortCodeCommand.php
Normal file
101
module/CLI/src/Command/Shortcode/DeleteShortCodeCommand.php
Normal 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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,8 @@ use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class GeneratePreviewCommand extends Command
|
||||
{
|
||||
const NAME = 'shortcode:process-previews';
|
||||
public const NAME = 'short-code:process-previews';
|
||||
private const ALIASES = ['shortcode:process-previews'];
|
||||
|
||||
/**
|
||||
* @var PreviewGeneratorInterface
|
||||
@@ -40,17 +41,19 @@ class GeneratePreviewCommand extends Command
|
||||
parent::__construct(null);
|
||||
}
|
||||
|
||||
public function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::NAME)
|
||||
->setDescription(
|
||||
$this->translator->translate(
|
||||
'Processes and generates the previews for every URL, improving performance for later web requests.'
|
||||
)
|
||||
);
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setAliases(self::ALIASES)
|
||||
->setDescription(
|
||||
$this->translator->translate(
|
||||
'Processes and generates the previews for every URL, improving performance for later web requests.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$page = 1;
|
||||
do {
|
||||
@@ -65,7 +68,7 @@ class GeneratePreviewCommand extends Command
|
||||
(new SymfonyStyle($input, $output))->success($this->translator->translate('Finished processing all URLs'));
|
||||
}
|
||||
|
||||
protected function processUrl($url, OutputInterface $output)
|
||||
private function processUrl($url, OutputInterface $output): void
|
||||
{
|
||||
try {
|
||||
$output->write(\sprintf($this->translator->translate('Processing URL %s...'), $url));
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Shortcode;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||
use Shlinkio\Shlink\Core\Util\ShortUrlBuilderTrait;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -17,7 +18,10 @@ use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class GenerateShortcodeCommand extends Command
|
||||
{
|
||||
const NAME = 'shortcode:generate';
|
||||
use ShortUrlBuilderTrait;
|
||||
|
||||
public const NAME = 'short-code:generate';
|
||||
private const ALIASES = ['shortcode:generate'];
|
||||
|
||||
/**
|
||||
* @var UrlShortenerInterface
|
||||
@@ -43,36 +47,38 @@ class GenerateShortcodeCommand extends Command
|
||||
parent::__construct(null);
|
||||
}
|
||||
|
||||
public function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::NAME)
|
||||
->setDescription(
|
||||
$this->translator->translate('Generates a short code for provided URL and returns the short URL')
|
||||
)
|
||||
->addArgument('longUrl', InputArgument::REQUIRED, $this->translator->translate('The long URL to parse'))
|
||||
->addOption(
|
||||
'tags',
|
||||
't',
|
||||
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
|
||||
$this->translator->translate('Tags to apply to the new short URL')
|
||||
)
|
||||
->addOption('validSince', 's', InputOption::VALUE_REQUIRED, $this->translator->translate(
|
||||
'The date from which this short URL will be valid. '
|
||||
. 'If someone tries to access it before this date, it will not be found.'
|
||||
))
|
||||
->addOption('validUntil', 'u', InputOption::VALUE_REQUIRED, $this->translator->translate(
|
||||
'The date until which this short URL will be valid. '
|
||||
. 'If someone tries to access it after this date, it will not be found.'
|
||||
))
|
||||
->addOption('customSlug', 'c', InputOption::VALUE_REQUIRED, $this->translator->translate(
|
||||
'If provided, this slug will be used instead of generating a short code'
|
||||
))
|
||||
->addOption('maxVisits', 'm', InputOption::VALUE_REQUIRED, $this->translator->translate(
|
||||
'This will limit the number of visits for this short URL.'
|
||||
));
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setAliases(self::ALIASES)
|
||||
->setDescription(
|
||||
$this->translator->translate('Generates a short code for provided URL and returns the short URL')
|
||||
)
|
||||
->addArgument('longUrl', InputArgument::REQUIRED, $this->translator->translate('The long URL to parse'))
|
||||
->addOption(
|
||||
'tags',
|
||||
't',
|
||||
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
|
||||
$this->translator->translate('Tags to apply to the new short URL')
|
||||
)
|
||||
->addOption('validSince', 's', InputOption::VALUE_REQUIRED, $this->translator->translate(
|
||||
'The date from which this short URL will be valid. '
|
||||
. 'If someone tries to access it before this date, it will not be found.'
|
||||
))
|
||||
->addOption('validUntil', 'u', InputOption::VALUE_REQUIRED, $this->translator->translate(
|
||||
'The date until which this short URL will be valid. '
|
||||
. 'If someone tries to access it after this date, it will not be found.'
|
||||
))
|
||||
->addOption('customSlug', 'c', InputOption::VALUE_REQUIRED, $this->translator->translate(
|
||||
'If provided, this slug will be used instead of generating a short code'
|
||||
))
|
||||
->addOption('maxVisits', 'm', InputOption::VALUE_REQUIRED, $this->translator->translate(
|
||||
'This will limit the number of visits for this short URL.'
|
||||
));
|
||||
}
|
||||
|
||||
public function interact(InputInterface $input, OutputInterface $output)
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$longUrl = $input->getArgument('longUrl');
|
||||
@@ -88,7 +94,7 @@ class GenerateShortcodeCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$longUrl = $input->getArgument('longUrl');
|
||||
@@ -115,10 +121,8 @@ class GenerateShortcodeCommand extends Command
|
||||
$this->getOptionalDate($input, 'validUntil'),
|
||||
$customSlug,
|
||||
$maxVisits !== null ? (int) $maxVisits : null
|
||||
);
|
||||
$shortUrl = (new Uri())->withPath($shortCode)
|
||||
->withScheme($this->domainConfig['schema'])
|
||||
->withHost($this->domainConfig['hostname']);
|
||||
)->getShortCode();
|
||||
$shortUrl = $this->buildShortUrl($this->domainConfig, $shortCode);
|
||||
|
||||
$io->writeln([
|
||||
\sprintf('%s <info>%s</info>', $this->translator->translate('Processed long URL:'), $longUrl),
|
||||
@@ -139,7 +143,7 @@ class GenerateShortcodeCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
private function getOptionalDate(InputInterface $input, string $fieldName)
|
||||
private function getOptionalDate(InputInterface $input, string $fieldName): ?\DateTime
|
||||
{
|
||||
$since = $input->getOption($fieldName);
|
||||
return $since !== null ? new \DateTime($since) : null;
|
||||
|
||||
@@ -15,7 +15,8 @@ use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class GetVisitsCommand extends Command
|
||||
{
|
||||
const NAME = 'shortcode:visits';
|
||||
public const NAME = 'short-code:visits';
|
||||
private const ALIASES = ['shortcode:visits'];
|
||||
|
||||
/**
|
||||
* @var VisitsTrackerInterface
|
||||
@@ -33,9 +34,11 @@ class GetVisitsCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::NAME)
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setAliases(self::ALIASES)
|
||||
->setDescription(
|
||||
$this->translator->translate('Returns the detailed visits information for provided short code')
|
||||
)
|
||||
@@ -58,7 +61,7 @@ class GetVisitsCommand extends Command
|
||||
);
|
||||
}
|
||||
|
||||
public function interact(InputInterface $input, OutputInterface $output)
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$shortCode = $input->getArgument('shortCode');
|
||||
if (! empty($shortCode)) {
|
||||
@@ -74,7 +77,7 @@ class GetVisitsCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$shortCode = $input->getArgument('shortCode');
|
||||
@@ -85,20 +88,23 @@ class GetVisitsCommand extends Command
|
||||
$rows = [];
|
||||
foreach ($visits as $row) {
|
||||
$rowData = $row->jsonSerialize();
|
||||
// Unset location info
|
||||
unset($rowData['visitLocation']);
|
||||
|
||||
// Unset location info and remote addr
|
||||
unset($rowData['visitLocation'], $rowData['remoteAddr']);
|
||||
|
||||
$rowData['country'] = $row->getVisitLocation()->getCountryName();
|
||||
|
||||
$rows[] = \array_values($rowData);
|
||||
}
|
||||
$io->table([
|
||||
$this->translator->translate('Referer'),
|
||||
$this->translator->translate('Date'),
|
||||
$this->translator->translate('Remote Address'),
|
||||
$this->translator->translate('User agent'),
|
||||
$this->translator->translate('Country'),
|
||||
], $rows);
|
||||
}
|
||||
|
||||
protected function getDateOption(InputInterface $input, $key)
|
||||
private function getDateOption(InputInterface $input, $key)
|
||||
{
|
||||
$value = $input->getOption($key);
|
||||
if (! empty($value)) {
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Shortcode;
|
||||
use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
@@ -17,7 +18,8 @@ class ListShortcodesCommand extends Command
|
||||
{
|
||||
use PaginatorUtilsTrait;
|
||||
|
||||
const NAME = 'shortcode:list';
|
||||
public const NAME = 'short-code:list';
|
||||
private const ALIASES = ['shortcode:list'];
|
||||
|
||||
/**
|
||||
* @var ShortUrlServiceInterface
|
||||
@@ -27,59 +29,69 @@ class ListShortcodesCommand extends Command
|
||||
* @var TranslatorInterface
|
||||
*/
|
||||
private $translator;
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private $domainConfig;
|
||||
|
||||
public function __construct(ShortUrlServiceInterface $shortUrlService, TranslatorInterface $translator)
|
||||
{
|
||||
public function __construct(
|
||||
ShortUrlServiceInterface $shortUrlService,
|
||||
TranslatorInterface $translator,
|
||||
array $domainConfig
|
||||
) {
|
||||
$this->shortUrlService = $shortUrlService;
|
||||
$this->translator = $translator;
|
||||
parent::__construct();
|
||||
$this->domainConfig = $domainConfig;
|
||||
}
|
||||
|
||||
public function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::NAME)
|
||||
->setDescription($this->translator->translate('List all short URLs'))
|
||||
->addOption(
|
||||
'page',
|
||||
'p',
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
sprintf(
|
||||
$this->translator->translate('The first page to list (%s items per page)'),
|
||||
PaginableRepositoryAdapter::ITEMS_PER_PAGE
|
||||
),
|
||||
1
|
||||
)
|
||||
->addOption(
|
||||
'searchTerm',
|
||||
's',
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
$this->translator->translate(
|
||||
'A query used to filter results by searching for it on the longUrl and shortCode fields'
|
||||
)
|
||||
)
|
||||
->addOption(
|
||||
'tags',
|
||||
't',
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
$this->translator->translate('A comma-separated list of tags to filter results')
|
||||
)
|
||||
->addOption(
|
||||
'orderBy',
|
||||
'o',
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
$this->translator->translate(
|
||||
'The field from which we want to order by. Pass ASC or DESC separated by a comma'
|
||||
)
|
||||
)
|
||||
->addOption(
|
||||
'showTags',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
$this->translator->translate('Whether to display the tags or not')
|
||||
);
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setAliases(self::ALIASES)
|
||||
->setDescription($this->translator->translate('List all short URLs'))
|
||||
->addOption(
|
||||
'page',
|
||||
'p',
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
sprintf(
|
||||
$this->translator->translate('The first page to list (%s items per page)'),
|
||||
PaginableRepositoryAdapter::ITEMS_PER_PAGE
|
||||
),
|
||||
1
|
||||
)
|
||||
->addOption(
|
||||
'searchTerm',
|
||||
's',
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
$this->translator->translate(
|
||||
'A query used to filter results by searching for it on the longUrl and shortCode fields'
|
||||
)
|
||||
)
|
||||
->addOption(
|
||||
'tags',
|
||||
't',
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
$this->translator->translate('A comma-separated list of tags to filter results')
|
||||
)
|
||||
->addOption(
|
||||
'orderBy',
|
||||
'o',
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
$this->translator->translate(
|
||||
'The field from which we want to order by. Pass ASC or DESC separated by a comma'
|
||||
)
|
||||
)
|
||||
->addOption(
|
||||
'showTags',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
$this->translator->translate('Whether to display the tags or not')
|
||||
);
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$page = (int) $input->getOption('page');
|
||||
@@ -87,6 +99,7 @@ class ListShortcodesCommand extends Command
|
||||
$tags = $input->getOption('tags');
|
||||
$tags = ! empty($tags) ? \explode(',', $tags) : [];
|
||||
$showTags = $input->getOption('showTags');
|
||||
$transformer = new ShortUrlDataTransformer($this->domainConfig);
|
||||
|
||||
do {
|
||||
$result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input));
|
||||
@@ -94,7 +107,8 @@ class ListShortcodesCommand extends Command
|
||||
|
||||
$headers = [
|
||||
$this->translator->translate('Short code'),
|
||||
$this->translator->translate('Original URL'),
|
||||
$this->translator->translate('Short URL'),
|
||||
$this->translator->translate('Long URL'),
|
||||
$this->translator->translate('Date created'),
|
||||
$this->translator->translate('Visits count'),
|
||||
];
|
||||
@@ -104,17 +118,14 @@ class ListShortcodesCommand extends Command
|
||||
|
||||
$rows = [];
|
||||
foreach ($result as $row) {
|
||||
$shortUrl = $row->jsonSerialize();
|
||||
$shortUrl = $transformer->transform($row);
|
||||
if ($showTags) {
|
||||
$shortUrl['tags'] = [];
|
||||
foreach ($row->getTags() as $tag) {
|
||||
$shortUrl['tags'][] = $tag->getName();
|
||||
}
|
||||
$shortUrl['tags'] = implode(', ', $shortUrl['tags']);
|
||||
$shortUrl['tags'] = \implode(', ', $shortUrl['tags']);
|
||||
} else {
|
||||
unset($shortUrl['tags']);
|
||||
}
|
||||
|
||||
unset($shortUrl['originalUrl']);
|
||||
$rows[] = \array_values($shortUrl);
|
||||
}
|
||||
$io->table($headers, $rows);
|
||||
@@ -138,7 +149,7 @@ class ListShortcodesCommand extends Command
|
||||
return null;
|
||||
}
|
||||
|
||||
$orderBy = explode(',', $orderBy);
|
||||
return count($orderBy) === 1 ? $orderBy[0] : [$orderBy[0] => $orderBy[1]];
|
||||
$orderBy = \explode(',', $orderBy);
|
||||
return \count($orderBy) === 1 ? $orderBy[0] : [$orderBy[0] => $orderBy[1]];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class ResolveUrlCommand extends Command
|
||||
{
|
||||
const NAME = 'shortcode:parse';
|
||||
public const NAME = 'short-code:parse';
|
||||
private const ALIASES = ['shortcode:parse'];
|
||||
|
||||
/**
|
||||
* @var UrlShortenerInterface
|
||||
@@ -33,18 +34,20 @@ class ResolveUrlCommand extends Command
|
||||
parent::__construct(null);
|
||||
}
|
||||
|
||||
public function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::NAME)
|
||||
->setDescription($this->translator->translate('Returns the long URL behind a short code'))
|
||||
->addArgument(
|
||||
'shortCode',
|
||||
InputArgument::REQUIRED,
|
||||
$this->translator->translate('The short code to parse')
|
||||
);
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setAliases(self::ALIASES)
|
||||
->setDescription($this->translator->translate('Returns the long URL behind a short code'))
|
||||
->addArgument(
|
||||
'shortCode',
|
||||
InputArgument::REQUIRED,
|
||||
$this->translator->translate('The short code to parse')
|
||||
);
|
||||
}
|
||||
|
||||
public function interact(InputInterface $input, OutputInterface $output)
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$shortCode = $input->getArgument('shortCode');
|
||||
if (! empty($shortCode)) {
|
||||
@@ -60,14 +63,16 @@ class ResolveUrlCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$shortCode = $input->getArgument('shortCode');
|
||||
|
||||
try {
|
||||
$longUrl = $this->urlShortener->shortCodeToUrl($shortCode);
|
||||
$output->writeln(\sprintf('%s <info>%s</info>', $this->translator->translate('Long URL:'), $longUrl));
|
||||
$url = $this->urlShortener->shortCodeToUrl($shortCode);
|
||||
$output->writeln(
|
||||
\sprintf('%s <info>%s</info>', $this->translator->translate('Long URL:'), $url->getLongUrl())
|
||||
);
|
||||
} catch (InvalidShortCodeException $e) {
|
||||
$io->error(
|
||||
\sprintf($this->translator->translate('Provided short code "%s" has an invalid format.'), $shortCode)
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Shlinkio\Shlink\Common\Exception\WrongIpException;
|
||||
use Shlinkio\Shlink\Common\Service\IpLocationResolverInterface;
|
||||
use Shlinkio\Shlink\Common\Util\IpAddress;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Service\VisitServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
@@ -15,8 +16,7 @@ use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class ProcessVisitsCommand extends Command
|
||||
{
|
||||
const LOCALHOST = '127.0.0.1';
|
||||
const NAME = 'visit:process';
|
||||
public const NAME = 'visit:process';
|
||||
|
||||
/**
|
||||
* @var VisitServiceInterface
|
||||
@@ -55,16 +55,18 @@ class ProcessVisitsCommand extends Command
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$visits = $this->visitService->getUnlocatedVisits();
|
||||
|
||||
$count = 0;
|
||||
foreach ($visits as $visit) {
|
||||
$ipAddr = $visit->getRemoteAddr();
|
||||
$io->write(sprintf('%s <info>%s</info>', $this->translator->translate('Processing IP'), $ipAddr));
|
||||
if ($ipAddr === self::LOCALHOST) {
|
||||
$io->write(\sprintf('%s <info>%s</info>', $this->translator->translate('Processing IP'), $ipAddr));
|
||||
if ($ipAddr === IpAddress::LOCALHOST) {
|
||||
$io->writeln(
|
||||
sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address'))
|
||||
\sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address'))
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$count++;
|
||||
try {
|
||||
$result = $this->ipLocationResolver->resolveIpLocation($ipAddr);
|
||||
|
||||
@@ -73,12 +75,27 @@ class ProcessVisitsCommand extends Command
|
||||
$visit->setVisitLocation($location);
|
||||
$this->visitService->saveVisit($visit);
|
||||
|
||||
$io->writeln(sprintf(
|
||||
$io->writeln(\sprintf(
|
||||
' (' . $this->translator->translate('Address located at "%s"') . ')',
|
||||
$location->getCityName()
|
||||
));
|
||||
} catch (WrongIpException $e) {
|
||||
continue;
|
||||
$io->writeln(
|
||||
\sprintf(' <error>%s</error>', $this->translator->translate('An error occurred while locating IP'))
|
||||
);
|
||||
if ($io->isVerbose()) {
|
||||
$this->getApplication()->renderException($e, $output);
|
||||
}
|
||||
}
|
||||
|
||||
if ($count === $this->ipLocationResolver->getApiLimit()) {
|
||||
$count = 0;
|
||||
$seconds = $this->ipLocationResolver->getApiInterval();
|
||||
$io->note(\sprintf(
|
||||
$this->translator->translate('IP location resolver limit reached. Waiting %s seconds...'),
|
||||
$seconds
|
||||
));
|
||||
\sleep($seconds);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
@@ -37,7 +38,8 @@ class GenerateKeyCommandTest extends TestCase
|
||||
*/
|
||||
public function noExpirationDateIsDefinedIfNotProvided()
|
||||
{
|
||||
$this->apiKeyService->create(null)->shouldBeCalledTimes(1);
|
||||
$this->apiKeyService->create(null)->shouldBeCalledTimes(1)
|
||||
->willReturn(new ApiKey());
|
||||
$this->commandTester->execute([
|
||||
'command' => 'api-key:generate',
|
||||
]);
|
||||
@@ -46,9 +48,10 @@ class GenerateKeyCommandTest extends TestCase
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function expirationDateIsDefinedIfWhenProvided()
|
||||
public function expirationDateIsDefinedIfProvided()
|
||||
{
|
||||
$this->apiKeyService->create(Argument::type(\DateTime::class))->shouldBeCalledTimes(1);
|
||||
$this->apiKeyService->create(Argument::type(\DateTime::class))->shouldBeCalledTimes(1)
|
||||
->willReturn(new ApiKey());
|
||||
$this->commandTester->execute([
|
||||
'command' => 'api-key:generate',
|
||||
'--expirationDate' => '2016-01-01',
|
||||
|
||||
@@ -15,6 +15,7 @@ use Symfony\Component\Console\Helper\ProcessHelper;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Symfony\Component\Filesystem\Exception\IOException;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Zend\Config\Writer\WriterInterface;
|
||||
|
||||
@@ -55,6 +56,9 @@ class InstallCommandTest extends TestCase
|
||||
$configCustomizers = $this->prophesize(ConfigCustomizerManagerInterface::class);
|
||||
$configCustomizers->get(Argument::cetera())->willReturn($configCustomizer->reveal());
|
||||
|
||||
$finder = $this->prophesize(PhpExecutableFinder::class);
|
||||
$finder->find(false)->willReturn('php');
|
||||
|
||||
$app = new Application();
|
||||
$helperSet = $app->getHelperSet();
|
||||
$helperSet->set($processHelper->reveal());
|
||||
@@ -62,7 +66,9 @@ class InstallCommandTest extends TestCase
|
||||
$this->command = new InstallCommand(
|
||||
$this->configWriter->reveal(),
|
||||
$this->filesystem->reveal(),
|
||||
$configCustomizers->reveal()
|
||||
$configCustomizers->reveal(),
|
||||
false,
|
||||
$finder->reveal()
|
||||
);
|
||||
$app->add($this->command);
|
||||
|
||||
|
||||
120
module/CLI/test/Command/Shortcode/DeleteShortCodeCommandTest.php
Normal file
120
module/CLI/test/Command/Shortcode/DeleteShortCodeCommandTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Shortcode\GenerateShortcodeCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortener;
|
||||
use Symfony\Component\Console\Application;
|
||||
@@ -41,8 +42,12 @@ class GenerateShortcodeCommandTest extends TestCase
|
||||
*/
|
||||
public function properShortCodeIsCreatedIfLongUrlIsCorrect()
|
||||
{
|
||||
$this->urlShortener->urlToShortCode(Argument::cetera())->willReturn('abc123')
|
||||
->shouldBeCalledTimes(1);
|
||||
$this->urlShortener->urlToShortCode(Argument::cetera())
|
||||
->willReturn(
|
||||
(new ShortUrl())->setShortCode('abc123')
|
||||
->setLongUrl('')
|
||||
)
|
||||
->shouldBeCalledTimes(1);
|
||||
|
||||
$this->commandTester->execute([
|
||||
'command' => 'shortcode:generate',
|
||||
|
||||
@@ -9,6 +9,7 @@ use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Shortcode\GetVisitsCommand;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
@@ -77,7 +78,7 @@ class GetVisitsCommandTest extends TestCase
|
||||
$shortCode = 'abc123';
|
||||
$this->visitsTracker->info($shortCode, Argument::any())->willReturn([
|
||||
(new Visit())->setReferer('foo')
|
||||
->setRemoteAddr('1.2.3.4')
|
||||
->setVisitLocation((new VisitLocation())->setCountryName('Spain'))
|
||||
->setUserAgent('bar'),
|
||||
])->shouldBeCalledTimes(1);
|
||||
|
||||
@@ -86,8 +87,8 @@ class GetVisitsCommandTest extends TestCase
|
||||
'shortCode' => $shortCode,
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertTrue(strpos($output, 'foo') > 0);
|
||||
$this->assertTrue(strpos($output, '1.2.3.4') > 0);
|
||||
$this->assertTrue(strpos($output, 'bar') > 0);
|
||||
$this->assertGreaterThan(0, \strpos($output, 'foo'));
|
||||
$this->assertGreaterThan(0, \strpos($output, 'Spain'));
|
||||
$this->assertGreaterThan(0, \strpos($output, 'bar'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class ListShortcodesCommandTest extends TestCase
|
||||
{
|
||||
$this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class);
|
||||
$app = new Application();
|
||||
$command = new ListShortcodesCommand($this->shortUrlService->reveal(), Translator::factory([]));
|
||||
$command = new ListShortcodesCommand($this->shortUrlService->reveal(), Translator::factory([]), []);
|
||||
$app->add($command);
|
||||
$this->commandTester = new CommandTester($command);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ class ListShortcodesCommandTest extends TestCase
|
||||
// The paginator will return more than one page for the first 3 times
|
||||
$data = [];
|
||||
for ($i = 0; $i < 50; $i++) {
|
||||
$data[] = new ShortUrl();
|
||||
$data[] = (new ShortUrl())->setLongUrl('url_' . $i);
|
||||
}
|
||||
|
||||
$this->shortUrlService->listShortUrls(Argument::cetera())->will(function () use (&$data) {
|
||||
@@ -74,7 +74,7 @@ class ListShortcodesCommandTest extends TestCase
|
||||
// The paginator will return more than one page
|
||||
$data = [];
|
||||
for ($i = 0; $i < 30; $i++) {
|
||||
$data[] = new ShortUrl();
|
||||
$data[] = (new ShortUrl())->setLongUrl('url_' . $i);
|
||||
}
|
||||
|
||||
$this->shortUrlService->listShortUrls(Argument::cetera())->willReturn(new Paginator(new ArrayAdapter($data)))
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Shortcode\ResolveUrlCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortener;
|
||||
@@ -41,7 +42,8 @@ class ResolveUrlCommandTest extends TestCase
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$expectedUrl = 'http://domain.com/foo/bar';
|
||||
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($expectedUrl)
|
||||
$shortUrl = (new ShortUrl())->setLongUrl($expectedUrl);
|
||||
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($shortUrl)
|
||||
->shouldBeCalledTimes(1);
|
||||
|
||||
$this->commandTester->execute([
|
||||
|
||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\MethodProphecy;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
@@ -52,7 +53,7 @@ class CreateTagCommandTest extends TestCase
|
||||
{
|
||||
$tagNames = ['foo', 'bar'];
|
||||
/** @var MethodProphecy $createTags */
|
||||
$createTags = $this->tagService->createTags($tagNames)->willReturn([]);
|
||||
$createTags = $this->tagService->createTags($tagNames)->willReturn(new ArrayCollection());
|
||||
|
||||
$this->commandTester->execute([
|
||||
'--name' => $tagNames,
|
||||
|
||||
@@ -7,7 +7,7 @@ use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\ProcessVisitsCommand;
|
||||
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
|
||||
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Service\VisitService;
|
||||
use Symfony\Component\Console\Application;
|
||||
@@ -32,7 +32,9 @@ class ProcessVisitsCommandTest extends TestCase
|
||||
public function setUp()
|
||||
{
|
||||
$this->visitService = $this->prophesize(VisitService::class);
|
||||
$this->ipResolver = $this->prophesize(IpLocationResolver::class);
|
||||
$this->ipResolver = $this->prophesize(IpApiLocationResolver::class);
|
||||
$this->ipResolver->getApiLimit()->willReturn(10000000000);
|
||||
|
||||
$command = new ProcessVisitsCommand(
|
||||
$this->visitService->reveal(),
|
||||
$this->ipResolver->reveal(),
|
||||
@@ -65,9 +67,9 @@ class ProcessVisitsCommandTest extends TestCase
|
||||
'command' => 'visit:process',
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertTrue(strpos($output, 'Processing IP 1.2.3.4') === 0);
|
||||
$this->assertTrue(strpos($output, 'Processing IP 4.3.2.1') > 0);
|
||||
$this->assertTrue(strpos($output, 'Processing IP 12.34.56.78') > 0);
|
||||
$this->assertEquals(0, \strpos($output, 'Processing IP 1.2.3.0'));
|
||||
$this->assertGreaterThan(0, \strpos($output, 'Processing IP 4.3.2.0'));
|
||||
$this->assertGreaterThan(0, \strpos($output, 'Processing IP 12.34.56.0'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,14 +87,51 @@ class ProcessVisitsCommandTest extends TestCase
|
||||
$this->visitService->getUnlocatedVisits()->willReturn($visits)
|
||||
->shouldBeCalledTimes(1);
|
||||
|
||||
$this->visitService->saveVisit(Argument::any())->shouldBeCalledTimes(count($visits) - 2);
|
||||
$this->visitService->saveVisit(Argument::any())->shouldBeCalledTimes(\count($visits) - 2);
|
||||
$this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
|
||||
->shouldBeCalledTimes(count($visits) - 2);
|
||||
->shouldBeCalledTimes(\count($visits) - 2);
|
||||
|
||||
$this->commandTester->execute([
|
||||
'command' => 'visit:process',
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertTrue(strpos($output, 'Ignored localhost address') > 0);
|
||||
$this->assertGreaterThan(0, \strpos($output, 'Ignored localhost address'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function sleepsEveryTimeTheApiLimitIsReached()
|
||||
{
|
||||
$visits = [
|
||||
(new Visit())->setRemoteAddr('1.2.3.4'),
|
||||
(new Visit())->setRemoteAddr('4.3.2.1'),
|
||||
(new Visit())->setRemoteAddr('12.34.56.78'),
|
||||
(new Visit())->setRemoteAddr('1.2.3.4'),
|
||||
(new Visit())->setRemoteAddr('4.3.2.1'),
|
||||
(new Visit())->setRemoteAddr('12.34.56.78'),
|
||||
(new Visit())->setRemoteAddr('1.2.3.4'),
|
||||
(new Visit())->setRemoteAddr('4.3.2.1'),
|
||||
(new Visit())->setRemoteAddr('12.34.56.78'),
|
||||
(new Visit())->setRemoteAddr('4.3.2.1'),
|
||||
];
|
||||
$apiLimit = 3;
|
||||
|
||||
$this->visitService->getUnlocatedVisits()->willReturn($visits);
|
||||
$this->visitService->saveVisit(Argument::any())->will(function () {
|
||||
});
|
||||
|
||||
$getApiLimit = $this->ipResolver->getApiLimit()->willReturn($apiLimit);
|
||||
$getApiInterval = $this->ipResolver->getApiInterval()->willReturn(0);
|
||||
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
|
||||
->shouldBeCalledTimes(count($visits));
|
||||
|
||||
$this->commandTester->execute([
|
||||
'command' => 'visit:process',
|
||||
]);
|
||||
|
||||
$getApiLimit->shouldHaveBeenCalledTimes(\count($visits));
|
||||
$getApiInterval->shouldHaveBeenCalledTimes(\round(\count($visits) / $apiLimit));
|
||||
$resolveIpLocation->shouldHaveBeenCalledTimes(\count($visits));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ return [
|
||||
|
||||
Image\ImageBuilder::class => Image\ImageBuilderFactory::class,
|
||||
|
||||
Service\IpLocationResolver::class => ConfigAbstractFactory::class,
|
||||
Service\IpApiLocationResolver::class => ConfigAbstractFactory::class,
|
||||
Service\PreviewGenerator::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
'aliases' => [
|
||||
@@ -51,7 +51,7 @@ return [
|
||||
ConfigAbstractFactory::class => [
|
||||
TranslatorExtension::class => ['translator'],
|
||||
LocaleMiddleware::class => ['translator'],
|
||||
Service\IpLocationResolver::class => ['httpClient'],
|
||||
Service\IpApiLocationResolver::class => ['httpClient'],
|
||||
Service\PreviewGenerator::class => [
|
||||
ImageBuilder::class,
|
||||
Filesystem::class,
|
||||
|
||||
@@ -3,6 +3,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Common;
|
||||
|
||||
use function getenv;
|
||||
use function strtolower;
|
||||
use function trim;
|
||||
|
||||
/**
|
||||
* Gets the value of an environment variable. Supports boolean, empty and null.
|
||||
* This is basically Laravel's env helper
|
||||
|
||||
@@ -8,26 +8,19 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
abstract class AbstractEntity
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
* @var string
|
||||
* @ORM\Id
|
||||
* @ORM\GeneratedValue(strategy="IDENTITY")
|
||||
* @ORM\Column(name="id", type="bigint", options={"unsigned"=true})
|
||||
*/
|
||||
protected $id;
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getId()
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $id
|
||||
* @return $this
|
||||
*/
|
||||
public function setId($id)
|
||||
public function setId(string $id): self
|
||||
{
|
||||
$this->id = $id;
|
||||
return $this;
|
||||
|
||||
@@ -5,8 +5,8 @@ namespace Shlinkio\Shlink\Common\Exception;
|
||||
|
||||
class WrongIpException extends RuntimeException
|
||||
{
|
||||
public static function fromIpAddress($ipAddress, \Throwable $prev = null)
|
||||
public static function fromIpAddress($ipAddress, \Throwable $prev = null): self
|
||||
{
|
||||
return new self(sprintf('Provided IP "%s" is invalid', $ipAddress), 0, $prev);
|
||||
return new self(\sprintf('Provided IP "%s" is invalid', $ipAddress), 0, $prev);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ class EmptyResponseImplicitOptionsMiddlewareFactory implements FactoryInterface
|
||||
*/
|
||||
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
|
||||
{
|
||||
return new ImplicitOptionsMiddleware(new EmptyResponse());
|
||||
return new ImplicitOptionsMiddleware(function () {
|
||||
return new EmptyResponse();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,7 @@ class EntityManagerFactory implements FactoryInterface
|
||||
* @param null|array $options
|
||||
* @return object
|
||||
* @throws ServiceNotFoundException if unable to resolve the service.
|
||||
* @throws ServiceNotCreatedException if an exception is raised when
|
||||
* creating a service.
|
||||
* @throws ServiceNotCreatedException if an exception is raised when creating a service.
|
||||
* @throws ContainerException if any other error occurs
|
||||
*/
|
||||
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
|
||||
@@ -32,14 +31,14 @@ class EntityManagerFactory implements FactoryInterface
|
||||
$globalConfig = $container->get('config');
|
||||
$isDevMode = isset($globalConfig['debug']) ? ((bool) $globalConfig['debug']) : false;
|
||||
$cache = $container->has(Cache::class) ? $container->get(Cache::class) : new ArrayCache();
|
||||
$emConfig = isset($globalConfig['entity_manager']) ? $globalConfig['entity_manager'] : [];
|
||||
$connecitonConfig = isset($emConfig['connection']) ? $emConfig['connection'] : [];
|
||||
$ormConfig = isset($emConfig['orm']) ? $emConfig['orm'] : [];
|
||||
$emConfig = $globalConfig['entity_manager'] ?? [];
|
||||
$connectionConfig = $emConfig['connection'] ?? [];
|
||||
$ormConfig = $emConfig['orm'] ?? [];
|
||||
|
||||
return EntityManager::create($connecitonConfig, Setup::createAnnotationMetadataConfiguration(
|
||||
isset($ormConfig['entities_paths']) ? $ormConfig['entities_paths'] : [],
|
||||
return EntityManager::create($connectionConfig, Setup::createAnnotationMetadataConfiguration(
|
||||
$ormConfig['entities_paths'] ?? [],
|
||||
$isDevMode,
|
||||
isset($ormConfig['proxies_dir']) ? $ormConfig['proxies_dir'] : null,
|
||||
$ormConfig['proxies_dir'] ?? null,
|
||||
$cache,
|
||||
false
|
||||
));
|
||||
|
||||
@@ -3,10 +3,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Common\Middleware;
|
||||
|
||||
use Interop\Http\ServerMiddleware\DelegateInterface;
|
||||
use Interop\Http\ServerMiddleware\MiddlewareInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface as DelegateInterface;
|
||||
use Zend\I18n\Translator\Translator;
|
||||
|
||||
class LocaleMiddleware implements MiddlewareInterface
|
||||
@@ -32,15 +32,15 @@ class LocaleMiddleware implements MiddlewareInterface
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function process(Request $request, DelegateInterface $delegate)
|
||||
public function process(Request $request, DelegateInterface $delegate): Response
|
||||
{
|
||||
if (! $request->hasHeader('Accept-Language')) {
|
||||
return $delegate->process($request);
|
||||
return $delegate->handle($request);
|
||||
}
|
||||
|
||||
$locale = $request->getHeaderLine('Accept-Language');
|
||||
$this->translator->setLocale($this->normalizeLocale($locale));
|
||||
return $delegate->process($request);
|
||||
return $delegate->handle($request);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@ use Zend\Paginator\Adapter\AdapterInterface;
|
||||
|
||||
class PaginableRepositoryAdapter implements AdapterInterface
|
||||
{
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
public const ITEMS_PER_PAGE = 10;
|
||||
|
||||
/**
|
||||
* @var PaginableRepositoryInterface
|
||||
@@ -34,7 +34,7 @@ class PaginableRepositoryAdapter implements AdapterInterface
|
||||
$orderBy = null
|
||||
) {
|
||||
$this->paginableRepository = $paginableRepository;
|
||||
$this->searchTerm = $searchTerm !== null ? trim(strip_tags($searchTerm)) : null;
|
||||
$this->searchTerm = $searchTerm !== null ? \trim(\strip_tags($searchTerm)) : null;
|
||||
$this->orderBy = $orderBy;
|
||||
$this->tags = $tags;
|
||||
}
|
||||
@@ -46,7 +46,7 @@ class PaginableRepositoryAdapter implements AdapterInterface
|
||||
* @param int $itemCountPerPage Number of items per page
|
||||
* @return array
|
||||
*/
|
||||
public function getItems($offset, $itemCountPerPage)
|
||||
public function getItems($offset, $itemCountPerPage): array
|
||||
{
|
||||
return $this->paginableRepository->findList(
|
||||
$itemCountPerPage,
|
||||
@@ -66,7 +66,7 @@ class PaginableRepositoryAdapter implements AdapterInterface
|
||||
* The return value is cast to an integer.
|
||||
* @since 5.1.0
|
||||
*/
|
||||
public function count()
|
||||
public function count(): int
|
||||
{
|
||||
return $this->paginableRepository->countList($this->searchTerm, $this->tags);
|
||||
}
|
||||
|
||||
@@ -3,29 +3,38 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Common\Paginator\Util;
|
||||
|
||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
||||
use Zend\Paginator\Paginator;
|
||||
use Zend\Stdlib\ArrayUtils;
|
||||
|
||||
trait PaginatorUtilsTrait
|
||||
{
|
||||
protected function serializePaginator(Paginator $paginator)
|
||||
private function serializePaginator(Paginator $paginator, ?DataTransformerInterface $transformer = null): array
|
||||
{
|
||||
return [
|
||||
'data' => ArrayUtils::iteratorToArray($paginator->getCurrentItems()),
|
||||
'data' => $this->serializeItems(ArrayUtils::iteratorToArray($paginator->getCurrentItems()), $transformer),
|
||||
'pagination' => [
|
||||
'currentPage' => $paginator->getCurrentPageNumber(),
|
||||
'pagesCount' => $paginator->count(),
|
||||
'itemsPerPage' => $paginator->getItemCountPerPage(),
|
||||
'itemsInCurrentPage' => $paginator->getCurrentItemCount(),
|
||||
'totalItems' => $paginator->getTotalItemCount(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function serializeItems(array $items, ?DataTransformerInterface $transformer = null): array
|
||||
{
|
||||
return $transformer === null ? $items : \array_map([$transformer, 'transform'], $items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if provided paginator is in last page
|
||||
*
|
||||
* @param Paginator $paginator
|
||||
* @return bool
|
||||
*/
|
||||
protected function isLastPage(Paginator $paginator)
|
||||
private function isLastPage(Paginator $paginator): bool
|
||||
{
|
||||
return $paginator->getCurrentPageNumber() >= $paginator->count();
|
||||
}
|
||||
|
||||
33
module/Common/src/Response/PixelResponse.php
Normal file
33
module/Common/src/Response/PixelResponse.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Common\Response;
|
||||
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
use Zend\Diactoros\Response;
|
||||
use Zend\Diactoros\Stream;
|
||||
|
||||
class PixelResponse extends Response
|
||||
{
|
||||
private const BASE_64_IMAGE = 'R0lGODlhAQABAJAAAP8AAAAAACH5BAUQAAAALAAAAAABAAEAAAICBAEAOw==';
|
||||
private const CONTENT_TYPE = 'image/gif';
|
||||
|
||||
public function __construct(int $status = 200, array $headers = [])
|
||||
{
|
||||
$headers['content-type'] = self::CONTENT_TYPE;
|
||||
parent::__construct($this->createBody(), $status, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the message body.
|
||||
*
|
||||
* @return StreamInterface
|
||||
*/
|
||||
private function createBody(): StreamInterface
|
||||
{
|
||||
$body = new Stream('php://temp', 'wb+');
|
||||
$body->write((string) \base64_decode(self::BASE_64_IMAGE));
|
||||
$body->rewind();
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
9
module/Common/src/Rest/DataTransformerInterface.php
Normal file
9
module/Common/src/Rest/DataTransformerInterface.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Common\Rest;
|
||||
|
||||
interface DataTransformerInterface
|
||||
{
|
||||
public function transform($value): array;
|
||||
}
|
||||
71
module/Common/src/Service/IpApiLocationResolver.php
Normal file
71
module/Common/src/Service/IpApiLocationResolver.php
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,4 +13,18 @@ interface IpLocationResolverInterface
|
||||
* @throws WrongIpException
|
||||
*/
|
||||
public function resolveIpLocation(string $ipAddress): array;
|
||||
|
||||
/**
|
||||
* Returns the interval in seconds that needs to be waited when the API limit is reached
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getApiInterval(): int;
|
||||
|
||||
/**
|
||||
* Returns the limit of requests that can be performed to the API in a specific interval, or null if no limit exists
|
||||
*
|
||||
* @return int|null
|
||||
*/
|
||||
public function getApiLimit(): ?int;
|
||||
}
|
||||
|
||||
74
module/Common/src/Util/IpAddress.php
Normal file
74
module/Common/src/Util/IpAddress.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,40 +3,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Common\DbUnit;
|
||||
|
||||
use Doctrine\DBAL\Driver\PDOConnection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\DbUnit\Database\Connection as DbConn;
|
||||
use PHPUnit\DbUnit\DataSet\IDataSet as DataSet;
|
||||
use PHPUnit\DbUnit\TestCase;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
abstract class DatabaseTestCase extends TestCase
|
||||
{
|
||||
const ENTITIES_TO_EMPTY = [];
|
||||
protected const ENTITIES_TO_EMPTY = [];
|
||||
|
||||
/**
|
||||
* @var EntityManagerInterface
|
||||
*/
|
||||
public static $em;
|
||||
/**
|
||||
* @var DbConn
|
||||
*/
|
||||
private static $conn;
|
||||
|
||||
public function getConnection(): DbConn
|
||||
{
|
||||
if (isset(self::$conn)) {
|
||||
return self::$conn;
|
||||
}
|
||||
|
||||
/** @var PDOConnection $pdo */
|
||||
$pdo = static::$em->getConnection()->getWrappedConnection();
|
||||
return self::$conn = $this->createDefaultDBConnection($pdo, static::$em->getConnection()->getDatabase());
|
||||
}
|
||||
|
||||
public function getDataSet(): DataSet
|
||||
{
|
||||
return $this->createArrayDataSet([]);
|
||||
}
|
||||
|
||||
protected function getEntityManager(): EntityManagerInterface
|
||||
{
|
||||
|
||||
@@ -38,8 +38,8 @@ class EmptyResponseImplicitOptionsMiddlewareFactoryTest extends TestCase
|
||||
$instance = $this->factory->__invoke(new ServiceManager(), '');
|
||||
|
||||
$ref = new \ReflectionObject($instance);
|
||||
$prop = $ref->getProperty('response');
|
||||
$prop = $ref->getProperty('responseFactory');
|
||||
$prop->setAccessible(true);
|
||||
$this->assertInstanceOf(EmptyResponse::class, $prop->getValue($instance));
|
||||
$this->assertInstanceOf(EmptyResponse::class, $prop->getValue($instance)());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ class LocaleMiddlewareTest extends TestCase
|
||||
public function whenNoHeaderIsPresentLocaleIsNotChanged()
|
||||
{
|
||||
$this->assertEquals('ru', $this->translator->getLocale());
|
||||
$this->middleware->process(ServerRequestFactory::fromGlobals(), TestUtils::createDelegateMock()->reveal());
|
||||
$this->middleware->process(ServerRequestFactory::fromGlobals(), TestUtils::createReqHandlerMock()->reveal());
|
||||
$this->assertEquals('ru', $this->translator->getLocale());
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ class LocaleMiddlewareTest extends TestCase
|
||||
{
|
||||
$this->assertEquals('ru', $this->translator->getLocale());
|
||||
$request = ServerRequestFactory::fromGlobals()->withHeader('Accept-Language', 'es');
|
||||
$this->middleware->process($request, TestUtils::createDelegateMock()->reveal());
|
||||
$this->middleware->process($request, TestUtils::createReqHandlerMock()->reveal());
|
||||
$this->assertEquals('es', $this->translator->getLocale());
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ class LocaleMiddlewareTest extends TestCase
|
||||
*/
|
||||
public function localeGetsNormalized()
|
||||
{
|
||||
$delegate = TestUtils::createDelegateMock();
|
||||
$delegate = TestUtils::createReqHandlerMock();
|
||||
|
||||
$this->assertEquals('ru', $this->translator->getLocale());
|
||||
|
||||
|
||||
84
module/Common/test/Service/IpApiLocationResolverTest.php
Normal file
84
module/Common/test/Service/IpApiLocationResolverTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -3,22 +3,22 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Common\Util;
|
||||
|
||||
use Interop\Http\ServerMiddleware\DelegateInterface;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophet;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Zend\Diactoros\Response;
|
||||
|
||||
class TestUtils
|
||||
{
|
||||
private static $prophet;
|
||||
|
||||
public static function createDelegateMock(ResponseInterface $response = null, RequestInterface $request = null)
|
||||
public static function createReqHandlerMock(ResponseInterface $response = null, RequestInterface $request = null)
|
||||
{
|
||||
$argument = $request ?: Argument::any();
|
||||
$delegate = static::getProphet()->prophesize(DelegateInterface::class);
|
||||
$delegate->process($argument)->willReturn($response ?: new Response());
|
||||
$delegate = static::getProphet()->prophesize(RequestHandlerInterface::class);
|
||||
$delegate->handle($argument)->willReturn($response ?: new Response());
|
||||
|
||||
return $delegate;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use Shlinkio\Shlink\Common\Service\PreviewGenerator;
|
||||
use Shlinkio\Shlink\Core\Action;
|
||||
use Shlinkio\Shlink\Core\Middleware;
|
||||
use Shlinkio\Shlink\Core\Options;
|
||||
use Shlinkio\Shlink\Core\Response\NotFoundDelegate;
|
||||
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
|
||||
use Shlinkio\Shlink\Core\Service;
|
||||
use Zend\Expressive\Router\RouterInterface;
|
||||
use Zend\Expressive\Template\TemplateRendererInterface;
|
||||
@@ -17,7 +17,8 @@ return [
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
Options\AppOptions::class => Options\AppOptionsFactory::class,
|
||||
NotFoundDelegate::class => ConfigAbstractFactory::class,
|
||||
Options\DeleteShortUrlsOptions::class => Options\DeleteShortUrlsOptionsFactory::class,
|
||||
NotFoundHandler::class => ConfigAbstractFactory::class,
|
||||
|
||||
// Services
|
||||
Service\UrlShortener::class => ConfigAbstractFactory::class,
|
||||
@@ -25,27 +26,24 @@ return [
|
||||
Service\ShortUrlService::class => ConfigAbstractFactory::class,
|
||||
Service\VisitService::class => ConfigAbstractFactory::class,
|
||||
Service\Tag\TagService::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
|
||||
|
||||
// Middleware
|
||||
Action\RedirectAction::class => ConfigAbstractFactory::class,
|
||||
Action\PixelAction::class => ConfigAbstractFactory::class,
|
||||
Action\QrCodeAction::class => ConfigAbstractFactory::class,
|
||||
Action\PreviewAction::class => ConfigAbstractFactory::class,
|
||||
Middleware\QrCodeCacheMiddleware::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
|
||||
'aliases' => [
|
||||
'Zend\Expressive\Delegate\DefaultDelegate' => NotFoundDelegate::class,
|
||||
],
|
||||
],
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
NotFoundDelegate::class => [TemplateRendererInterface::class],
|
||||
NotFoundHandler::class => [TemplateRendererInterface::class],
|
||||
|
||||
// Services
|
||||
Service\UrlShortener::class => [
|
||||
'httpClient',
|
||||
'em',
|
||||
Cache::class,
|
||||
'config.url_shortener.validate_url',
|
||||
'config.url_shortener.shortcode_chars',
|
||||
],
|
||||
@@ -53,15 +51,23 @@ return [
|
||||
Service\ShortUrlService::class => ['em'],
|
||||
Service\VisitService::class => ['em'],
|
||||
Service\Tag\TagService::class => ['em'],
|
||||
Service\ShortUrl\DeleteShortUrlService::class => ['em', Options\DeleteShortUrlsOptions::class],
|
||||
|
||||
// Middleware
|
||||
Action\RedirectAction::class => [
|
||||
Service\UrlShortener::class,
|
||||
Service\VisitsTracker::class,
|
||||
Options\AppOptions::class,
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\PixelAction::class => [
|
||||
Service\UrlShortener::class,
|
||||
Service\VisitsTracker::class,
|
||||
Options\AppOptions::class,
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\QrCodeAction::class => [RouterInterface::class, Service\UrlShortener::class, 'Logger_Shlink'],
|
||||
Action\PreviewAction::class => [PreviewGenerator::class, Service\UrlShortener::class],
|
||||
Action\PreviewAction::class => [PreviewGenerator::class, Service\UrlShortener::class, 'Logger_Shlink'],
|
||||
Middleware\QrCodeCacheMiddleware::class => [Cache::class],
|
||||
],
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ return [
|
||||
'middleware' => Action\RedirectAction::class,
|
||||
'allowed_methods' => ['GET'],
|
||||
],
|
||||
[
|
||||
'name' => 'pixel-tracking',
|
||||
'path' => '/{shortCode}/track',
|
||||
'middleware' => Action\PixelAction::class,
|
||||
'allowed_methods' => ['GET'],
|
||||
],
|
||||
[
|
||||
'name' => 'short-url-qr-code',
|
||||
'path' => '/{shortCode}/qr-code[/{size:[0-9]+}]',
|
||||
|
||||
83
module/Core/src/Action/AbstractTrackingAction.php
Normal file
83
module/Core/src/Action/AbstractTrackingAction.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Action;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Shlinkio\Shlink\Core\Action\Util\ErrorResponseBuilderTrait;
|
||||
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
|
||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
|
||||
abstract class AbstractTrackingAction implements MiddlewareInterface
|
||||
{
|
||||
use ErrorResponseBuilderTrait;
|
||||
|
||||
/**
|
||||
* @var UrlShortenerInterface
|
||||
*/
|
||||
private $urlShortener;
|
||||
/**
|
||||
* @var VisitsTrackerInterface
|
||||
*/
|
||||
private $visitTracker;
|
||||
/**
|
||||
* @var AppOptions
|
||||
*/
|
||||
private $appOptions;
|
||||
/**
|
||||
* @var LoggerInterface
|
||||
*/
|
||||
private $logger;
|
||||
|
||||
public function __construct(
|
||||
UrlShortenerInterface $urlShortener,
|
||||
VisitsTrackerInterface $visitTracker,
|
||||
AppOptions $appOptions,
|
||||
LoggerInterface $logger = null
|
||||
) {
|
||||
$this->urlShortener = $urlShortener;
|
||||
$this->visitTracker = $visitTracker;
|
||||
$this->appOptions = $appOptions;
|
||||
$this->logger = $logger ?: new NullLogger();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an incoming server request and return a response, optionally delegating
|
||||
* to the next middleware component to create the response.
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @param RequestHandlerInterface $handler
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$shortCode = $request->getAttribute('shortCode', '');
|
||||
$query = $request->getQueryParams();
|
||||
$disableTrackParam = $this->appOptions->getDisableTrackParam();
|
||||
|
||||
try {
|
||||
$url = $this->urlShortener->shortCodeToUrl($shortCode);
|
||||
|
||||
// Track visit to this short code
|
||||
if ($disableTrackParam === null || ! \array_key_exists($disableTrackParam, $query)) {
|
||||
$this->visitTracker->track($shortCode, $request);
|
||||
}
|
||||
|
||||
return $this->createResp($url->getLongUrl());
|
||||
} catch (InvalidShortCodeException | EntityDoesNotExistException $e) {
|
||||
$this->logger->warning('An error occurred while tracking short code.' . PHP_EOL . $e);
|
||||
return $this->buildErrorResponse($request, $handler);
|
||||
}
|
||||
}
|
||||
|
||||
abstract protected function createResp(string $longUrl): ResponseInterface;
|
||||
}
|
||||
15
module/Core/src/Action/PixelAction.php
Normal file
15
module/Core/src/Action/PixelAction.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Action;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Shlinkio\Shlink\Common\Response\PixelResponse;
|
||||
|
||||
class PixelAction extends AbstractTrackingAction
|
||||
{
|
||||
protected function createResp(string $longUrl): ResponseInterface
|
||||
{
|
||||
return new PixelResponse();
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Action;
|
||||
|
||||
use Interop\Http\ServerMiddleware\DelegateInterface;
|
||||
use Interop\Http\ServerMiddleware\MiddlewareInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
|
||||
use Shlinkio\Shlink\Common\Service\PreviewGeneratorInterface;
|
||||
use Shlinkio\Shlink\Common\Util\ResponseUtilsTrait;
|
||||
@@ -28,11 +30,19 @@ class PreviewAction implements MiddlewareInterface
|
||||
* @var UrlShortenerInterface
|
||||
*/
|
||||
private $urlShortener;
|
||||
/**
|
||||
* @var LoggerInterface
|
||||
*/
|
||||
private $logger;
|
||||
|
||||
public function __construct(PreviewGeneratorInterface $previewGenerator, UrlShortenerInterface $urlShortener)
|
||||
{
|
||||
public function __construct(
|
||||
PreviewGeneratorInterface $previewGenerator,
|
||||
UrlShortenerInterface $urlShortener,
|
||||
LoggerInterface $logger = null
|
||||
) {
|
||||
$this->previewGenerator = $previewGenerator;
|
||||
$this->urlShortener = $urlShortener;
|
||||
$this->logger = $logger ?: new NullLogger();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,24 +50,21 @@ class PreviewAction implements MiddlewareInterface
|
||||
* to the next middleware component to create the response.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param DelegateInterface $delegate
|
||||
* @param RequestHandlerInterface $handler
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function process(Request $request, DelegateInterface $delegate)
|
||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||
{
|
||||
$shortCode = $request->getAttribute('shortCode');
|
||||
|
||||
try {
|
||||
$url = $this->urlShortener->shortCodeToUrl($shortCode);
|
||||
$imagePath = $this->previewGenerator->generatePreview($url);
|
||||
$imagePath = $this->previewGenerator->generatePreview($url->getLongUrl());
|
||||
return $this->generateImageResponse($imagePath);
|
||||
} catch (InvalidShortCodeException $e) {
|
||||
return $this->buildErrorResponse($request, $delegate);
|
||||
} catch (EntityDoesNotExistException $e) {
|
||||
return $this->buildErrorResponse($request, $delegate);
|
||||
} catch (PreviewGenerationException $e) {
|
||||
return $this->buildErrorResponse($request, $delegate);
|
||||
} catch (InvalidShortCodeException | EntityDoesNotExistException | PreviewGenerationException $e) {
|
||||
$this->logger->warning('An error occurred while generating preview image.' . PHP_EOL . $e);
|
||||
return $this->buildErrorResponse($request, $handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Action;
|
||||
|
||||
use Endroid\QrCode\QrCode;
|
||||
use Interop\Http\ServerMiddleware\DelegateInterface;
|
||||
use Interop\Http\ServerMiddleware\MiddlewareInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
|
||||
@@ -15,12 +15,17 @@ use Shlinkio\Shlink\Core\Action\Util\ErrorResponseBuilderTrait;
|
||||
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||
use Zend\Expressive\Router\Exception\RuntimeException;
|
||||
use Zend\Expressive\Router\RouterInterface;
|
||||
|
||||
class QrCodeAction implements MiddlewareInterface
|
||||
{
|
||||
use ErrorResponseBuilderTrait;
|
||||
|
||||
private const DEFAULT_SIZE = 300;
|
||||
private const MIN_SIZE = 50;
|
||||
private const MAX_SIZE = 1000;
|
||||
|
||||
/**
|
||||
* @var RouterInterface
|
||||
*/
|
||||
@@ -49,22 +54,21 @@ class QrCodeAction implements MiddlewareInterface
|
||||
* to the next middleware component to create the response.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param DelegateInterface $delegate
|
||||
* @param RequestHandlerInterface $handler
|
||||
*
|
||||
* @return Response
|
||||
* @throws \InvalidArgumentException
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function process(Request $request, DelegateInterface $delegate)
|
||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||
{
|
||||
// Make sure the short URL exists for this short code
|
||||
$shortCode = $request->getAttribute('shortCode');
|
||||
try {
|
||||
$this->urlShortener->shortCodeToUrl($shortCode);
|
||||
} catch (InvalidShortCodeException $e) {
|
||||
$this->logger->warning('Tried to create a QR code with an invalid short code' . PHP_EOL . $e);
|
||||
return $this->buildErrorResponse($request, $delegate);
|
||||
} catch (EntityDoesNotExistException $e) {
|
||||
$this->logger->warning('Tried to create a QR code with a not found short code' . PHP_EOL . $e);
|
||||
return $this->buildErrorResponse($request, $delegate);
|
||||
} catch (InvalidShortCodeException | EntityDoesNotExistException $e) {
|
||||
$this->logger->warning('An error occurred while creating QR code' . PHP_EOL . $e);
|
||||
return $this->buildErrorResponse($request, $handler);
|
||||
}
|
||||
|
||||
$path = $this->router->generateUri('long-url-redirect', ['shortCode' => $shortCode]);
|
||||
@@ -80,13 +84,13 @@ class QrCodeAction implements MiddlewareInterface
|
||||
* @param Request $request
|
||||
* @return int
|
||||
*/
|
||||
protected function getSizeParam(Request $request)
|
||||
private function getSizeParam(Request $request): int
|
||||
{
|
||||
$size = (int) $request->getAttribute('size', 300);
|
||||
if ($size < 50) {
|
||||
return 50;
|
||||
$size = (int) $request->getAttribute('size', self::DEFAULT_SIZE);
|
||||
if ($size < self::MIN_SIZE) {
|
||||
return self::MIN_SIZE;
|
||||
}
|
||||
|
||||
return $size > 1000 ? 1000 : $size;
|
||||
return $size > self::MAX_SIZE ? self::MAX_SIZE : $size;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,75 +3,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Action;
|
||||
|
||||
use Interop\Http\ServerMiddleware\DelegateInterface;
|
||||
use Interop\Http\ServerMiddleware\MiddlewareInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Shlinkio\Shlink\Core\Action\Util\ErrorResponseBuilderTrait;
|
||||
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
|
||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Zend\Diactoros\Response\RedirectResponse;
|
||||
|
||||
class RedirectAction implements MiddlewareInterface
|
||||
class RedirectAction extends AbstractTrackingAction
|
||||
{
|
||||
use ErrorResponseBuilderTrait;
|
||||
|
||||
/**
|
||||
* @var UrlShortenerInterface
|
||||
*/
|
||||
private $urlShortener;
|
||||
/**
|
||||
* @var VisitsTrackerInterface
|
||||
*/
|
||||
private $visitTracker;
|
||||
/**
|
||||
* @var AppOptions
|
||||
*/
|
||||
private $appOptions;
|
||||
|
||||
public function __construct(
|
||||
UrlShortenerInterface $urlShortener,
|
||||
VisitsTrackerInterface $visitTracker,
|
||||
AppOptions $appOptions
|
||||
) {
|
||||
$this->urlShortener = $urlShortener;
|
||||
$this->visitTracker = $visitTracker;
|
||||
$this->appOptions = $appOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an incoming server request and return a response, optionally delegating
|
||||
* to the next middleware component to create the response.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param DelegateInterface $delegate
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function process(Request $request, DelegateInterface $delegate)
|
||||
protected function createResp(string $longUrl): Response
|
||||
{
|
||||
$shortCode = $request->getAttribute('shortCode', '');
|
||||
$query = $request->getQueryParams();
|
||||
$disableTrackParam = $this->appOptions->getDisableTrackParam();
|
||||
|
||||
try {
|
||||
$longUrl = $this->urlShortener->shortCodeToUrl($shortCode);
|
||||
|
||||
// Track visit to this short code
|
||||
if ($disableTrackParam === null || ! \array_key_exists($disableTrackParam, $query)) {
|
||||
$this->visitTracker->track($shortCode, $request);
|
||||
}
|
||||
|
||||
// Return a redirect response to the long URL.
|
||||
// Use a temporary redirect to make sure browsers always hit the server for analytics purposes
|
||||
return new RedirectResponse($longUrl);
|
||||
} catch (InvalidShortCodeException $e) {
|
||||
return $this->buildErrorResponse($request, $delegate);
|
||||
} catch (EntityDoesNotExistException $e) {
|
||||
return $this->buildErrorResponse($request, $delegate);
|
||||
}
|
||||
// Return a redirect response to the long URL.
|
||||
// Use a temporary redirect to make sure browsers always hit the server for analytics purposes
|
||||
return new RedirectResponse($longUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,18 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Action\Util;
|
||||
|
||||
use Interop\Http\ServerMiddleware\DelegateInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Core\Response\NotFoundDelegate;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
|
||||
|
||||
trait ErrorResponseBuilderTrait
|
||||
{
|
||||
private function buildErrorResponse(ServerRequestInterface $request, DelegateInterface $delegate): ResponseInterface
|
||||
{
|
||||
$request = $request->withAttribute(NotFoundDelegate::NOT_FOUND_TEMPLATE, 'ShlinkCore::invalid-short-code');
|
||||
return $delegate->process($request);
|
||||
private function buildErrorResponse(
|
||||
ServerRequestInterface $request,
|
||||
RequestHandlerInterface $handler
|
||||
): ResponseInterface {
|
||||
$request = $request->withAttribute(NotFoundHandler::NOT_FOUND_TEMPLATE, 'ShlinkCore::invalid-short-code');
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,43 +7,44 @@ use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
|
||||
|
||||
/**
|
||||
* Class ShortUrl
|
||||
* @author
|
||||
* @link
|
||||
*
|
||||
* @ORM\Entity(repositoryClass="Shlinkio\Shlink\Core\Repository\ShortUrlRepository")
|
||||
* @ORM\Entity(repositoryClass=ShortUrlRepository::class)
|
||||
* @ORM\Table(name="short_urls")
|
||||
*/
|
||||
class ShortUrl extends AbstractEntity implements \JsonSerializable
|
||||
class ShortUrl extends AbstractEntity
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
* @ORM\Column(name="original_url", type="string", nullable=false, length=1024)
|
||||
*/
|
||||
protected $originalUrl;
|
||||
private $originalUrl;
|
||||
/**
|
||||
* @var string
|
||||
* @ORM\Column(
|
||||
* name="short_code",
|
||||
* type="string",
|
||||
* nullable=false,
|
||||
* length=10,
|
||||
* length=255,
|
||||
* unique=true
|
||||
* )
|
||||
*/
|
||||
protected $shortCode;
|
||||
private $shortCode;
|
||||
/**
|
||||
* @var \DateTime
|
||||
* @ORM\Column(name="date_created", type="datetime")
|
||||
*/
|
||||
protected $dateCreated;
|
||||
private $dateCreated;
|
||||
/**
|
||||
* @var Collection|Visit[]
|
||||
* @ORM\OneToMany(targetEntity=Visit::class, mappedBy="shortUrl", fetch="EXTRA_LAZY")
|
||||
*/
|
||||
protected $visits;
|
||||
private $visits;
|
||||
/**
|
||||
* @var Collection|Tag[]
|
||||
* @ORM\ManyToMany(targetEntity=Tag::class, cascade={"persist"})
|
||||
@@ -53,83 +54,75 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
|
||||
* @ORM\JoinColumn(name="tag_id", referencedColumnName="id")
|
||||
* })
|
||||
*/
|
||||
protected $tags;
|
||||
private $tags;
|
||||
/**
|
||||
* @var \DateTime
|
||||
* @ORM\Column(name="valid_since", type="datetime", nullable=true)
|
||||
*/
|
||||
protected $validSince;
|
||||
private $validSince;
|
||||
/**
|
||||
* @var \DateTime
|
||||
* @ORM\Column(name="valid_until", type="datetime", nullable=true)
|
||||
*/
|
||||
protected $validUntil;
|
||||
private $validUntil;
|
||||
/**
|
||||
* @var integer
|
||||
* @ORM\Column(name="max_visits", type="integer", nullable=true)
|
||||
*/
|
||||
protected $maxVisits;
|
||||
private $maxVisits;
|
||||
|
||||
/**
|
||||
* ShortUrl constructor.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->shortCode = '';
|
||||
$this->dateCreated = new \DateTime();
|
||||
$this->visits = new ArrayCollection();
|
||||
$this->shortCode = '';
|
||||
$this->tags = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getOriginalUrl(): string
|
||||
public function getLongUrl(): string
|
||||
{
|
||||
return $this->originalUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $originalUrl
|
||||
* @return $this
|
||||
*/
|
||||
public function setOriginalUrl(string $originalUrl)
|
||||
public function setLongUrl(string $longUrl): self
|
||||
{
|
||||
$this->originalUrl = $originalUrl;
|
||||
$this->originalUrl = $longUrl;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
* @deprecated Use getLongUrl() instead
|
||||
*/
|
||||
public function getOriginalUrl(): string
|
||||
{
|
||||
return $this->getLongUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use setLongUrl() instead
|
||||
*/
|
||||
public function setOriginalUrl(string $originalUrl): self
|
||||
{
|
||||
return $this->setLongUrl($originalUrl);
|
||||
}
|
||||
|
||||
public function getShortCode(): string
|
||||
{
|
||||
return $this->shortCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $shortCode
|
||||
* @return $this
|
||||
*/
|
||||
public function setShortCode(string $shortCode)
|
||||
public function setShortCode(string $shortCode): self
|
||||
{
|
||||
$this->shortCode = $shortCode;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTime
|
||||
*/
|
||||
public function getDateCreated(): \DateTime
|
||||
{
|
||||
return $this->dateCreated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateTime $dateCreated
|
||||
* @return $this
|
||||
*/
|
||||
public function setDateCreated(\DateTime $dateCreated)
|
||||
public function setDateCreated(\DateTime $dateCreated): self
|
||||
{
|
||||
$this->dateCreated = $dateCreated;
|
||||
return $this;
|
||||
@@ -145,55 +138,36 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
|
||||
|
||||
/**
|
||||
* @param Collection|Tag[] $tags
|
||||
* @return $this
|
||||
*/
|
||||
public function setTags(Collection $tags)
|
||||
public function setTags(Collection $tags): self
|
||||
{
|
||||
$this->tags = $tags;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Tag $tag
|
||||
* @return $this
|
||||
*/
|
||||
public function addTag(Tag $tag)
|
||||
public function addTag(Tag $tag): self
|
||||
{
|
||||
$this->tags->add($tag);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTime|null
|
||||
*/
|
||||
public function getValidSince()
|
||||
public function getValidSince(): ?\DateTime
|
||||
{
|
||||
return $this->validSince;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateTime|null $validSince
|
||||
* @return $this|self
|
||||
*/
|
||||
public function setValidSince($validSince): self
|
||||
public function setValidSince(?\DateTime $validSince): self
|
||||
{
|
||||
$this->validSince = $validSince;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTime|null
|
||||
*/
|
||||
public function getValidUntil()
|
||||
public function getValidUntil(): ?\DateTime
|
||||
{
|
||||
return $this->validUntil;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateTime|null $validUntil
|
||||
* @return $this|self
|
||||
*/
|
||||
public function setValidUntil($validUntil): self
|
||||
public function setValidUntil(?\DateTime $validUntil): self
|
||||
{
|
||||
$this->validUntil = $validUntil;
|
||||
return $this;
|
||||
@@ -201,11 +175,11 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
|
||||
|
||||
public function getVisitsCount(): int
|
||||
{
|
||||
return count($this->visits);
|
||||
return \count($this->visits);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection $visits
|
||||
* @param Collection|Visit[] $visits
|
||||
* @return ShortUrl
|
||||
* @internal
|
||||
*/
|
||||
@@ -215,19 +189,12 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|null
|
||||
*/
|
||||
public function getMaxVisits()
|
||||
public function getMaxVisits(): ?int
|
||||
{
|
||||
return $this->maxVisits;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|null $maxVisits
|
||||
* @return $this|self
|
||||
*/
|
||||
public function setMaxVisits($maxVisits): self
|
||||
public function setMaxVisits(?int $maxVisits): self
|
||||
{
|
||||
$this->maxVisits = $maxVisits;
|
||||
return $this;
|
||||
@@ -237,22 +204,4 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
|
||||
{
|
||||
return $this->maxVisits !== null && $this->getVisitsCount() >= $this->maxVisits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify data which should be serialized to JSON
|
||||
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
|
||||
* @return mixed data which can be serialized by <b>json_encode</b>,
|
||||
* which is a value of any type other than a resource.
|
||||
* @since 5.4.0
|
||||
*/
|
||||
public function jsonSerialize()
|
||||
{
|
||||
return [
|
||||
'shortCode' => $this->shortCode,
|
||||
'originalUrl' => $this->originalUrl,
|
||||
'dateCreated' => $this->dateCreated !== null ? $this->dateCreated->format(\DateTime::ATOM) : null,
|
||||
'visitsCount' => $this->getVisitsCount(),
|
||||
'tags' => $this->tags->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,39 +21,25 @@ class Tag extends AbstractEntity implements \JsonSerializable
|
||||
* @var string
|
||||
* @ORM\Column(unique=true)
|
||||
*/
|
||||
protected $name;
|
||||
private $name;
|
||||
|
||||
public function __construct($name = null)
|
||||
{
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getName()
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @return $this
|
||||
*/
|
||||
public function setName($name)
|
||||
public function setName(string $name)
|
||||
{
|
||||
$this->name = $name;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify data which should be serialized to JSON
|
||||
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
|
||||
* @return mixed data which can be serialized by <b>json_encode</b>,
|
||||
* which is a value of any type other than a resource.
|
||||
* @since 5.4.0
|
||||
*/
|
||||
public function jsonSerialize()
|
||||
public function jsonSerialize(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
@@ -5,13 +5,16 @@ namespace Shlinkio\Shlink\Core\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Common\Exception\WrongIpException;
|
||||
use Shlinkio\Shlink\Common\Util\IpAddress;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
|
||||
/**
|
||||
* Class Visit
|
||||
* @author
|
||||
* @link
|
||||
*
|
||||
* @ORM\Entity(repositoryClass="Shlinkio\Shlink\Core\Repository\VisitRepository")
|
||||
* @ORM\Entity(repositoryClass=VisitRepository::class)
|
||||
* @ORM\Table(name="visits")
|
||||
*/
|
||||
class Visit extends AbstractEntity implements \JsonSerializable
|
||||
@@ -20,143 +23,115 @@ class Visit extends AbstractEntity implements \JsonSerializable
|
||||
* @var string
|
||||
* @ORM\Column(type="string", length=256, nullable=true)
|
||||
*/
|
||||
protected $referer;
|
||||
private $referer;
|
||||
/**
|
||||
* @var \DateTime
|
||||
* @ORM\Column(type="datetime", nullable=false)
|
||||
*/
|
||||
protected $date;
|
||||
private $date;
|
||||
/**
|
||||
* @var string
|
||||
* @var string|null
|
||||
* @ORM\Column(type="string", length=256, name="remote_addr", nullable=true)
|
||||
*/
|
||||
protected $remoteAddr;
|
||||
private $remoteAddr;
|
||||
/**
|
||||
* @var string
|
||||
* @ORM\Column(type="string", length=256, name="user_agent", nullable=true)
|
||||
*/
|
||||
protected $userAgent;
|
||||
private $userAgent;
|
||||
/**
|
||||
* @var ShortUrl
|
||||
* @ORM\ManyToOne(targetEntity=ShortUrl::class)
|
||||
* @ORM\JoinColumn(name="short_url_id", referencedColumnName="id")
|
||||
*/
|
||||
protected $shortUrl;
|
||||
private $shortUrl;
|
||||
/**
|
||||
* @var VisitLocation
|
||||
* @ORM\ManyToOne(targetEntity=VisitLocation::class, cascade={"persist"})
|
||||
* @ORM\JoinColumn(name="visit_location_id", referencedColumnName="id", nullable=true)
|
||||
*/
|
||||
protected $visitLocation;
|
||||
private $visitLocation;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->date = new \DateTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getReferer()
|
||||
public function getReferer(): string
|
||||
{
|
||||
return $this->referer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $referer
|
||||
* @return $this
|
||||
*/
|
||||
public function setReferer($referer)
|
||||
public function setReferer(string $referer): self
|
||||
{
|
||||
$this->referer = $referer;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTime
|
||||
*/
|
||||
public function getDate()
|
||||
public function getDate(): \DateTime
|
||||
{
|
||||
return $this->date;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateTime $date
|
||||
* @return $this
|
||||
*/
|
||||
public function setDate($date)
|
||||
public function setDate(\DateTime $date): self
|
||||
{
|
||||
$this->date = $date;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ShortUrl
|
||||
*/
|
||||
public function getShortUrl()
|
||||
public function getShortUrl(): ShortUrl
|
||||
{
|
||||
return $this->shortUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ShortUrl $shortUrl
|
||||
* @return $this
|
||||
*/
|
||||
public function setShortUrl($shortUrl)
|
||||
public function setShortUrl(ShortUrl $shortUrl): self
|
||||
{
|
||||
$this->shortUrl = $shortUrl;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getRemoteAddr()
|
||||
public function getRemoteAddr(): ?string
|
||||
{
|
||||
return $this->remoteAddr;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $remoteAddr
|
||||
* @return $this
|
||||
*/
|
||||
public function setRemoteAddr($remoteAddr)
|
||||
public function setRemoteAddr(?string $remoteAddr): self
|
||||
{
|
||||
$this->remoteAddr = $remoteAddr;
|
||||
$this->remoteAddr = $this->obfuscateAddress($remoteAddr);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getUserAgent()
|
||||
private function obfuscateAddress(?string $address): ?string
|
||||
{
|
||||
// Localhost addresses do not need to be obfuscated
|
||||
if ($address === null || $address === IpAddress::LOCALHOST) {
|
||||
return $address;
|
||||
}
|
||||
|
||||
try {
|
||||
return (string) IpAddress::fromString($address)->getObfuscatedCopy();
|
||||
} catch (WrongIpException $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getUserAgent(): string
|
||||
{
|
||||
return $this->userAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $userAgent
|
||||
* @return $this
|
||||
*/
|
||||
public function setUserAgent($userAgent)
|
||||
public function setUserAgent(string $userAgent): self
|
||||
{
|
||||
$this->userAgent = $userAgent;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return VisitLocation
|
||||
*/
|
||||
public function getVisitLocation()
|
||||
public function getVisitLocation(): VisitLocation
|
||||
{
|
||||
return $this->visitLocation;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param VisitLocation $visitLocation
|
||||
* @return $this
|
||||
*/
|
||||
public function setVisitLocation($visitLocation)
|
||||
public function setVisitLocation(VisitLocation $visitLocation): self
|
||||
{
|
||||
$this->visitLocation = $visitLocation;
|
||||
return $this;
|
||||
@@ -165,18 +140,20 @@ class Visit extends AbstractEntity implements \JsonSerializable
|
||||
/**
|
||||
* Specify data which should be serialized to JSON
|
||||
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
|
||||
* @return mixed data which can be serialized by <b>json_encode</b>,
|
||||
* @return array data which can be serialized by <b>json_encode</b>,
|
||||
* which is a value of any type other than a resource.
|
||||
* @since 5.4.0
|
||||
*/
|
||||
public function jsonSerialize()
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'referer' => $this->referer,
|
||||
'date' => isset($this->date) ? $this->date->format(\DateTime::ATOM) : null,
|
||||
'remoteAddr' => $this->remoteAddr,
|
||||
'userAgent' => $this->userAgent,
|
||||
'visitLocation' => $this->visitLocation,
|
||||
|
||||
// Deprecated
|
||||
'remoteAddr' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,159 +21,110 @@ class VisitLocation extends AbstractEntity implements ArraySerializableInterface
|
||||
* @var string
|
||||
* @ORM\Column(nullable=true)
|
||||
*/
|
||||
protected $countryCode;
|
||||
private $countryCode;
|
||||
/**
|
||||
* @var string
|
||||
* @ORM\Column(nullable=true)
|
||||
*/
|
||||
protected $countryName;
|
||||
private $countryName;
|
||||
/**
|
||||
* @var string
|
||||
* @ORM\Column(nullable=true)
|
||||
*/
|
||||
protected $regionName;
|
||||
private $regionName;
|
||||
/**
|
||||
* @var string
|
||||
* @ORM\Column(nullable=true)
|
||||
*/
|
||||
protected $cityName;
|
||||
private $cityName;
|
||||
/**
|
||||
* @var string
|
||||
* @ORM\Column(nullable=true)
|
||||
*/
|
||||
protected $latitude;
|
||||
private $latitude;
|
||||
/**
|
||||
* @var string
|
||||
* @ORM\Column(nullable=true)
|
||||
*/
|
||||
protected $longitude;
|
||||
private $longitude;
|
||||
/**
|
||||
* @var string
|
||||
* @ORM\Column(nullable=true)
|
||||
*/
|
||||
protected $timezone;
|
||||
private $timezone;
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getCountryCode()
|
||||
public function getCountryCode(): string
|
||||
{
|
||||
return $this->countryCode;
|
||||
return $this->countryCode ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $countryCode
|
||||
* @return $this
|
||||
*/
|
||||
public function setCountryCode($countryCode)
|
||||
public function setCountryCode(string $countryCode)
|
||||
{
|
||||
$this->countryCode = $countryCode;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getCountryName()
|
||||
public function getCountryName(): string
|
||||
{
|
||||
return $this->countryName;
|
||||
return $this->countryName ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $countryName
|
||||
* @return $this
|
||||
*/
|
||||
public function setCountryName($countryName)
|
||||
public function setCountryName(string $countryName): self
|
||||
{
|
||||
$this->countryName = $countryName;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getRegionName()
|
||||
public function getRegionName(): string
|
||||
{
|
||||
return $this->regionName;
|
||||
return $this->regionName ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $regionName
|
||||
* @return $this
|
||||
*/
|
||||
public function setRegionName($regionName)
|
||||
public function setRegionName(string $regionName): self
|
||||
{
|
||||
$this->regionName = $regionName;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getCityName()
|
||||
public function getCityName(): string
|
||||
{
|
||||
return $this->cityName;
|
||||
return $this->cityName ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $cityName
|
||||
* @return $this
|
||||
*/
|
||||
public function setCityName($cityName)
|
||||
public function setCityName(string $cityName): self
|
||||
{
|
||||
$this->cityName = $cityName;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getLatitude()
|
||||
public function getLatitude(): string
|
||||
{
|
||||
return $this->latitude;
|
||||
return $this->latitude ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $latitude
|
||||
* @return $this
|
||||
*/
|
||||
public function setLatitude($latitude)
|
||||
public function setLatitude(string $latitude): self
|
||||
{
|
||||
$this->latitude = $latitude;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getLongitude()
|
||||
public function getLongitude(): string
|
||||
{
|
||||
return $this->longitude;
|
||||
return $this->longitude ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $longitude
|
||||
* @return $this
|
||||
*/
|
||||
public function setLongitude($longitude)
|
||||
public function setLongitude(string $longitude): self
|
||||
{
|
||||
$this->longitude = $longitude;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getTimezone()
|
||||
public function getTimezone(): string
|
||||
{
|
||||
return $this->timezone;
|
||||
return $this->timezone ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $timezone
|
||||
* @return $this
|
||||
*/
|
||||
public function setTimezone($timezone)
|
||||
public function setTimezone(string $timezone): self
|
||||
{
|
||||
$this->timezone = $timezone;
|
||||
return $this;
|
||||
@@ -181,41 +132,36 @@ class VisitLocation extends AbstractEntity implements ArraySerializableInterface
|
||||
|
||||
/**
|
||||
* Exchange internal values from provided array
|
||||
*
|
||||
* @param array $array
|
||||
* @return void
|
||||
*/
|
||||
public function exchangeArray(array $array)
|
||||
public function exchangeArray(array $array): void
|
||||
{
|
||||
if (array_key_exists('country_code', $array)) {
|
||||
if (\array_key_exists('country_code', $array)) {
|
||||
$this->setCountryCode($array['country_code']);
|
||||
}
|
||||
if (array_key_exists('country_name', $array)) {
|
||||
if (\array_key_exists('country_name', $array)) {
|
||||
$this->setCountryName($array['country_name']);
|
||||
}
|
||||
if (array_key_exists('region_name', $array)) {
|
||||
if (\array_key_exists('region_name', $array)) {
|
||||
$this->setRegionName($array['region_name']);
|
||||
}
|
||||
if (array_key_exists('city', $array)) {
|
||||
if (\array_key_exists('city', $array)) {
|
||||
$this->setCityName($array['city']);
|
||||
}
|
||||
if (array_key_exists('latitude', $array)) {
|
||||
if (\array_key_exists('latitude', $array)) {
|
||||
$this->setLatitude($array['latitude']);
|
||||
}
|
||||
if (array_key_exists('longitude', $array)) {
|
||||
if (\array_key_exists('longitude', $array)) {
|
||||
$this->setLongitude($array['longitude']);
|
||||
}
|
||||
if (array_key_exists('time_zone', $array)) {
|
||||
if (\array_key_exists('time_zone', $array)) {
|
||||
$this->setTimezone($array['time_zone']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array representation of the object
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getArrayCopy()
|
||||
public function getArrayCopy(): array
|
||||
{
|
||||
return [
|
||||
'countryCode' => $this->countryCode,
|
||||
@@ -228,14 +174,7 @@ class VisitLocation extends AbstractEntity implements ArraySerializableInterface
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify data which should be serialized to JSON
|
||||
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
|
||||
* @return mixed data which can be serialized by <b>json_encode</b>,
|
||||
* which is a value of any type other than a resource.
|
||||
* @since 5.4.0
|
||||
*/
|
||||
public function jsonSerialize()
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->getArrayCopy();
|
||||
}
|
||||
|
||||
34
module/Core/src/Exception/DeleteShortUrlException.php
Normal file
34
module/Core/src/Exception/DeleteShortUrlException.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ class InvalidShortCodeException extends RuntimeException
|
||||
{
|
||||
public static function fromCharset($shortCode, $charSet, \Exception $previous = null)
|
||||
{
|
||||
$code = isset($previous) ? $previous->getCode() : -1;
|
||||
$code = $previous !== null ? $previous->getCode() : -1;
|
||||
return new static(
|
||||
sprintf('Provided short code "%s" does not match the char set "%s"', $shortCode, $charSet),
|
||||
\sprintf('Provided short code "%s" does not match the char set "%s"', $shortCode, $charSet),
|
||||
$code,
|
||||
$previous
|
||||
);
|
||||
@@ -17,6 +17,6 @@ class InvalidShortCodeException extends RuntimeException
|
||||
|
||||
public static function fromNotFoundShortCode($shortCode)
|
||||
{
|
||||
return new static(sprintf('Provided short code "%s" does not belong to a short URL', $shortCode));
|
||||
return new static(\sprintf('Provided short code "%s" does not belong to a short URL', $shortCode));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Middleware;
|
||||
|
||||
use Doctrine\Common\Cache\Cache;
|
||||
use Interop\Http\ServerMiddleware\DelegateInterface;
|
||||
use Interop\Http\ServerMiddleware\MiddlewareInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Zend\Diactoros\Response as DiactResp;
|
||||
|
||||
class QrCodeCacheMiddleware implements MiddlewareInterface
|
||||
@@ -27,11 +27,11 @@ class QrCodeCacheMiddleware implements MiddlewareInterface
|
||||
* to the next middleware component to create the response.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param DelegateInterface $delegate
|
||||
* @param RequestHandlerInterface $handler
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function process(Request $request, DelegateInterface $delegate)
|
||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||
{
|
||||
$cacheKey = $request->getUri()->getPath();
|
||||
|
||||
@@ -45,7 +45,7 @@ class QrCodeCacheMiddleware implements MiddlewareInterface
|
||||
|
||||
// If not, call the next middleware and cache it
|
||||
/** @var Response $resp */
|
||||
$resp = $delegate->process($request);
|
||||
$resp = $handler->handle($request);
|
||||
$this->cache->save($cacheKey, [
|
||||
'body' => $resp->getBody()->__toString(),
|
||||
'content-type' => $resp->getHeaderLine('Content-Type'),
|
||||
|
||||
56
module/Core/src/Model/CreateShortCodeData.php
Normal file
56
module/Core/src/Model/CreateShortCodeData.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Psr\Http\Message\UriInterface;
|
||||
|
||||
final class CreateShortCodeData
|
||||
{
|
||||
/**
|
||||
* @var UriInterface
|
||||
*/
|
||||
private $longUrl;
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private $tags;
|
||||
/**
|
||||
* @var ShortUrlMeta
|
||||
*/
|
||||
private $meta;
|
||||
|
||||
public function __construct(
|
||||
UriInterface $longUrl,
|
||||
array $tags = [],
|
||||
ShortUrlMeta $meta = null
|
||||
) {
|
||||
$this->longUrl = $longUrl;
|
||||
$this->tags = $tags;
|
||||
$this->meta = $meta ?? ShortUrlMeta::createFromParams();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return UriInterface
|
||||
*/
|
||||
public function getLongUrl(): UriInterface
|
||||
{
|
||||
return $this->longUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function getTags(): array
|
||||
{
|
||||
return $this->tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ShortUrlMeta
|
||||
*/
|
||||
public function getMeta(): ShortUrlMeta
|
||||
{
|
||||
return $this->meta;
|
||||
}
|
||||
}
|
||||
@@ -71,26 +71,41 @@ final class ShortUrlMeta
|
||||
* @param array $data
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function validate(array $data)
|
||||
private function validate(array $data): void
|
||||
{
|
||||
$inputFilter = new ShortUrlMetaInputFilter($data);
|
||||
if (! $inputFilter->isValid()) {
|
||||
throw ValidationException::fromInputFilter($inputFilter);
|
||||
}
|
||||
|
||||
$this->validSince = $inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE);
|
||||
$this->validSince = $this->validSince !== null ? new \DateTime($this->validSince) : null;
|
||||
$this->validUntil = $inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL);
|
||||
$this->validUntil = $this->validUntil !== null ? new \DateTime($this->validUntil) : null;
|
||||
$this->validSince = $this->parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
|
||||
$this->validUntil = $this->parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
|
||||
$this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
|
||||
$this->maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS);
|
||||
$this->maxVisits = $this->maxVisits !== null ? (int) $this->maxVisits : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|\DateTime|null $date
|
||||
* @return \DateTime|null
|
||||
*/
|
||||
public function getValidSince()
|
||||
private function parseDateField($date): ?\DateTime
|
||||
{
|
||||
if ($date === null || $date instanceof \DateTime) {
|
||||
return $date;
|
||||
}
|
||||
|
||||
if (\is_string($date)) {
|
||||
return new \DateTime($date);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTime|null
|
||||
*/
|
||||
public function getValidSince(): ?\DateTime
|
||||
{
|
||||
return $this->validSince;
|
||||
}
|
||||
@@ -103,7 +118,7 @@ final class ShortUrlMeta
|
||||
/**
|
||||
* @return \DateTime|null
|
||||
*/
|
||||
public function getValidUntil()
|
||||
public function getValidUntil(): ?\DateTime
|
||||
{
|
||||
return $this->validUntil;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,6 @@ class AppOptionsFactory implements FactoryInterface
|
||||
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
|
||||
{
|
||||
$config = $container->has('config') ? $container->get('config') : [];
|
||||
return new AppOptions(isset($config['app_options']) ? $config['app_options'] : []);
|
||||
return new AppOptions($config['app_options'] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
34
module/Core/src/Options/DeleteShortUrlsOptions.php
Normal file
34
module/Core/src/Options/DeleteShortUrlsOptions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
31
module/Core/src/Options/DeleteShortUrlsOptionsFactory.php
Normal file
31
module/Core/src/Options/DeleteShortUrlsOptionsFactory.php
Normal 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'] ?? []);
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
$orderBy = null
|
||||
): array {
|
||||
$qb = $this->createListQueryBuilder($searchTerm, $tags);
|
||||
$qb->select('s');
|
||||
$qb->select('DISTINCT s');
|
||||
|
||||
// Set limit and offset
|
||||
if ($limit !== null) {
|
||||
@@ -47,20 +47,28 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
|
||||
protected function processOrderByForList(QueryBuilder $qb, $orderBy)
|
||||
{
|
||||
$fieldName = is_array($orderBy) ? key($orderBy) : $orderBy;
|
||||
$order = is_array($orderBy) ? $orderBy[$fieldName] : 'ASC';
|
||||
// Map public field names to column names
|
||||
$fieldNameMap = [
|
||||
'originalUrl' => 'originalUrl',
|
||||
'longUrl' => 'originalUrl',
|
||||
'shortCode' => 'shortCode',
|
||||
'dateCreated' => 'dateCreated',
|
||||
];
|
||||
$fieldName = \is_array($orderBy) ? \key($orderBy) : $orderBy;
|
||||
$order = \is_array($orderBy) ? $orderBy[$fieldName] : 'ASC';
|
||||
|
||||
if (in_array($fieldName, ['visits', 'visitsCount', 'visitCount'], true)) {
|
||||
$qb->addSelect('COUNT(v) AS totalVisits')
|
||||
if (\in_array($fieldName, ['visits', 'visitsCount', 'visitCount'], true)) {
|
||||
$qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
|
||||
->leftJoin('s.visits', 'v')
|
||||
->groupBy('s')
|
||||
->orderBy('totalVisits', $order);
|
||||
|
||||
return array_column($qb->getQuery()->getResult(), 0);
|
||||
} elseif (in_array($fieldName, ['originalUrl', 'shortCode', 'dateCreated'], true)) {
|
||||
$qb->orderBy('s.' . $fieldName, $order);
|
||||
return \array_column($qb->getQuery()->getResult(), 0);
|
||||
}
|
||||
|
||||
if (\array_key_exists($fieldName, $fieldNameMap)) {
|
||||
$qb->orderBy('s.' . $fieldNameMap[$fieldName], $order);
|
||||
}
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
@@ -74,7 +82,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
public function countList(string $searchTerm = null, array $tags = []): int
|
||||
{
|
||||
$qb = $this->createListQueryBuilder($searchTerm, $tags);
|
||||
$qb->select('COUNT(s)');
|
||||
$qb->select('COUNT(DISTINCT s)');
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
@@ -92,7 +100,10 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
|
||||
// Apply search term to every searchable field if not empty
|
||||
if (! empty($searchTerm)) {
|
||||
$qb->join('s.tags', 't');
|
||||
// Left join with tags only if no tags were provided. In case of tags, an inner join will be done later
|
||||
if (empty($tags)) {
|
||||
$qb->leftJoin('s.tags', 't');
|
||||
}
|
||||
|
||||
$conditions = [
|
||||
$qb->expr()->like('s.originalUrl', ':searchPattern'),
|
||||
@@ -102,8 +113,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
|
||||
// Unpack and apply search conditions
|
||||
$qb->andWhere($qb->expr()->orX(...$conditions));
|
||||
$searchTerm = '%' . $searchTerm . '%';
|
||||
$qb->setParameter('searchPattern', $searchTerm);
|
||||
$qb->setParameter('searchPattern', '%' . $searchTerm . '%');
|
||||
}
|
||||
|
||||
// Filter by tags if provided
|
||||
@@ -119,7 +129,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
* @param string $shortCode
|
||||
* @return ShortUrl|null
|
||||
*/
|
||||
public function findOneByShortCode(string $shortCode)
|
||||
public function findOneByShortCode(string $shortCode): ?ShortUrl
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
|
||||
|
||||
@@ -4,15 +4,15 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Response;
|
||||
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Interop\Http\ServerMiddleware\DelegateInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Zend\Diactoros\Response;
|
||||
use Zend\Expressive\Template\TemplateRendererInterface;
|
||||
|
||||
class NotFoundDelegate implements DelegateInterface
|
||||
class NotFoundHandler implements RequestHandlerInterface
|
||||
{
|
||||
const NOT_FOUND_TEMPLATE = 'notFoundTemplate';
|
||||
public const NOT_FOUND_TEMPLATE = 'notFoundTemplate';
|
||||
|
||||
/**
|
||||
* @var TemplateRendererInterface
|
||||
@@ -37,14 +37,14 @@ class NotFoundDelegate implements DelegateInterface
|
||||
* @return ResponseInterface
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function process(ServerRequestInterface $request): ResponseInterface
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$accepts = explode(',', $request->getHeaderLine('Accept'));
|
||||
$accept = array_shift($accepts);
|
||||
$accepts = \explode(',', $request->getHeaderLine('Accept'));
|
||||
$accept = \array_shift($accepts);
|
||||
$status = StatusCodeInterface::STATUS_NOT_FOUND;
|
||||
|
||||
// If the first accepted type is json, return a json response
|
||||
if (in_array($accept, ['application/json', 'text/json', 'application/x-json'], true)) {
|
||||
if (\in_array($accept, ['application/json', 'text/json', 'application/x-json'], true)) {
|
||||
return new Response\JsonResponse([
|
||||
'error' => 'NOT_FOUND',
|
||||
'message' => 'Not found',
|
||||
56
module/Core/src/Service/ShortUrl/DeleteShortUrlService.php
Normal file
56
module/Core/src/Service/ShortUrl/DeleteShortUrlService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
29
module/Core/src/Service/ShortUrl/FindShortCodeTrait.php
Normal file
29
module/Core/src/Service/ShortUrl/FindShortCodeTrait.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,13 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\FindShortCodeTrait;
|
||||
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
|
||||
use Zend\Paginator\Paginator;
|
||||
|
||||
class ShortUrlService implements ShortUrlServiceInterface
|
||||
{
|
||||
use FindShortCodeTrait;
|
||||
use TagManagerTrait;
|
||||
|
||||
/**
|
||||
@@ -27,13 +29,11 @@ class ShortUrlService implements ShortUrlServiceInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $page
|
||||
* @param string $searchQuery
|
||||
* @param array $tags
|
||||
* @param null $orderBy
|
||||
* @param string[] $tags
|
||||
* @param array|string|null $orderBy
|
||||
* @return ShortUrl[]|Paginator
|
||||
*/
|
||||
public function listShortUrls($page = 1, $searchQuery = null, array $tags = [], $orderBy = null)
|
||||
public function listShortUrls(int $page = 1, string $searchQuery = null, array $tags = [], $orderBy = null)
|
||||
{
|
||||
/** @var ShortUrlRepository $repo */
|
||||
$repo = $this->em->getRepository(ShortUrl::class);
|
||||
@@ -45,14 +45,12 @@ class ShortUrlService implements ShortUrlServiceInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $shortCode
|
||||
* @param string[] $tags
|
||||
* @return ShortUrl
|
||||
* @throws InvalidShortCodeException
|
||||
*/
|
||||
public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl
|
||||
{
|
||||
$shortUrl = $this->findByShortCode($shortCode);
|
||||
$shortUrl = $this->findByShortCode($this->em, $shortCode);
|
||||
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
|
||||
$this->em->flush();
|
||||
|
||||
@@ -60,14 +58,11 @@ class ShortUrlService implements ShortUrlServiceInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $shortCode
|
||||
* @param ShortUrlMeta $shortCodeMeta
|
||||
* @return ShortUrl
|
||||
* @throws InvalidShortCodeException
|
||||
*/
|
||||
public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortCodeMeta): ShortUrl
|
||||
{
|
||||
$shortUrl = $this->findByShortCode($shortCode);
|
||||
$shortUrl = $this->findByShortCode($this->em, $shortCode);
|
||||
if ($shortCodeMeta->hasValidSince()) {
|
||||
$shortUrl->setValidSince($shortCodeMeta->getValidSince());
|
||||
}
|
||||
@@ -81,23 +76,6 @@ class ShortUrlService implements ShortUrlServiceInterface
|
||||
/** @var ORM\EntityManager $em */
|
||||
$em = $this->em;
|
||||
$em->flush($shortUrl);
|
||||
return $shortUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $shortCode
|
||||
* @return ShortUrl
|
||||
* @throws InvalidShortCodeException
|
||||
*/
|
||||
private function findByShortCode(string $shortCode): ShortUrl
|
||||
{
|
||||
/** @var ShortUrl|null $shortUrl */
|
||||
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
|
||||
'shortCode' => $shortCode,
|
||||
]);
|
||||
if ($shortUrl === null) {
|
||||
throw InvalidShortCodeException::fromNotFoundShortCode($shortCode);
|
||||
}
|
||||
|
||||
return $shortUrl;
|
||||
}
|
||||
|
||||
@@ -11,26 +11,19 @@ use Zend\Paginator\Paginator;
|
||||
interface ShortUrlServiceInterface
|
||||
{
|
||||
/**
|
||||
* @param int $page
|
||||
* @param string $searchQuery
|
||||
* @param array $tags
|
||||
* @param null $orderBy
|
||||
* @param string[] $tags
|
||||
* @param array|string|null $orderBy
|
||||
* @return ShortUrl[]|Paginator
|
||||
*/
|
||||
public function listShortUrls($page = 1, $searchQuery = null, array $tags = [], $orderBy = null);
|
||||
public function listShortUrls(int $page = 1, string $searchQuery = null, array $tags = [], $orderBy = null);
|
||||
|
||||
/**
|
||||
* @param string $shortCode
|
||||
* @param string[] $tags
|
||||
* @return ShortUrl
|
||||
* @throws InvalidShortCodeException
|
||||
*/
|
||||
public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl;
|
||||
|
||||
/**
|
||||
* @param string $shortCode
|
||||
* @param ShortUrlMeta $shortCodeMeta
|
||||
* @return ShortUrl
|
||||
* @throws InvalidShortCodeException
|
||||
*/
|
||||
public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortCodeMeta): ShortUrl;
|
||||
|
||||
@@ -28,16 +28,18 @@ class TagService implements TagServiceInterface
|
||||
* @return Tag[]
|
||||
* @throws \UnexpectedValueException
|
||||
*/
|
||||
public function listTags()
|
||||
public function listTags(): array
|
||||
{
|
||||
return $this->em->getRepository(Tag::class)->findBy([], ['name' => 'ASC']);
|
||||
/** @var Tag[] $tags */
|
||||
$tags = $this->em->getRepository(Tag::class)->findBy([], ['name' => 'ASC']);
|
||||
return $tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $tagNames
|
||||
* @return void
|
||||
*/
|
||||
public function deleteTags(array $tagNames)
|
||||
public function deleteTags(array $tagNames): void
|
||||
{
|
||||
/** @var TagRepository $repo */
|
||||
$repo = $this->em->getRepository(Tag::class);
|
||||
@@ -50,7 +52,7 @@ class TagService implements TagServiceInterface
|
||||
* @param string[] $tagNames
|
||||
* @return Collection|Tag[]
|
||||
*/
|
||||
public function createTags(array $tagNames)
|
||||
public function createTags(array $tagNames): Collection
|
||||
{
|
||||
$tags = $this->tagNamesToEntities($this->em, $tagNames);
|
||||
$this->em->flush();
|
||||
@@ -65,7 +67,7 @@ class TagService implements TagServiceInterface
|
||||
* @throws EntityDoesNotExistException
|
||||
* @throws ORM\OptimisticLockException
|
||||
*/
|
||||
public function renameTag($oldName, $newName)
|
||||
public function renameTag($oldName, $newName): Tag
|
||||
{
|
||||
$criteria = ['name' => $oldName];
|
||||
/** @var Tag|null $tag */
|
||||
|
||||
@@ -12,13 +12,13 @@ interface TagServiceInterface
|
||||
/**
|
||||
* @return Tag[]
|
||||
*/
|
||||
public function listTags();
|
||||
public function listTags(): array;
|
||||
|
||||
/**
|
||||
* @param string[] $tagNames
|
||||
* @return void
|
||||
*/
|
||||
public function deleteTags(array $tagNames);
|
||||
public function deleteTags(array $tagNames): void;
|
||||
|
||||
/**
|
||||
* Provided a list of tag names, creates all that do not exist yet
|
||||
@@ -26,7 +26,7 @@ interface TagServiceInterface
|
||||
* @param string[] $tagNames
|
||||
* @return Collection|Tag[]
|
||||
*/
|
||||
public function createTags(array $tagNames);
|
||||
public function createTags(array $tagNames): Collection;
|
||||
|
||||
/**
|
||||
* @param string $oldName
|
||||
@@ -34,5 +34,5 @@ interface TagServiceInterface
|
||||
* @return Tag
|
||||
* @throws EntityDoesNotExistException
|
||||
*/
|
||||
public function renameTag($oldName, $newName);
|
||||
public function renameTag($oldName, $newName): Tag;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
use Cocur\Slugify\Slugify;
|
||||
use Cocur\Slugify\SlugifyInterface;
|
||||
use Doctrine\Common\Cache\Cache;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use GuzzleHttp\ClientInterface;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
@@ -23,7 +22,8 @@ class UrlShortener implements UrlShortenerInterface
|
||||
{
|
||||
use TagManagerTrait;
|
||||
|
||||
const DEFAULT_CHARS = '123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ';
|
||||
public const DEFAULT_CHARS = '123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ';
|
||||
private const ID_INCREMENT = 200000;
|
||||
|
||||
/**
|
||||
* @var ClientInterface
|
||||
@@ -37,10 +37,6 @@ class UrlShortener implements UrlShortenerInterface
|
||||
* @var string
|
||||
*/
|
||||
private $chars;
|
||||
/**
|
||||
* @var Cache
|
||||
*/
|
||||
private $cache;
|
||||
/**
|
||||
* @var SlugifyInterface
|
||||
*/
|
||||
@@ -53,15 +49,13 @@ class UrlShortener implements UrlShortenerInterface
|
||||
public function __construct(
|
||||
ClientInterface $httpClient,
|
||||
EntityManagerInterface $em,
|
||||
Cache $cache,
|
||||
$urlValidationEnabled,
|
||||
$chars = self::DEFAULT_CHARS,
|
||||
SlugifyInterface $slugger = null
|
||||
) {
|
||||
$this->httpClient = $httpClient;
|
||||
$this->em = $em;
|
||||
$this->cache = $cache;
|
||||
$this->urlValidationEnabled = $urlValidationEnabled;
|
||||
$this->urlValidationEnabled = (bool) $urlValidationEnabled;
|
||||
$this->chars = empty($chars) ? self::DEFAULT_CHARS : $chars;
|
||||
$this->slugger = $slugger ?: new Slugify();
|
||||
}
|
||||
@@ -75,7 +69,6 @@ class UrlShortener implements UrlShortenerInterface
|
||||
* @param \DateTime|null $validUntil
|
||||
* @param string|null $customSlug
|
||||
* @param int|null $maxVisits
|
||||
* @return string
|
||||
* @throws NonUniqueSlugException
|
||||
* @throws InvalidUrlException
|
||||
* @throws RuntimeException
|
||||
@@ -87,19 +80,9 @@ class UrlShortener implements UrlShortenerInterface
|
||||
\DateTime $validUntil = null,
|
||||
string $customSlug = null,
|
||||
int $maxVisits = null
|
||||
): string {
|
||||
// If the url already exists in the database, just return its short code
|
||||
/** @var ShortUrl|null $shortUrl */
|
||||
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
|
||||
'originalUrl' => $url,
|
||||
]);
|
||||
if ($shortUrl !== null) {
|
||||
return $shortUrl->getShortCode();
|
||||
}
|
||||
|
||||
// Check if the validation of url is enabled in the config
|
||||
if (true === $this->urlValidationEnabled) {
|
||||
// Check that the URL exists
|
||||
): ShortUrl {
|
||||
// If the URL validation is enabled, check that the URL actually exists
|
||||
if ($this->urlValidationEnabled) {
|
||||
$this->checkUrlExists($url);
|
||||
}
|
||||
$customSlug = $this->processCustomSlug($customSlug);
|
||||
@@ -124,7 +107,7 @@ class UrlShortener implements UrlShortenerInterface
|
||||
$this->em->flush();
|
||||
|
||||
$this->em->commit();
|
||||
return $shortCode;
|
||||
return $shortUrl;
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->em->getConnection()->isTransactionActive()) {
|
||||
$this->em->rollback();
|
||||
@@ -160,14 +143,14 @@ class UrlShortener implements UrlShortenerInterface
|
||||
*/
|
||||
private function convertAutoincrementIdToShortCode(float $id): string
|
||||
{
|
||||
$id += 200000; // Increment the Id so that the generated shortcode is not too short
|
||||
$id += self::ID_INCREMENT; // Increment the Id so that the generated shortcode is not too short
|
||||
$length = \strlen($this->chars);
|
||||
$code = '';
|
||||
|
||||
while ($id > 0) {
|
||||
// Determine the value of the next higher character in the short code and prepend it
|
||||
$code = $this->chars[(int) fmod($id, $length)] . $code;
|
||||
$id = floor($id / $length);
|
||||
$code = $this->chars[(int) \fmod($id, $length)] . $code;
|
||||
$id = \floor($id / $length);
|
||||
}
|
||||
|
||||
return $this->chars[(int) $id] . $code;
|
||||
@@ -179,7 +162,7 @@ class UrlShortener implements UrlShortenerInterface
|
||||
return null;
|
||||
}
|
||||
|
||||
// If a custom slug was provided, check it is unique
|
||||
// If a custom slug was provided, make sure it's unique
|
||||
$customSlug = $this->slugger->slugify($customSlug);
|
||||
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy(['shortCode' => $customSlug]);
|
||||
if ($shortUrl !== null) {
|
||||
@@ -192,21 +175,13 @@ class UrlShortener implements UrlShortenerInterface
|
||||
/**
|
||||
* Tries to find the mapped URL for provided short code. Returns null if not found
|
||||
*
|
||||
* @param string $shortCode
|
||||
* @return string
|
||||
* @throws InvalidShortCodeException
|
||||
* @throws EntityDoesNotExistException
|
||||
*/
|
||||
public function shortCodeToUrl(string $shortCode): string
|
||||
public function shortCodeToUrl(string $shortCode): ShortUrl
|
||||
{
|
||||
$cacheKey = sprintf('%s_longUrl', $shortCode);
|
||||
// Check if the short code => URL map is already cached
|
||||
if ($this->cache->contains($cacheKey)) {
|
||||
return $this->cache->fetch($cacheKey);
|
||||
}
|
||||
|
||||
// Validate short code format
|
||||
if (! preg_match('|[' . $this->chars . ']+|', $shortCode)) {
|
||||
if (! \preg_match('|[' . $this->chars . ']+|', $shortCode)) {
|
||||
throw InvalidShortCodeException::fromCharset($shortCode, $this->chars);
|
||||
}
|
||||
|
||||
@@ -219,9 +194,6 @@ class UrlShortener implements UrlShortenerInterface
|
||||
]);
|
||||
}
|
||||
|
||||
// Cache the shortcode
|
||||
$url = $shortUrl->getOriginalUrl();
|
||||
$this->cache->save($cacheKey, $url);
|
||||
return $url;
|
||||
return $shortUrl;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user