mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-02 05:03:12 +08:00
Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15d49e97c0 | ||
|
|
d5e7ce38ac | ||
|
|
162d0560db | ||
|
|
1de05047ca | ||
|
|
2af5de1199 | ||
|
|
e66a724d2b | ||
|
|
9f4c2ac8d7 | ||
|
|
44f0011445 | ||
|
|
545094cddf | ||
|
|
99f45d8853 | ||
|
|
c25b5f9938 | ||
|
|
db1304c11a | ||
|
|
57714b373c | ||
|
|
5be7f839f3 | ||
|
|
aa441eb58b | ||
|
|
e6b6a40fa6 | ||
|
|
f6dde6f4c1 | ||
|
|
36ab475578 | ||
|
|
a74fe62da6 | ||
|
|
1e4de7fec4 | ||
|
|
47117d1fb7 | ||
|
|
cb8ef408a4 | ||
|
|
e5f21a88fa | ||
|
|
0458c4f798 | ||
|
|
75f6160432 | ||
|
|
5337eb48e7 | ||
|
|
86c30ee731 | ||
|
|
d68dc38959 | ||
|
|
0525639329 | ||
|
|
0d9c7282df | ||
|
|
3b95925217 | ||
|
|
fa595f7aa3 | ||
|
|
ff80f32f72 | ||
|
|
e55dbef2fc | ||
|
|
ebf2e459e8 | ||
|
|
1b5081ae21 | ||
|
|
d5736756f7 | ||
|
|
757cf2e193 | ||
|
|
3a75ac0486 | ||
|
|
3c3ef6fa05 | ||
|
|
3282bfd03b | ||
|
|
0813df6b29 | ||
|
|
df74a04085 | ||
|
|
8323b87076 | ||
|
|
48f01921e1 | ||
|
|
ae9d99257e | ||
|
|
0183c8a4b7 | ||
|
|
9a2ca35e6e | ||
|
|
2edb48e314 | ||
|
|
a81fd497d4 | ||
|
|
49cca5cd69 | ||
|
|
f92cff6241 | ||
|
|
1b4343ffc2 | ||
|
|
d5392a5f59 | ||
|
|
a65ce649ac | ||
|
|
d5dc6cea99 | ||
|
|
5ecfe9f0f0 | ||
|
|
0f5fb066d1 | ||
|
|
8e61639598 | ||
|
|
e88468d867 | ||
|
|
bc46e2f509 | ||
|
|
2241279bb6 | ||
|
|
25ffbed756 | ||
|
|
8784843a7a | ||
|
|
a964e2b3c9 | ||
|
|
7f7efd45ab | ||
|
|
af8f5afef8 | ||
|
|
dcfaed437c | ||
|
|
47e2322e33 | ||
|
|
00e7d57245 | ||
|
|
d53a3222d0 | ||
|
|
80fe3a73e2 | ||
|
|
7ab993b764 | ||
|
|
622edd2ed1 | ||
|
|
1f5faee356 | ||
|
|
076b0cf867 | ||
|
|
d4168bebc6 | ||
|
|
13c3629cd6 | ||
|
|
1eff9801e8 | ||
|
|
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 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
.idea
|
||||
build
|
||||
composer.lock
|
||||
composer.phar
|
||||
vendor/
|
||||
.env
|
||||
data/database.sqlite
|
||||
|
||||
30
.travis.yml
30
.travis.yml
@@ -1,5 +1,7 @@
|
||||
language: php
|
||||
|
||||
sudo: false # Use containerized environment
|
||||
|
||||
branches:
|
||||
only:
|
||||
- /.*/
|
||||
@@ -7,12 +9,17 @@ branches:
|
||||
php:
|
||||
- 7.1
|
||||
- 7.2
|
||||
- 7.3
|
||||
|
||||
matrix:
|
||||
allow_failures:
|
||||
- php: 7.3
|
||||
|
||||
before_install:
|
||||
- phpenv config-add data/infra/travis-php/memcached.ini
|
||||
- phpenv config-add data/infra/travis-php/apcu.ini
|
||||
- echo 'extension = memcached.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
|
||||
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
|
||||
|
||||
before_script:
|
||||
install:
|
||||
- composer self-update
|
||||
- composer install --no-interaction
|
||||
|
||||
@@ -20,9 +27,22 @@ script:
|
||||
- mkdir build
|
||||
- composer check
|
||||
|
||||
after_script:
|
||||
after_success:
|
||||
- vendor/bin/phpcov merge build --clover build/clover.xml
|
||||
- wget https://scrutinizer-ci.com/ocular.phar
|
||||
- php ocular.phar code-coverage:upload --format=php-clover build/clover.xml
|
||||
|
||||
sudo: false
|
||||
# Before deploying, build dist file for current travis tag
|
||||
before_deploy:
|
||||
- rm -f ocular.phar
|
||||
- ./build.sh ${TRAVIS_TAG#?}
|
||||
|
||||
deploy:
|
||||
provider: releases
|
||||
api_key:
|
||||
secure: a9dbZchocqeuOViwUeNH54bQR5Sz7rEYXx5b9WPFtnFn9LGKKUaLbA2U91UQ9QKPrcTpsALubUYbw2CnNmvCwzaY+R8lCD3gkU4ohsEnbpnw3deOeixI74sqBHJAuCH9FSaRDGILoBMtUKx2xlzIymFxkIsgIukkGbdkWHDlRWY3oTUUuw1SQ2Xk9KDsbJQtjIc1+G/O6gHaV4qv/R9W8NPmJExKTNDrAZbC1vIUnxqp4UpVo1hst8qPd1at94CndDYM5rG+7imGbdtxTxzamt819qdTO1OfvtctKawNAm7YXZrrWft6c7gI6j6SI4hxd+ZrrPBqbaRFHkZHjnNssO/yn4SaOHFFzccmu0MzvpPCf0qWZwd3sGHVYer1MnR2mHYqU84QPlW3nrHwJjkrpq3+q0JcBY6GsJs+RskHNtkMTKV05Iz6QUI5YZGwTpuXaRm036SmavjGc4IDlMaYCk/NmbB9BKpthJxLdUpczOHpnjXXHziotWD6cfEnbjU3byfD8HY5WrxSjsNT7SKmXN3hRof7bk985ewQVjGT42O3NbnfnqjQQWr/B7/zFTpLR4f526Bkq12CdCyf5lvrbq+POkLVdJ+uFfR7ds248Ue/jBQy6kM1tWmKF9QiwisFlA84eQ4CW3I93Rp97URv+AQa9zmbD0Ve3Udp+g6nF5I=
|
||||
file: "./build/shlink_${TRAVIS_TAG#?}_dist.zip"
|
||||
skip_cleanup: true
|
||||
on:
|
||||
tags: true
|
||||
php: 7.1
|
||||
|
||||
191
CHANGELOG.md
191
CHANGELOG.md
@@ -1,5 +1,196 @@
|
||||
# CHANGELOG
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
|
||||
## 1.13.2 - 2018-10-18
|
||||
|
||||
#### Added
|
||||
|
||||
* [#233](https://github.com/shlinkio/shlink/issues/233) Added PHP 7.3 to build matrix allowing its failure.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#235](https://github.com/shlinkio/shlink/issues/235) Improved update instructions (thanks to [tivyhosting](https://github.com/tivyhosting)).
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#237](https://github.com/shlinkio/shlink/issues/233) Solved errors when trying to geo-locate `null` IP addresses.
|
||||
|
||||
Also improved how visitor IP addresses are discovered, thanks to [akrabat/ip-address-middleware](https://github.com/akrabat/ip-address-middleware) package.
|
||||
|
||||
|
||||
## 1.13.1 - 2018-10-16
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#231](https://github.com/shlinkio/shlink/issues/197) Fixed error when processing visits.
|
||||
|
||||
|
||||
## 1.13.0 - 2018-10-06
|
||||
|
||||
#### Added
|
||||
|
||||
* [#197](https://github.com/shlinkio/shlink/issues/197) Added [cakephp/chronos](https://book.cakephp.org/3.0/en/chronos.html) library for date manipulations.
|
||||
* [#214](https://github.com/shlinkio/shlink/issues/214) Improved build script, which allows builds to be done without "jumping" outside the project directory, and generates smaller dist files.
|
||||
|
||||
It also allows automating the dist file generation in travis-ci builds.
|
||||
|
||||
* [#207](https://github.com/shlinkio/shlink/issues/207) Added two new config options which are asked during installation process. The config options already existed in previous shlink version, but you had to manually set their values.
|
||||
|
||||
These are the new options:
|
||||
|
||||
* Visits threshold to allow short URLs to be deleted.
|
||||
* Check the visits threshold when trying to delete a short URL via REST API.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#211](https://github.com/shlinkio/shlink/issues/211) Extracted installer to its own module, which will simplify moving it to a separated package in the future.
|
||||
* [#200](https://github.com/shlinkio/shlink/issues/200) and [#201](https://github.com/shlinkio/shlink/issues/201) Renamed REST Action classes and CLI Command classes to use the concept of `ShortUrl` instead of the concept of `ShortCode` when referring to the entity, and left the `short code` concept to the identifier which is used as a unique code for a specific `Short URL`.
|
||||
* [#181](https://github.com/shlinkio/shlink/issues/181) When importing the configuration from a previous shlink installation, it no longer asks to import every block. Instead, it is capable of detecting only new config options introduced in the new version, and ask only for those.
|
||||
|
||||
If no new options are found and you have selected to import config, no further questions will be asked and shlink will just import the old config.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* [#205](https://github.com/shlinkio/shlink/issues/205) Deprecated `[POST /authenticate]` endpoint, and allowed any API request to be automatically authenticated using the `X-Api-Key` header with a valid API key.
|
||||
|
||||
This effectively deprecates the `Authorization: Bearer <JWT>` authentication form, but it will keep working.
|
||||
|
||||
* As of [#200](https://github.com/shlinkio/shlink/issues/200) and [#201](https://github.com/shlinkio/shlink/issues/201) REST urls have changed from `/short-codes/...` to `/short-urls/...`, and the command namespaces have changed from `short-code:...` to `short-url:...`.
|
||||
|
||||
In both cases, backwards compatibility has been retained and the old ones are aliases for the new ones, but the old ones are considered deprecated.
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#203](https://github.com/shlinkio/shlink/issues/203) Fixed some warnings thrown while unzipping distributable files.
|
||||
* [#206](https://github.com/shlinkio/shlink/issues/206) An error is now thrown during installation if any required param is left empty, making the installer display a message and ask again until a value is set.
|
||||
|
||||
|
||||
## 1.12.0 - 2018-09-15
|
||||
|
||||
#### Added
|
||||
|
||||
* [#187](https://github.com/shlinkio/shlink/issues/187) Included an API endpoint and a CLI command to delete short URLs.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
In order to change it, open the `config/autoload/delete_short_urls.global.php` file, which has this structure:
|
||||
|
||||
```php
|
||||
return [
|
||||
|
||||
'delete_short_urls' => [
|
||||
'visits_threshold' => 15,
|
||||
'check_visits_threshold' => true,
|
||||
],
|
||||
|
||||
];
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Once changed, delete the `data/cache/app_config.php` file (if any) to let shlink know about the new values.
|
||||
|
||||
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.
|
||||
|
||||
* [#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.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#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.
|
||||
|
||||
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)
|
||||
|
||||
* [#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.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#188](https://github.com/shlinkio/shlink/issues/188) Shlink now allows multiple short URLs to be created that resolve to the same long URL.
|
||||
|
||||
|
||||
## 1.11.0 - 2018-08-13
|
||||
|
||||
#### Added
|
||||
|
||||
* [#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.
|
||||
|
||||
The short URLs are now represented by this object in both cases:
|
||||
|
||||
```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"
|
||||
}
|
||||
```
|
||||
|
||||
The `originalUrl` property is considered deprecated and has been kept for backward compatibility purposes. It holds the same value as the `longUrl` property.
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* The `originalUrl` property in `[GET /short-codes]` and `[GET /short-codes/{shortCode}]` endpoints is now deprecated and replaced by the `longUrl` property.
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## 1.10.2 - 2018-08-04
|
||||
|
||||
#### Added
|
||||
|
||||
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
|
||||
|
||||
191
README.md
191
README.md
@@ -1,9 +1,188 @@
|
||||
# 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 automatically created by [travis](https://travis-ci.org/shlinkio/shlink), 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 is a command line tool that will guide you through the installation process. **Take into account that this tool has to be run directly on the server where you plan to host Shlink. Do not run it before uploading/moving it there.**
|
||||
* 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 short-url: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 entire process yourself. Instead, follow these steps:
|
||||
|
||||
1. Rename your existing Shlink directory to something else (ie. `shlink` ---> `shlink-old`)
|
||||
2. Download and extract the new version of Shlink, and set the directories name to that of the old version. (ie. `shlink`)
|
||||
3. Run the `bin/update` script in the new version's directory to migrate your configuration over.
|
||||
|
||||
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 the assets neccessary for Shlink to function.
|
||||
|
||||
Right now, it does not import cached info (like website previews), but it will. For 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.
|
||||
|
||||
### Shlink CLI Help
|
||||
|
||||
```
|
||||
Usage:
|
||||
command [options] [arguments]
|
||||
|
||||
Options:
|
||||
-h, --help Display this help message
|
||||
-q, --quiet Do not output any message
|
||||
-V, --version Display this application version
|
||||
--ansi Force ANSI output
|
||||
--no-ansi Disable ANSI output
|
||||
-n, --no-interaction Do not ask any interactive question
|
||||
-v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
|
||||
|
||||
Available commands:
|
||||
help Displays help for a command
|
||||
list Lists commands
|
||||
api-key
|
||||
api-key:disable Disables an API key.
|
||||
api-key:generate Generates a new valid API key.
|
||||
api-key:list Lists all the available API keys.
|
||||
config
|
||||
config:generate-charset Generates a character set sample just by shuffling the default one, "123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ". Then it can be set in the SHORTCODE_CHARS environment variable
|
||||
config:generate-secret Generates a random secret string that can be used for JWT token encryption
|
||||
short-url
|
||||
short-url:delete [short-code:delete] Deletes a short URL
|
||||
short-url:generate [shortcode:generate|short-code:generate] Generates a short URL for provided long URL and returns it
|
||||
short-url:list [shortcode:list|short-code:list] List all short URLs
|
||||
short-url:parse [shortcode:parse|short-code:parse] Returns the long URL behind a short code
|
||||
short-url:process-previews [shortcode:process-previews|short-code:process-previews] Processes and generates the previews for every URL, improving performance for later web requests.
|
||||
short-url:visits [shortcode:visits|short-code:visits] Returns the detailed visits information for provided short code
|
||||
tag
|
||||
tag:create Creates one or more tags.
|
||||
tag:delete Deletes one or more tags.
|
||||
tag:list Lists existing tags.
|
||||
tag:rename Renames one existing tag.
|
||||
visit
|
||||
visit:process Processes visits where location is not set yet
|
||||
```
|
||||
|
||||
29
bin/install
29
bin/install
@@ -1,29 +1,12 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Installer;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Factory\InstallApplicationFactory;
|
||||
use Shlinkio\Shlink\CLI\Install\Plugin\DatabaseConfigCustomizer;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Zend\ServiceManager\Factory\InvokableFactory;
|
||||
use Zend\ServiceManager\ServiceManager;
|
||||
use Zend\ServiceManager\ServiceLocatorInterface;
|
||||
|
||||
chdir(dirname(__DIR__));
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
$container = new ServiceManager([
|
||||
'factories' => [
|
||||
Application::class => InstallApplicationFactory::class,
|
||||
Filesystem::class => InvokableFactory::class,
|
||||
],
|
||||
'services' => [
|
||||
'config' => [
|
||||
ConfigAbstractFactory::class => [
|
||||
DatabaseConfigCustomizer::class => [Filesystem::class]
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
/** @var ServiceLocatorInterface $container */
|
||||
$container = include __DIR__ . '/../config/install-container.php';
|
||||
$container->build(Application::class)->run();
|
||||
|
||||
29
bin/update
29
bin/update
@@ -1,29 +1,12 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Installer;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Factory\InstallApplicationFactory;
|
||||
use Shlinkio\Shlink\CLI\Install\Plugin\DatabaseConfigCustomizer;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Zend\ServiceManager\Factory\InvokableFactory;
|
||||
use Zend\ServiceManager\ServiceManager;
|
||||
use Zend\ServiceManager\ServiceLocatorInterface;
|
||||
|
||||
chdir(dirname(__DIR__));
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
$container = new ServiceManager([
|
||||
'factories' => [
|
||||
Application::class => InstallApplicationFactory::class,
|
||||
Filesystem::class => InvokableFactory::class,
|
||||
],
|
||||
'services' => [
|
||||
'config' => [
|
||||
ConfigAbstractFactory::class => [
|
||||
DatabaseConfigCustomizer::class => [Filesystem::class]
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
/** @var ServiceLocatorInterface $container */
|
||||
$container = include __DIR__ . '/../config/install-container.php';
|
||||
$container->build(Application::class, ['isUpdate' => true])->run();
|
||||
|
||||
57
build.sh
57
build.sh
@@ -8,48 +8,51 @@ if [ "$#" -ne 1 ]; then
|
||||
fi
|
||||
|
||||
version=$1
|
||||
builtcontent=$(readlink -f "../shlink_${version}_dist")
|
||||
builtcontent="./build/shlink_${version}_dist"
|
||||
projectdir=$(pwd)
|
||||
[ -f ./composer.phar ] && composerBin='./composer.phar' || composerBin='composer'
|
||||
|
||||
# Copy project content to temp dir
|
||||
echo 'Copying project files...'
|
||||
rm -rf "${builtcontent}"
|
||||
mkdir "${builtcontent}"
|
||||
sudo chmod -R 777 "${projectdir}"/data/infra/{database,nginx}
|
||||
cp -R "${projectdir}"/* "${builtcontent}"
|
||||
mkdir -p "${builtcontent}"
|
||||
rsync -av * "${builtcontent}" \
|
||||
--exclude=data/infra \
|
||||
--exclude=**/.gitignore \
|
||||
--exclude=CHANGELOG.md \
|
||||
--exclude=composer.lock \
|
||||
--exclude=vendor \
|
||||
--exclude=docs \
|
||||
--exclude=indocker \
|
||||
--exclude=docker* \
|
||||
--exclude=func_tests_bootstrap.php \
|
||||
--exclude=php* \
|
||||
--exclude=infection.json \
|
||||
--exclude=phpstan.neon \
|
||||
--exclude=config/autoload/*local* \
|
||||
--exclude=**/test* \
|
||||
--exclude=build*
|
||||
cd "${builtcontent}"
|
||||
|
||||
# Install dependencies
|
||||
rm -rf vendor
|
||||
rm -f composer.lock
|
||||
composer self-update
|
||||
composer install --no-dev --optimize-autoloader --no-progress --no-interaction
|
||||
echo "Installing dependencies with $composerBin..."
|
||||
${composerBin} self-update
|
||||
${composerBin} install --no-dev --optimize-autoloader --no-progress --no-interaction
|
||||
|
||||
# Delete development files
|
||||
echo 'Deleting dev files...'
|
||||
rm build.sh
|
||||
rm CHANGELOG.md
|
||||
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
|
||||
rm -rf data/{cache,log,proxies}/{*,.gitignore}
|
||||
rm -rf config/params/{*,.gitignore}
|
||||
rm -rf config/autoload/{{,*.}local.php{,.dist},.gitignore}
|
||||
rm -f data/database.sqlite
|
||||
|
||||
# Update shlink version in config
|
||||
sed -i "s/%SHLINK_VERSION%/${version}/g" config/autoload/app_options.global.php
|
||||
|
||||
# Compressing file
|
||||
rm -f "${projectdir}"/build/shlink_${version}_dist.zip
|
||||
zip -ry "${projectdir}"/build/shlink_${version}_dist.zip "../shlink_${version}_dist"
|
||||
echo 'Compressing files...'
|
||||
cd "${projectdir}"/build
|
||||
rm -f ./shlink_${version}_dist.zip
|
||||
zip -ry ./shlink_${version}_dist.zip ./shlink_${version}_dist
|
||||
cd "${projectdir}"
|
||||
rm -rf "${builtcontent}"
|
||||
|
||||
echo 'Done!'
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
],
|
||||
"require": {
|
||||
"php": "^7.1",
|
||||
"ext-json": "*",
|
||||
"ext-pdo": "*",
|
||||
"acelaya/ze-content-based-error-handler": "^2.2",
|
||||
"akrabat/ip-address-middleware": "^1.0",
|
||||
"cakephp/chronos": "^1.2",
|
||||
"cocur/slugify": "^3.0",
|
||||
"doctrine/cache": "^1.6",
|
||||
"doctrine/migrations": "^1.4",
|
||||
@@ -24,13 +28,13 @@
|
||||
"mikehaertl/phpwkhtmltopdf": "^2.2",
|
||||
"monolog/monolog": "^1.21",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"symfony/console": "^4.0",
|
||||
"symfony/console": "^4.0 <4.1.5",
|
||||
"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-diactoros": "^1.7",
|
||||
"zendframework/zend-diactoros": "^2.0",
|
||||
"zendframework/zend-expressive": "^3.0",
|
||||
"zendframework/zend-expressive-fastroute": "^3.0",
|
||||
"zendframework/zend-expressive-helpers": "^5.0",
|
||||
@@ -59,7 +63,8 @@
|
||||
"Shlinkio\\Shlink\\CLI\\": "module/CLI/src",
|
||||
"Shlinkio\\Shlink\\Rest\\": "module/Rest/src",
|
||||
"Shlinkio\\Shlink\\Core\\": "module/Core/src",
|
||||
"Shlinkio\\Shlink\\Common\\": "module/Common/src"
|
||||
"Shlinkio\\Shlink\\Common\\": "module/Common/src",
|
||||
"Shlinkio\\Shlink\\Installer\\": "module/Installer/src"
|
||||
},
|
||||
"files": [
|
||||
"module/Common/functions/functions.php"
|
||||
@@ -76,7 +81,8 @@
|
||||
"ShlinkioTest\\Shlink\\Common\\": [
|
||||
"module/Common/test",
|
||||
"module/Common/test-func"
|
||||
]
|
||||
],
|
||||
"ShlinkioTest\\Shlink\\Installer\\": "module/Installer/test"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -84,30 +90,44 @@
|
||||
"@cs",
|
||||
"@stan",
|
||||
"@test",
|
||||
"@func-test",
|
||||
"@infect"
|
||||
],
|
||||
|
||||
"cs": "phpcs",
|
||||
"cs-fix": "phpcbf",
|
||||
"serve": "php -S 0.0.0.0:8000 -t public/",
|
||||
"test": "phpunit --coverage-php build/coverage-unit.cov",
|
||||
"pretty-test": "phpunit --coverage-html build/coverage",
|
||||
"func-test": "phpunit -c phpunit-func.xml --coverage-php build/coverage-func.cov",
|
||||
"complete-pretty-test": [
|
||||
"@test",
|
||||
"@func-test",
|
||||
"cs:fix": "phpcbf",
|
||||
"stan": "phpstan analyse module/*/src/ --level=6 -c phpstan.neon",
|
||||
|
||||
"test": [
|
||||
"@test:unit",
|
||||
"@test:func"
|
||||
],
|
||||
"test:unit": "phpunit --coverage-php build/coverage-unit.cov",
|
||||
"test:func": "phpunit -c phpunit-func.xml --coverage-php build/coverage-func.cov",
|
||||
|
||||
"test:pretty": [
|
||||
"@test:unit",
|
||||
"@test:func",
|
||||
"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"
|
||||
"test:unit:pretty": "phpunit --coverage-html build/coverage",
|
||||
|
||||
"infect": "infection --threads=4 --min-msi=60 --only-covered --log-verbosity=2",
|
||||
"infect:show": "infection --threads=4 --min-msi=60 --only-covered --log-verbosity=2 --show-mutations"
|
||||
},
|
||||
"scripts-descriptions": {
|
||||
"check": "<fg=blue;options=bold>Alias for \"cs\", \"stan\", \"test\" and \"infect\"</>",
|
||||
"cs": "<fg=blue;options=bold>Checks coding styles</>",
|
||||
"cs:fix": "<fg=blue;options=bold>Fixes coding styles, when possible</>",
|
||||
"stan": "<fg=blue;options=bold>Inspects code with phpstan</>",
|
||||
"test": "<fg=blue;options=bold>Runs all test suites</>",
|
||||
"test:unit": "<fg=blue;options=bold>Runs unit test suites</>",
|
||||
"test:func": "<fg=blue;options=bold>Runs functional test suites (covering entity repositories)</>",
|
||||
"test:pretty": "<fg=blue;options=bold>Runs all test suites and generates an HTML code coverage report</>",
|
||||
"test:unit:pretty": "<fg=blue;options=bold>Runs unit test suites and generates an HTML code coverage report</>",
|
||||
"infect": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing</>",
|
||||
"infect:show": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing and shows applied mutators</>"
|
||||
},
|
||||
"config": {
|
||||
"process-timeout": 0,
|
||||
"sort-packages": true,
|
||||
"platform": {
|
||||
"php": "7.1.8"
|
||||
}
|
||||
"sort-packages": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ return [
|
||||
'name' => 'Shlink',
|
||||
'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,
|
||||
],
|
||||
|
||||
];
|
||||
@@ -1,12 +1,8 @@
|
||||
<?php
|
||||
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;
|
||||
use Shlinkio\Shlink\Rest\Middleware\PathVersionMiddleware;
|
||||
namespace Shlinkio\Shlink;
|
||||
|
||||
use Zend\Expressive;
|
||||
use Zend\Stratigility\Middleware\ErrorHandler;
|
||||
|
||||
@@ -17,14 +13,15 @@ return [
|
||||
'middleware' => [
|
||||
ErrorHandler::class,
|
||||
Expressive\Helper\ContentLengthMiddleware::class,
|
||||
LocaleMiddleware::class,
|
||||
Common\Middleware\LocaleMiddleware::class,
|
||||
],
|
||||
'priority' => 11,
|
||||
'priority' => 12,
|
||||
],
|
||||
'pre-routing-rest' => [
|
||||
'path' => '/rest',
|
||||
'middleware' => [
|
||||
PathVersionMiddleware::class,
|
||||
Rest\Middleware\PathVersionMiddleware::class,
|
||||
Rest\Middleware\ShortUrl\ShortCodePathMiddleware::class,
|
||||
],
|
||||
'priority' => 11,
|
||||
],
|
||||
@@ -39,10 +36,10 @@ return [
|
||||
'rest' => [
|
||||
'path' => '/rest',
|
||||
'middleware' => [
|
||||
CrossDomainMiddleware::class,
|
||||
Rest\Middleware\CrossDomainMiddleware::class,
|
||||
Expressive\Router\Middleware\ImplicitOptionsMiddleware::class,
|
||||
BodyParserMiddleware::class,
|
||||
CheckAuthenticationMiddleware::class,
|
||||
Rest\Middleware\BodyParserMiddleware::class,
|
||||
Rest\Middleware\AuthenticationMiddleware::class,
|
||||
],
|
||||
'priority' => 5,
|
||||
],
|
||||
@@ -50,7 +47,7 @@ return [
|
||||
'post-routing' => [
|
||||
'middleware' => [
|
||||
Expressive\Router\Middleware\DispatchMiddleware::class,
|
||||
NotFoundHandler::class,
|
||||
Core\Response\NotFoundHandler::class,
|
||||
],
|
||||
'priority' => 1,
|
||||
],
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink;
|
||||
|
||||
use Acelaya\ExpressiveErrorHandler;
|
||||
use Shlinkio\Shlink\CLI;
|
||||
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``.
|
||||
* then ``local.php`` and finally ``*.local.php``. This way local settings overwrite global settings.
|
||||
*
|
||||
* The configuration can be cached. This can be done by setting ``config_cache_enabled`` to ``true``.
|
||||
*
|
||||
* Obviously, if you use closures in your config you can't cache it.
|
||||
*/
|
||||
|
||||
return (new ConfigAggregator\ConfigAggregator([
|
||||
Expressive\ConfigProvider::class,
|
||||
Expressive\Router\ConfigProvider::class,
|
||||
@@ -31,6 +20,7 @@ return (new ConfigAggregator\ConfigAggregator([
|
||||
Common\ConfigProvider::class,
|
||||
Core\ConfigProvider::class,
|
||||
CLI\ConfigProvider::class,
|
||||
Installer\ConfigProvider::class,
|
||||
Rest\ConfigProvider::class,
|
||||
new ConfigAggregator\ZendConfigProvider('config/{autoload/{{,*.}global,{,*.}local},params/generated_config}.php'),
|
||||
], 'data/cache/app_config.php'))->getMergedConfig();
|
||||
|
||||
29
config/install-container.php
Normal file
29
config/install-container.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Shlinkio\Shlink\Installer\Config\Plugin\DatabaseConfigCustomizer;
|
||||
use Shlinkio\Shlink\Installer\Factory\InstallApplicationFactory;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Zend\ServiceManager\Factory\InvokableFactory;
|
||||
use Zend\ServiceManager\ServiceManager;
|
||||
|
||||
chdir(dirname(__DIR__));
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
$container = new ServiceManager([
|
||||
'factories' => [
|
||||
Application::class => InstallApplicationFactory::class,
|
||||
Filesystem::class => InvokableFactory::class,
|
||||
],
|
||||
'services' => [
|
||||
'config' => [
|
||||
ConfigAbstractFactory::class => [
|
||||
DatabaseConfigCustomizer::class => [Filesystem::class],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
return $container;
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM php:7.1-fpm-alpine
|
||||
FROM php:7.1.22-fpm-alpine
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
RUN apk update
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
extension="apcu.so"
|
||||
@@ -1 +0,0 @@
|
||||
extension="memcached.so"
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"post": {
|
||||
"deprecated": true,
|
||||
"operationId": "authenticate",
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"summary": "Perform authentication",
|
||||
"description": "Performs an authentication",
|
||||
"summary": "[Deprecated] Perform authentication",
|
||||
"description": "**This endpoint is deprecated, since the authentication can be performed via API key now**. Performs an authentication.",
|
||||
"requestBody": {
|
||||
"description": "Request body.",
|
||||
"required": true,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"get": {
|
||||
"operationId": "listShortUrls",
|
||||
"tags": [
|
||||
"ShortCodes"
|
||||
"Short URLs"
|
||||
],
|
||||
"summary": "List short URLs",
|
||||
"description": "Returns the list of short codes",
|
||||
"description": "Returns the list of short URLs.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "page",
|
||||
@@ -44,7 +45,7 @@
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"originalUrl",
|
||||
"longUrl",
|
||||
"shortCode",
|
||||
"dateCreated",
|
||||
"visits"
|
||||
@@ -53,6 +54,9 @@
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
},
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
@@ -89,7 +93,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 +104,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 +114,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": []
|
||||
@@ -139,12 +146,16 @@
|
||||
},
|
||||
|
||||
"post": {
|
||||
"operationId": "createShortUrl",
|
||||
"tags": [
|
||||
"ShortCodes"
|
||||
"Short URLs"
|
||||
],
|
||||
"summary": "Create short URL",
|
||||
"description": "Creates a new short code",
|
||||
"description": "Creates a new short URL.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
},
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"get": {
|
||||
"operationId": "shortenUrl",
|
||||
"tags": [
|
||||
"ShortCodes"
|
||||
"Short URLs"
|
||||
],
|
||||
"summary": "Create a short URL",
|
||||
"description": "Creates a short URL in a single API call. Useful for third party integrations",
|
||||
"description": "Creates a short URL in a single API call. Useful for third party integrations.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "apiKey",
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"get": {
|
||||
"operationId": "getShortUrl",
|
||||
"tags": [
|
||||
"ShortCodes"
|
||||
"Short URLs"
|
||||
],
|
||||
"summary": "Parse short code",
|
||||
"description": "Get the long URL behind a short code.",
|
||||
"description": "Get the long URL behind a short URL's short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "shortCode",
|
||||
@@ -17,29 +18,33 @@
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
},
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -77,11 +82,12 @@
|
||||
},
|
||||
|
||||
"put": {
|
||||
"operationId": "editShortUrl",
|
||||
"tags": [
|
||||
"ShortCodes"
|
||||
"Short URLs"
|
||||
],
|
||||
"summary": "Edit short code",
|
||||
"description": "Update certain meta arguments from an existing short URL.",
|
||||
"summary": "Edit short URL",
|
||||
"description": "Update certain meta arguments from an existing short URL.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "shortCode",
|
||||
@@ -119,6 +125,9 @@
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
},
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
@@ -158,5 +167,74 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"delete": {
|
||||
"operationId": "deleteShortUrl",
|
||||
"tags": [
|
||||
"Short URLs"
|
||||
],
|
||||
"summary": "Delete short URL",
|
||||
"description": "Deletes the short URL for provided short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "shortCode",
|
||||
"in": "path",
|
||||
"description": "The short code to edit.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
},
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "The short URL 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"put": {
|
||||
"operationId": "editShortUrlTags",
|
||||
"tags": [
|
||||
"ShortCodes",
|
||||
"Tags"
|
||||
"Short URLs"
|
||||
],
|
||||
"summary": "Edit tags on short URL",
|
||||
"description": "Edit the tags on provided short code.",
|
||||
"description": "Edit the tags on URL identified by provided short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "shortCode",
|
||||
"in": "path",
|
||||
"description": "The shortCode in which we want to edit tags.",
|
||||
"description": "The short code for the short URL in which we want to edit tags.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
@@ -41,6 +41,9 @@
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
},
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"get": {
|
||||
"operationId": "getShortUrlVisits",
|
||||
"tags": [
|
||||
"ShortCodes",
|
||||
"Visits"
|
||||
],
|
||||
"summary": "List visits for short URL",
|
||||
"description": "Get the list of visits on provided short code.",
|
||||
"description": "Get the list of visits on the short URL behind provided short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "shortCode",
|
||||
"in": "path",
|
||||
"description": "The shortCode from which we want to get the visits.",
|
||||
"description": "The short code for the short URL from which we want to get the visits.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
@@ -36,6 +36,9 @@
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
},
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
@@ -70,20 +73,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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"get": {
|
||||
"operationId": "listTags",
|
||||
"tags": [
|
||||
"Tags"
|
||||
],
|
||||
"summary": "List existing tags",
|
||||
"description": "Returns the list of all tags used in any short URL, ordered by name",
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
},
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
@@ -60,12 +64,16 @@
|
||||
},
|
||||
|
||||
"post": {
|
||||
"operationId": "createTags",
|
||||
"tags": [
|
||||
"Tags"
|
||||
],
|
||||
"summary": "Create tags",
|
||||
"description": "Provided a list of tags, creates all that do not yet exist",
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
},
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
@@ -143,12 +151,16 @@
|
||||
},
|
||||
|
||||
"put": {
|
||||
"operationId": "renameTag",
|
||||
"tags": [
|
||||
"Tags"
|
||||
],
|
||||
"summary": "Rename tag",
|
||||
"description": "Renames one existing tag",
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
},
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
@@ -216,6 +228,7 @@
|
||||
},
|
||||
|
||||
"delete": {
|
||||
"operationId": "deleteTags",
|
||||
"tags": [
|
||||
"Tags"
|
||||
],
|
||||
@@ -236,6 +249,9 @@
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
},
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
|
||||
@@ -23,8 +23,14 @@
|
||||
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"ApiKey": {
|
||||
"description": "A valid shlink API key",
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-Api-Key"
|
||||
},
|
||||
"Bearer": {
|
||||
"description": "The JWT identifying a previously logged API key",
|
||||
"description": "**[Deprecated]** The JWT identifying a previously authenticated API key",
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
@@ -32,30 +38,49 @@
|
||||
}
|
||||
},
|
||||
|
||||
"paths": {
|
||||
"/v1/authenticate": {
|
||||
"$ref": "paths/v1_authenticate.json"
|
||||
"tags": [
|
||||
{
|
||||
"name": "Short URLs",
|
||||
"description": "Operations that can be performed on short URLs"
|
||||
},
|
||||
{
|
||||
"name": "Tags",
|
||||
"description": "Let you handle the list of available tags"
|
||||
},
|
||||
{
|
||||
"name": "Visits",
|
||||
"description": "Operations to manage visits on short URLs"
|
||||
},
|
||||
{
|
||||
"name": "Authentication",
|
||||
"description": "Authentication-related endpoints"
|
||||
}
|
||||
],
|
||||
|
||||
"/v1/short-codes": {
|
||||
"$ref": "paths/v1_short-codes.json"
|
||||
"paths": {
|
||||
"/v1/short-urls": {
|
||||
"$ref": "paths/v1_short-urls.json"
|
||||
},
|
||||
"/v1/short-codes/shorten": {
|
||||
"$ref": "paths/v1_short-codes_shorten.json"
|
||||
"/v1/short-urls/shorten": {
|
||||
"$ref": "paths/v1_short-urls_shorten.json"
|
||||
},
|
||||
"/v1/short-codes/{shortCode}": {
|
||||
"$ref": "paths/v1_short-codes_{shortCode}.json"
|
||||
"/v1/short-urls/{shortCode}": {
|
||||
"$ref": "paths/v1_short-urls_{shortCode}.json"
|
||||
},
|
||||
"/v1/short-codes/{shortCode}/tags": {
|
||||
"$ref": "paths/v1_short-codes_{shortCode}_tags.json"
|
||||
"/v1/short-urls/{shortCode}/tags": {
|
||||
"$ref": "paths/v1_short-urls_{shortCode}_tags.json"
|
||||
},
|
||||
|
||||
"/v1/tags": {
|
||||
"$ref": "paths/v1_tags.json"
|
||||
},
|
||||
|
||||
"/v1/short-codes/{shortCode}/visits": {
|
||||
"$ref": "paths/v1_short-codes_{shortCode}_visits.json"
|
||||
"/v1/short-urls/{shortCode}/visits": {
|
||||
"$ref": "paths/v1_short-urls_{shortCode}_visits.json"
|
||||
},
|
||||
|
||||
"/v1/authenticate": {
|
||||
"$ref": "paths/v1_authenticate.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command;
|
||||
use Shlinkio\Shlink\Common;
|
||||
namespace Shlinkio\Shlink\CLI;
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
|
||||
return [
|
||||
|
||||
'cli' => [
|
||||
'locale' => Common\env('CLI_LOCALE', 'en'),
|
||||
'locale' => env('CLI_LOCALE', 'en'),
|
||||
'commands' => [
|
||||
Command\Shortcode\GenerateShortcodeCommand::NAME => Command\Shortcode\GenerateShortcodeCommand::class,
|
||||
Command\Shortcode\ResolveUrlCommand::NAME => Command\Shortcode\ResolveUrlCommand::class,
|
||||
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\ShortUrl\GenerateShortUrlCommand::NAME => Command\ShortUrl\GenerateShortUrlCommand::class,
|
||||
Command\ShortUrl\ResolveUrlCommand::NAME => Command\ShortUrl\ResolveUrlCommand::class,
|
||||
Command\ShortUrl\ListShortUrlsCommand::NAME => Command\ShortUrl\ListShortUrlsCommand::class,
|
||||
Command\ShortUrl\GetVisitsCommand::NAME => Command\ShortUrl\GetVisitsCommand::class,
|
||||
Command\ShortUrl\GeneratePreviewCommand::NAME => Command\ShortUrl\GeneratePreviewCommand::class,
|
||||
Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class,
|
||||
|
||||
Command\Visit\ProcessVisitsCommand::NAME => Command\Visit\ProcessVisitsCommand::class,
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command;
|
||||
use Shlinkio\Shlink\CLI\Factory\ApplicationFactory;
|
||||
namespace Shlinkio\Shlink\CLI;
|
||||
|
||||
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
|
||||
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
|
||||
use Shlinkio\Shlink\Core\Service;
|
||||
@@ -15,19 +15,24 @@ return [
|
||||
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
Application::class => ApplicationFactory::class,
|
||||
Application::class => Factory\ApplicationFactory::class,
|
||||
|
||||
Command\ShortUrl\GenerateShortUrlCommand::class => ConfigAbstractFactory::class,
|
||||
Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class,
|
||||
Command\ShortUrl\ListShortUrlsCommand::class => ConfigAbstractFactory::class,
|
||||
Command\ShortUrl\GetVisitsCommand::class => ConfigAbstractFactory::class,
|
||||
Command\ShortUrl\GeneratePreviewCommand::class => ConfigAbstractFactory::class,
|
||||
Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\Shortcode\GenerateShortcodeCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Shortcode\ResolveUrlCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Shortcode\ListShortcodesCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Shortcode\GetVisitsCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Shortcode\GeneratePreviewCommand::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,
|
||||
@@ -36,29 +41,41 @@ return [
|
||||
],
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
Command\Shortcode\GenerateShortcodeCommand::class => [
|
||||
Command\ShortUrl\GenerateShortUrlCommand::class => [
|
||||
Service\UrlShortener::class,
|
||||
'translator',
|
||||
'config.url_shortener.domain',
|
||||
],
|
||||
Command\Shortcode\ResolveUrlCommand::class => [Service\UrlShortener::class, 'translator'],
|
||||
Command\Shortcode\ListShortcodesCommand::class => [Service\ShortUrlService::class, 'translator'],
|
||||
Command\Shortcode\GetVisitsCommand::class => [Service\VisitsTracker::class, 'translator'],
|
||||
Command\Shortcode\GeneratePreviewCommand::class => [
|
||||
Command\ShortUrl\ResolveUrlCommand::class => [Service\UrlShortener::class, 'translator'],
|
||||
Command\ShortUrl\ListShortUrlsCommand::class => [
|
||||
Service\ShortUrlService::class,
|
||||
'translator',
|
||||
'config.url_shortener.domain',
|
||||
],
|
||||
Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class, 'translator'],
|
||||
Command\ShortUrl\GeneratePreviewCommand::class => [
|
||||
Service\ShortUrlService::class,
|
||||
PreviewGenerator::class,
|
||||
'translator',
|
||||
],
|
||||
Command\ShortUrl\DeleteShortUrlCommand::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,8 +1,8 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Shlink 1.0\n"
|
||||
"POT-Creation-Date: 2018-08-04 16:35+0200\n"
|
||||
"PO-Revision-Date: 2018-08-04 16:37+0200\n"
|
||||
"POT-Creation-Date: 2018-09-16 18:36+0200\n"
|
||||
"PO-Revision-Date: 2018-09-16 18:37+0200\n"
|
||||
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: es_ES\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 for the short URL to be deleted"
|
||||
msgstr "El código corto de la URL corta 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."
|
||||
@@ -100,9 +135,8 @@ msgstr " <info>¡Correcto!</info>"
|
||||
msgid "Error"
|
||||
msgstr "Error"
|
||||
|
||||
msgid "Generates a short code for provided URL and returns the short URL"
|
||||
msgstr ""
|
||||
"Genera un código corto para la URL proporcionada y devuelve la URL acortada"
|
||||
msgid "Generates a short URL for provided long URL and returns it"
|
||||
msgstr "Genera una URL corta para la URL larga proporcionada y la devuelve"
|
||||
|
||||
msgid "The long URL to parse"
|
||||
msgstr "La URL larga a procesar"
|
||||
@@ -183,12 +217,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 +252,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"
|
||||
@@ -230,8 +267,8 @@ msgstr "Número de visitas"
|
||||
msgid "Tags"
|
||||
msgstr "Etiquetas"
|
||||
|
||||
msgid "Short codes properly listed"
|
||||
msgstr "Códigos cortos correctamente listados"
|
||||
msgid "Short URLs properly listed"
|
||||
msgstr "URLs cortas listadas correctamente"
|
||||
|
||||
msgid "Continue with page"
|
||||
msgstr "Continuar con la página"
|
||||
@@ -253,10 +290,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."
|
||||
|
||||
@@ -327,6 +360,12 @@ 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"
|
||||
|
||||
|
||||
@@ -10,10 +10,11 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Zend\I18n\Translator\TranslatorInterface;
|
||||
use function sprintf;
|
||||
|
||||
class DisableKeyCommand extends Command
|
||||
{
|
||||
const NAME = 'api-key:disable';
|
||||
public const NAME = 'api-key:disable';
|
||||
|
||||
/**
|
||||
* @var ApiKeyServiceInterface
|
||||
@@ -31,14 +32,14 @@ class DisableKeyCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::NAME)
|
||||
->setDescription($this->translator->translate('Disables an API key.'))
|
||||
->addArgument('apiKey', InputArgument::REQUIRED, $this->translator->translate('The API key to disable'));
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$apiKey = $input->getArgument('apiKey');
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Api;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -10,10 +11,11 @@ use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Zend\I18n\Translator\TranslatorInterface;
|
||||
use function sprintf;
|
||||
|
||||
class GenerateKeyCommand extends Command
|
||||
{
|
||||
const NAME = 'api-key:generate';
|
||||
public const NAME = 'api-key:generate';
|
||||
|
||||
/**
|
||||
* @var ApiKeyServiceInterface
|
||||
@@ -31,7 +33,7 @@ class GenerateKeyCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::NAME)
|
||||
->setDescription($this->translator->translate('Generates a new valid API key.'))
|
||||
@@ -43,10 +45,10 @@ class GenerateKeyCommand extends Command
|
||||
);
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$expirationDate = $input->getOption('expirationDate');
|
||||
$apiKey = $this->apiKeyService->create(isset($expirationDate) ? new \DateTime($expirationDate) : null);
|
||||
$apiKey = $this->apiKeyService->create(isset($expirationDate) ? Chronos::parse($expirationDate) : null);
|
||||
|
||||
(new SymfonyStyle($input, $output))->success(
|
||||
sprintf($this->translator->translate('Generated API key: "%s"'), $apiKey)
|
||||
|
||||
@@ -11,6 +11,8 @@ use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Zend\I18n\Translator\TranslatorInterface;
|
||||
use function array_filter;
|
||||
use function sprintf;
|
||||
|
||||
class ListKeysCommand extends Command
|
||||
{
|
||||
@@ -36,7 +38,7 @@ class ListKeysCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::NAME)
|
||||
->setDescription($this->translator->translate('Lists all the available API keys.'))
|
||||
@@ -48,7 +50,7 @@ class ListKeysCommand extends Command
|
||||
);
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$enabledOnly = $input->getOption('enabledOnly');
|
||||
@@ -62,16 +64,16 @@ class ListKeysCommand extends Command
|
||||
$messagePattern = $this->determineMessagePattern($row);
|
||||
|
||||
// Set columns for this row
|
||||
$rowData = [\sprintf($messagePattern, $key)];
|
||||
$rowData = [sprintf($messagePattern, $key)];
|
||||
if (! $enabledOnly) {
|
||||
$rowData[] = \sprintf($messagePattern, $this->getEnabledSymbol($row));
|
||||
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($row));
|
||||
}
|
||||
$rowData[] = $expiration !== null ? $expiration->format(\DateTime::ATOM) : '-';
|
||||
$rowData[] = $expiration !== null ? $expiration->toAtomString() : '-';
|
||||
|
||||
$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'),
|
||||
|
||||
@@ -9,10 +9,12 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Zend\I18n\Translator\TranslatorInterface;
|
||||
use function sprintf;
|
||||
use function str_shuffle;
|
||||
|
||||
class GenerateCharsetCommand extends Command
|
||||
{
|
||||
const NAME = 'config:generate-charset';
|
||||
public const NAME = 'config:generate-charset';
|
||||
|
||||
/**
|
||||
* @var TranslatorInterface
|
||||
@@ -25,7 +27,7 @@ class GenerateCharsetCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::NAME)
|
||||
->setDescription(sprintf($this->translator->translate(
|
||||
@@ -34,11 +36,11 @@ class GenerateCharsetCommand extends Command
|
||||
), UrlShortener::DEFAULT_CHARS));
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$charSet = str_shuffle(UrlShortener::DEFAULT_CHARS);
|
||||
(new SymfonyStyle($input, $output))->success(
|
||||
\sprintf($this->translator->translate('Character set: "%s"'), $charSet)
|
||||
sprintf($this->translator->translate('Character set: "%s"'), $charSet)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,13 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Zend\I18n\Translator\TranslatorInterface;
|
||||
use function sprintf;
|
||||
|
||||
class GenerateSecretCommand extends Command
|
||||
{
|
||||
use StringUtilsTrait;
|
||||
|
||||
const NAME = 'config:generate-secret';
|
||||
public const NAME = 'config:generate-secret';
|
||||
|
||||
/**
|
||||
* @var TranslatorInterface
|
||||
@@ -27,7 +28,7 @@ class GenerateSecretCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::NAME)
|
||||
->setDescription($this->translator->translate(
|
||||
@@ -35,7 +36,7 @@ class GenerateSecretCommand extends Command
|
||||
));
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$secret = $this->generateRandomString(32);
|
||||
(new SymfonyStyle($input, $output))->success(
|
||||
|
||||
101
module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php
Normal file
101
module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
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 DeleteShortUrlCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:delete';
|
||||
private const ALIASES = ['short-code:delete'];
|
||||
|
||||
/**
|
||||
* @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 for the short URL 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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Shortcode;
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
|
||||
use Shlinkio\Shlink\Common\Service\PreviewGeneratorInterface;
|
||||
@@ -14,7 +14,8 @@ use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class GeneratePreviewCommand extends Command
|
||||
{
|
||||
const NAME = 'shortcode:process-previews';
|
||||
public const NAME = 'short-url:process-previews';
|
||||
private const ALIASES = ['shortcode:process-previews', 'short-code: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));
|
||||
@@ -1,11 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Shortcode;
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
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;
|
||||
@@ -15,9 +17,12 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Zend\Diactoros\Uri;
|
||||
use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class GenerateShortcodeCommand extends Command
|
||||
class GenerateShortUrlCommand extends Command
|
||||
{
|
||||
const NAME = 'shortcode:generate';
|
||||
use ShortUrlBuilderTrait;
|
||||
|
||||
public const NAME = 'short-url:generate';
|
||||
private const ALIASES = ['shortcode:generate', 'short-code:generate'];
|
||||
|
||||
/**
|
||||
* @var UrlShortenerInterface
|
||||
@@ -43,36 +48,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 URL for provided long URL and returns it')
|
||||
)
|
||||
->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 +95,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 +122,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,9 +144,9 @@ class GenerateShortcodeCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
private function getOptionalDate(InputInterface $input, string $fieldName)
|
||||
private function getOptionalDate(InputInterface $input, string $fieldName): ?Chronos
|
||||
{
|
||||
$since = $input->getOption($fieldName);
|
||||
return $since !== null ? new \DateTime($since) : null;
|
||||
return $since !== null ? Chronos::parse($since) : null;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Shortcode;
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
@@ -15,7 +16,8 @@ use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class GetVisitsCommand extends Command
|
||||
{
|
||||
const NAME = 'shortcode:visits';
|
||||
public const NAME = 'short-url:visits';
|
||||
private const ALIASES = ['shortcode:visits', 'short-code:visits'];
|
||||
|
||||
/**
|
||||
* @var VisitsTrackerInterface
|
||||
@@ -33,9 +35,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 +62,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 +78,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,26 +89,25 @@ 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)) {
|
||||
$value = new \DateTime($value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
return ! empty($value) ? Chronos::parse($value) : $value;
|
||||
}
|
||||
}
|
||||
155
module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php
Normal file
155
module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
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;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class ListShortUrlsCommand extends Command
|
||||
{
|
||||
use PaginatorUtilsTrait;
|
||||
|
||||
public const NAME = 'short-url:list';
|
||||
private const ALIASES = ['shortcode:list', 'short-code:list'];
|
||||
|
||||
/**
|
||||
* @var ShortUrlServiceInterface
|
||||
*/
|
||||
private $shortUrlService;
|
||||
/**
|
||||
* @var TranslatorInterface
|
||||
*/
|
||||
private $translator;
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private $domainConfig;
|
||||
|
||||
public function __construct(
|
||||
ShortUrlServiceInterface $shortUrlService,
|
||||
TranslatorInterface $translator,
|
||||
array $domainConfig
|
||||
) {
|
||||
$this->shortUrlService = $shortUrlService;
|
||||
$this->translator = $translator;
|
||||
parent::__construct();
|
||||
$this->domainConfig = $domainConfig;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$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')
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$page = (int) $input->getOption('page');
|
||||
$searchTerm = $input->getOption('searchTerm');
|
||||
$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));
|
||||
$page++;
|
||||
|
||||
$headers = [
|
||||
$this->translator->translate('Short code'),
|
||||
$this->translator->translate('Short URL'),
|
||||
$this->translator->translate('Long URL'),
|
||||
$this->translator->translate('Date created'),
|
||||
$this->translator->translate('Visits count'),
|
||||
];
|
||||
if ($showTags) {
|
||||
$headers[] = $this->translator->translate('Tags');
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($result as $row) {
|
||||
$shortUrl = $transformer->transform($row);
|
||||
if ($showTags) {
|
||||
$shortUrl['tags'] = \implode(', ', $shortUrl['tags']);
|
||||
} else {
|
||||
unset($shortUrl['tags']);
|
||||
}
|
||||
|
||||
unset($shortUrl['originalUrl']);
|
||||
$rows[] = \array_values($shortUrl);
|
||||
}
|
||||
$io->table($headers, $rows);
|
||||
|
||||
if ($this->isLastPage($result)) {
|
||||
$continue = false;
|
||||
$io->success($this->translator->translate('Short URLs properly listed'));
|
||||
} else {
|
||||
$continue = $io->confirm(
|
||||
\sprintf($this->translator->translate('Continue with page') . ' <options=bold>%s</>?', $page),
|
||||
false
|
||||
);
|
||||
}
|
||||
} while ($continue);
|
||||
}
|
||||
|
||||
private function processOrderBy(InputInterface $input)
|
||||
{
|
||||
$orderBy = $input->getOption('orderBy');
|
||||
if (empty($orderBy)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$orderBy = \explode(',', $orderBy);
|
||||
return \count($orderBy) === 1 ? $orderBy[0] : [$orderBy[0] => $orderBy[1]];
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Shortcode;
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
|
||||
@@ -15,7 +15,8 @@ use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class ResolveUrlCommand extends Command
|
||||
{
|
||||
const NAME = 'shortcode:parse';
|
||||
public const NAME = 'short-url:parse';
|
||||
private const ALIASES = ['shortcode:parse', 'short-code: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)
|
||||
@@ -1,144 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
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 Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class ListShortcodesCommand extends Command
|
||||
{
|
||||
use PaginatorUtilsTrait;
|
||||
|
||||
const NAME = 'shortcode:list';
|
||||
|
||||
/**
|
||||
* @var ShortUrlServiceInterface
|
||||
*/
|
||||
private $shortUrlService;
|
||||
/**
|
||||
* @var TranslatorInterface
|
||||
*/
|
||||
private $translator;
|
||||
|
||||
public function __construct(ShortUrlServiceInterface $shortUrlService, TranslatorInterface $translator)
|
||||
{
|
||||
$this->shortUrlService = $shortUrlService;
|
||||
$this->translator = $translator;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function configure()
|
||||
{
|
||||
$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')
|
||||
);
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$page = (int) $input->getOption('page');
|
||||
$searchTerm = $input->getOption('searchTerm');
|
||||
$tags = $input->getOption('tags');
|
||||
$tags = ! empty($tags) ? \explode(',', $tags) : [];
|
||||
$showTags = $input->getOption('showTags');
|
||||
|
||||
do {
|
||||
$result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input));
|
||||
$page++;
|
||||
|
||||
$headers = [
|
||||
$this->translator->translate('Short code'),
|
||||
$this->translator->translate('Original URL'),
|
||||
$this->translator->translate('Date created'),
|
||||
$this->translator->translate('Visits count'),
|
||||
];
|
||||
if ($showTags) {
|
||||
$headers[] = $this->translator->translate('Tags');
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($result as $row) {
|
||||
$shortUrl = $row->jsonSerialize();
|
||||
if ($showTags) {
|
||||
$shortUrl['tags'] = [];
|
||||
foreach ($row->getTags() as $tag) {
|
||||
$shortUrl['tags'][] = $tag->getName();
|
||||
}
|
||||
$shortUrl['tags'] = implode(', ', $shortUrl['tags']);
|
||||
} else {
|
||||
unset($shortUrl['tags']);
|
||||
}
|
||||
|
||||
$rows[] = \array_values($shortUrl);
|
||||
}
|
||||
$io->table($headers, $rows);
|
||||
|
||||
if ($this->isLastPage($result)) {
|
||||
$continue = false;
|
||||
$io->success($this->translator->translate('Short codes properly listed'));
|
||||
} else {
|
||||
$continue = $io->confirm(
|
||||
\sprintf($this->translator->translate('Continue with page') . ' <options=bold>%s</>?', $page),
|
||||
false
|
||||
);
|
||||
}
|
||||
} while ($continue);
|
||||
}
|
||||
|
||||
private function processOrderBy(InputInterface $input)
|
||||
{
|
||||
$orderBy = $input->getOption('orderBy');
|
||||
if (empty($orderBy)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$orderBy = explode(',', $orderBy);
|
||||
return count($orderBy) === 1 ? $orderBy[0] : [$orderBy[0] => $orderBy[1]];
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class CreateTagCommand extends Command
|
||||
{
|
||||
const NAME = 'tag:create';
|
||||
public const NAME = 'tag:create';
|
||||
|
||||
/**
|
||||
* @var TagServiceInterface
|
||||
@@ -31,7 +31,7 @@ class CreateTagCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
@@ -44,7 +44,7 @@ class CreateTagCommand extends Command
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$tagNames = $input->getOption('name');
|
||||
|
||||
@@ -13,7 +13,7 @@ use Zend\I18n\Translator\TranslatorInterface;
|
||||
|
||||
class DeleteTagsCommand extends Command
|
||||
{
|
||||
const NAME = 'tag:delete';
|
||||
public const NAME = 'tag:delete';
|
||||
|
||||
/**
|
||||
* @var TagServiceInterface
|
||||
@@ -31,7 +31,7 @@ class DeleteTagsCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
@@ -44,7 +44,7 @@ class DeleteTagsCommand extends Command
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$tagNames = $input->getOption('name');
|
||||
|
||||
@@ -10,10 +10,11 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Zend\I18n\Translator\TranslatorInterface;
|
||||
use function array_map;
|
||||
|
||||
class ListTagsCommand extends Command
|
||||
{
|
||||
const NAME = 'tag:list';
|
||||
public const NAME = 'tag:list';
|
||||
|
||||
/**
|
||||
* @var TagServiceInterface
|
||||
@@ -31,14 +32,14 @@ class ListTagsCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription($this->translator->translate('Lists existing tags.'));
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->table([$this->translator->translate('Name')], $this->getTagsRows());
|
||||
@@ -51,7 +52,7 @@ class ListTagsCommand extends Command
|
||||
return [[$this->translator->translate('No tags yet')]];
|
||||
}
|
||||
|
||||
return \array_map(function (Tag $tag) {
|
||||
return array_map(function (Tag $tag) {
|
||||
return [$tag->getName()];
|
||||
}, $tags);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,11 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Zend\I18n\Translator\TranslatorInterface;
|
||||
use function sprintf;
|
||||
|
||||
class RenameTagCommand extends Command
|
||||
{
|
||||
const NAME = 'tag:rename';
|
||||
public const NAME = 'tag:rename';
|
||||
|
||||
/**
|
||||
* @var TagServiceInterface
|
||||
@@ -32,7 +33,7 @@ class RenameTagCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
@@ -41,7 +42,7 @@ class RenameTagCommand extends Command
|
||||
->addArgument('newName', InputArgument::REQUIRED, $this->translator->translate('New name of the tag.'));
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$oldName = $input->getArgument('oldName');
|
||||
@@ -51,7 +52,7 @@ class RenameTagCommand extends Command
|
||||
$this->tagService->renameTag($oldName, $newName);
|
||||
$io->success($this->translator->translate('Tag properly renamed.'));
|
||||
} catch (EntityDoesNotExistException $e) {
|
||||
$io->error(\sprintf($this->translator->translate('A tag with name "%s" was not found'), $oldName));
|
||||
$io->error(sprintf($this->translator->translate('A tag with name "%s" was not found'), $oldName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -12,10 +13,11 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Zend\I18n\Translator\TranslatorInterface;
|
||||
use function sleep;
|
||||
use function sprintf;
|
||||
|
||||
class ProcessVisitsCommand extends Command
|
||||
{
|
||||
private const LOCALHOST = '127.0.0.1';
|
||||
public const NAME = 'visit:process';
|
||||
|
||||
/**
|
||||
@@ -42,7 +44,7 @@ class ProcessVisitsCommand extends Command
|
||||
parent::__construct(null);
|
||||
}
|
||||
|
||||
public function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::NAME)
|
||||
->setDescription(
|
||||
@@ -50,18 +52,26 @@ class ProcessVisitsCommand extends Command
|
||||
);
|
||||
}
|
||||
|
||||
public function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$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) {
|
||||
if (! $visit->hasRemoteAddr()) {
|
||||
$io->writeln(
|
||||
\sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address'))
|
||||
sprintf('<comment>%s</comment>', $this->translator->translate('Ignored visit with no IP address')),
|
||||
OutputInterface::VERBOSITY_VERBOSE
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$ipAddr = $visit->getRemoteAddr();
|
||||
$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'))
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -75,13 +85,13 @@ 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) {
|
||||
$io->writeln(
|
||||
\sprintf(' <error>%s</error>', $this->translator->translate('An error occurred while locating IP'))
|
||||
sprintf(' <error>%s</error>', $this->translator->translate('An error occurred while locating IP'))
|
||||
);
|
||||
if ($io->isVerbose()) {
|
||||
$this->getApplication()->renderException($e, $output);
|
||||
@@ -91,11 +101,11 @@ class ProcessVisitsCommand extends Command
|
||||
if ($count === $this->ipLocationResolver->getApiLimit()) {
|
||||
$count = 0;
|
||||
$seconds = $this->ipLocationResolver->getApiInterval();
|
||||
$io->note(\sprintf(
|
||||
$io->note(sprintf(
|
||||
$this->translator->translate('IP location resolver limit reached. Waiting %s seconds...'),
|
||||
$seconds
|
||||
));
|
||||
\sleep($seconds);
|
||||
sleep($seconds);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Install\Plugin;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
|
||||
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class ApplicationConfigCustomizer implements ConfigCustomizerInterface
|
||||
{
|
||||
use StringUtilsTrait;
|
||||
|
||||
/**
|
||||
* @param SymfonyStyle $io
|
||||
* @param CustomizableAppConfig $appConfig
|
||||
* @return void
|
||||
*/
|
||||
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig)
|
||||
{
|
||||
$io->title('APPLICATION');
|
||||
|
||||
if ($appConfig->hasApp() && $io->confirm('Do you want to keep imported application config?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$validator = function ($value) {
|
||||
return $value;
|
||||
};
|
||||
$appConfig->setApp([
|
||||
'SECRET' => $io->ask(
|
||||
'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one)',
|
||||
null,
|
||||
$validator
|
||||
) ?: $this->generateRandomString(32),
|
||||
'DISABLE_TRACK_PARAM' => $io->ask(
|
||||
'Provide a parameter name that you will be able to use to disable tracking on specific request to '
|
||||
. 'short URLs (leave empty and this feature won\'t be enabled)',
|
||||
null,
|
||||
$validator
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Install\Plugin;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
interface ConfigCustomizerInterface
|
||||
{
|
||||
/**
|
||||
* @param SymfonyStyle $io
|
||||
* @param CustomizableAppConfig $appConfig
|
||||
* @return void
|
||||
*/
|
||||
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Install\Plugin;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Filesystem\Exception\IOException;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
|
||||
class DatabaseConfigCustomizer implements ConfigCustomizerInterface
|
||||
{
|
||||
const DATABASE_DRIVERS = [
|
||||
'MySQL' => 'pdo_mysql',
|
||||
'PostgreSQL' => 'pdo_pgsql',
|
||||
'SQLite' => 'pdo_sqlite',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var Filesystem
|
||||
*/
|
||||
private $filesystem;
|
||||
|
||||
public function __construct(Filesystem $filesystem)
|
||||
{
|
||||
$this->filesystem = $filesystem;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SymfonyStyle $io
|
||||
* @param CustomizableAppConfig $appConfig
|
||||
* @return void
|
||||
* @throws IOException
|
||||
*/
|
||||
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig)
|
||||
{
|
||||
$io->title('DATABASE');
|
||||
|
||||
if ($appConfig->hasDatabase() && $io->confirm('Do you want to keep imported database config?')) {
|
||||
// If the user selected to keep DB config and is configured to use sqlite, copy DB file
|
||||
if ($appConfig->getDatabase()['DRIVER'] === self::DATABASE_DRIVERS['SQLite']) {
|
||||
try {
|
||||
$this->filesystem->copy(
|
||||
$appConfig->getImportedInstallationPath() . '/' . CustomizableAppConfig::SQLITE_DB_PATH,
|
||||
CustomizableAppConfig::SQLITE_DB_PATH
|
||||
);
|
||||
} catch (IOException $e) {
|
||||
$io->error('It wasn\'t possible to import the SQLite database');
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Select database type
|
||||
$params = [];
|
||||
$databases = \array_keys(self::DATABASE_DRIVERS);
|
||||
$dbType = $io->choice('Select database type', $databases, $databases[0]);
|
||||
$params['DRIVER'] = self::DATABASE_DRIVERS[$dbType];
|
||||
|
||||
// Ask for connection params if database is not SQLite
|
||||
if ($params['DRIVER'] !== self::DATABASE_DRIVERS['SQLite']) {
|
||||
$params['NAME'] = $io->ask('Database name', 'shlink');
|
||||
$params['USER'] = $io->ask('Database username');
|
||||
$params['PASSWORD'] = $io->ask('Database password');
|
||||
$params['HOST'] = $io->ask('Database host', 'localhost');
|
||||
$params['PORT'] = $io->ask('Database port', $this->getDefaultDbPort($params['DRIVER']));
|
||||
}
|
||||
|
||||
$appConfig->setDatabase($params);
|
||||
}
|
||||
|
||||
private function getDefaultDbPort(string $driver): string
|
||||
{
|
||||
return $driver === 'pdo_mysql' ? '3306' : '5432';
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Install\Plugin;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class LanguageConfigCustomizer implements ConfigCustomizerInterface
|
||||
{
|
||||
const SUPPORTED_LANGUAGES = ['en', 'es'];
|
||||
|
||||
/**
|
||||
* @param SymfonyStyle $io
|
||||
* @param CustomizableAppConfig $appConfig
|
||||
* @return void
|
||||
*/
|
||||
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig)
|
||||
{
|
||||
$io->title('LANGUAGE');
|
||||
|
||||
if ($appConfig->hasLanguage() && $io->confirm('Do you want to keep imported language?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$appConfig->setLanguage([
|
||||
'DEFAULT' => $this->chooseLanguage('Select default language for the application in general', $io),
|
||||
'CLI' => $this->chooseLanguage('Select default language for CLI executions', $io),
|
||||
]);
|
||||
}
|
||||
|
||||
private function chooseLanguage(string $message, SymfonyStyle $io): string
|
||||
{
|
||||
return $io->choice($message, self::SUPPORTED_LANGUAGES, self::SUPPORTED_LANGUAGES[0]);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Install\Plugin;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortener;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class UrlShortenerConfigCustomizer implements ConfigCustomizerInterface
|
||||
{
|
||||
/**
|
||||
* @param SymfonyStyle $io
|
||||
* @param CustomizableAppConfig $appConfig
|
||||
* @return void
|
||||
*/
|
||||
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig)
|
||||
{
|
||||
$io->title('URL SHORTENER');
|
||||
|
||||
if ($appConfig->hasUrlShortener() && $io->confirm('Do you want to keep imported URL shortener config?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ask for URL shortener params
|
||||
$appConfig->setUrlShortener([
|
||||
'SCHEMA' => $io->choice(
|
||||
'Select schema for generated short URLs',
|
||||
['http', 'https'],
|
||||
'http'
|
||||
),
|
||||
'HOSTNAME' => $io->ask('Hostname for generated URLs'),
|
||||
'CHARS' => $io->ask(
|
||||
'Character set for generated short codes (leave empty to autogenerate one)',
|
||||
null,
|
||||
function ($value) {
|
||||
return $value;
|
||||
}
|
||||
) ?: \str_shuffle(UrlShortener::DEFAULT_CHARS),
|
||||
'VALIDATE_URL' => $io->confirm('Do you want to validate long urls by 200 HTTP status code on response'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Model;
|
||||
|
||||
use Zend\Stdlib\ArraySerializableInterface;
|
||||
|
||||
final class CustomizableAppConfig implements ArraySerializableInterface
|
||||
{
|
||||
const SQLITE_DB_PATH = 'data/database.sqlite';
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private $database;
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private $urlShortener;
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private $language;
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private $app;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $importedInstallationPath;
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function getDatabase()
|
||||
{
|
||||
return $this->database;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $database
|
||||
* @return $this
|
||||
*/
|
||||
public function setDatabase(array $database)
|
||||
{
|
||||
$this->database = $database;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function hasDatabase()
|
||||
{
|
||||
return ! empty($this->database);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function getUrlShortener()
|
||||
{
|
||||
return $this->urlShortener;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $urlShortener
|
||||
* @return $this
|
||||
*/
|
||||
public function setUrlShortener(array $urlShortener)
|
||||
{
|
||||
$this->urlShortener = $urlShortener;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function hasUrlShortener()
|
||||
{
|
||||
return ! empty($this->urlShortener);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function getLanguage()
|
||||
{
|
||||
return $this->language;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $language
|
||||
* @return $this
|
||||
*/
|
||||
public function setLanguage(array $language)
|
||||
{
|
||||
$this->language = $language;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function hasLanguage()
|
||||
{
|
||||
return ! empty($this->language);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function getApp()
|
||||
{
|
||||
return $this->app;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $app
|
||||
* @return $this
|
||||
*/
|
||||
public function setApp(array $app)
|
||||
{
|
||||
$this->app = $app;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function hasApp()
|
||||
{
|
||||
return ! empty($this->app);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getImportedInstallationPath()
|
||||
{
|
||||
return $this->importedInstallationPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $importedInstallationPath
|
||||
* @return $this|self
|
||||
*/
|
||||
public function setImportedInstallationPath($importedInstallationPath)
|
||||
{
|
||||
$this->importedInstallationPath = $importedInstallationPath;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function hasImportedInstallationPath()
|
||||
{
|
||||
return $this->importedInstallationPath !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange internal values from provided array
|
||||
*
|
||||
* @param array $array
|
||||
* @return void
|
||||
*/
|
||||
public function exchangeArray(array $array)
|
||||
{
|
||||
if (isset($array['app_options'], $array['app_options']['secret_key'])) {
|
||||
$this->setApp([
|
||||
'SECRET' => $array['app_options']['secret_key'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (isset($array['entity_manager'], $array['entity_manager']['connection'])) {
|
||||
$this->deserializeDatabase($array['entity_manager']['connection']);
|
||||
}
|
||||
|
||||
if (isset($array['translator'], $array['translator']['locale'], $array['cli'], $array['cli']['locale'])) {
|
||||
$this->setLanguage([
|
||||
'DEFAULT' => $array['translator']['locale'],
|
||||
'CLI' => $array['cli']['locale'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (isset($array['url_shortener'])) {
|
||||
$urlShortener = $array['url_shortener'];
|
||||
$this->setUrlShortener([
|
||||
'SCHEMA' => $urlShortener['domain']['schema'],
|
||||
'HOSTNAME' => $urlShortener['domain']['hostname'],
|
||||
'CHARS' => $urlShortener['shortcode_chars'],
|
||||
'VALIDATE_URL' => $urlShortener['validate_url'] ?? true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function deserializeDatabase(array $conn)
|
||||
{
|
||||
if (! isset($conn['driver'])) {
|
||||
return;
|
||||
}
|
||||
$driver = $conn['driver'];
|
||||
|
||||
$params = ['DRIVER' => $driver];
|
||||
if ($driver !== 'pdo_sqlite') {
|
||||
$params['USER'] = $conn['user'];
|
||||
$params['PASSWORD'] = $conn['password'];
|
||||
$params['NAME'] = $conn['dbname'];
|
||||
$params['HOST'] = $conn['host'];
|
||||
$params['PORT'] = $conn['port'];
|
||||
}
|
||||
|
||||
$this->setDatabase($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array representation of the object
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getArrayCopy()
|
||||
{
|
||||
$config = [
|
||||
'app_options' => [
|
||||
'secret_key' => $this->app['SECRET'],
|
||||
'disable_track_param' => $this->app['DISABLE_TRACK_PARAM'] ?? null,
|
||||
],
|
||||
'entity_manager' => [
|
||||
'connection' => [
|
||||
'driver' => $this->database['DRIVER'],
|
||||
],
|
||||
],
|
||||
'translator' => [
|
||||
'locale' => $this->language['DEFAULT'],
|
||||
],
|
||||
'cli' => [
|
||||
'locale' => $this->language['CLI'],
|
||||
],
|
||||
'url_shortener' => [
|
||||
'domain' => [
|
||||
'schema' => $this->urlShortener['SCHEMA'],
|
||||
'hostname' => $this->urlShortener['HOSTNAME'],
|
||||
],
|
||||
'shortcode_chars' => $this->urlShortener['CHARS'],
|
||||
'validate_url' => $this->urlShortener['VALIDATE_URL'],
|
||||
],
|
||||
];
|
||||
|
||||
// Build dynamic database config based on selected driver
|
||||
if ($this->database['DRIVER'] === 'pdo_sqlite') {
|
||||
$config['entity_manager']['connection']['path'] = self::SQLITE_DB_PATH;
|
||||
} else {
|
||||
$config['entity_manager']['connection']['user'] = $this->database['USER'];
|
||||
$config['entity_manager']['connection']['password'] = $this->database['PASSWORD'];
|
||||
$config['entity_manager']['connection']['dbname'] = $this->database['NAME'];
|
||||
$config['entity_manager']['connection']['host'] = $this->database['HOST'];
|
||||
$config['entity_manager']['connection']['port'] = $this->database['PORT'];
|
||||
|
||||
if ($this->database['DRIVER'] === 'pdo_mysql') {
|
||||
$config['entity_manager']['connection']['driverOptions'] = [
|
||||
\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Api;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
@@ -50,8 +51,8 @@ class GenerateKeyCommandTest extends TestCase
|
||||
*/
|
||||
public function expirationDateIsDefinedIfProvided()
|
||||
{
|
||||
$this->apiKeyService->create(Argument::type(\DateTime::class))->shouldBeCalledTimes(1)
|
||||
->willReturn(new ApiKey());
|
||||
$this->apiKeyService->create(Argument::type(Chronos::class))->shouldBeCalledTimes(1)
|
||||
->willReturn(new ApiKey());
|
||||
$this->commandTester->execute([
|
||||
'command' => 'api-key:generate',
|
||||
'--expirationDate' => '2016-01-01',
|
||||
|
||||
120
module/CLI/test/Command/ShortUrl/DeleteShortCodeCommandTest.php
Normal file
120
module/CLI/test/Command/ShortUrl/DeleteShortCodeCommandTest.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand;
|
||||
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 DeleteShortUrlCommand($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);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Shortcode\GeneratePreviewCommand;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\GeneratePreviewCommand;
|
||||
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
|
||||
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
@@ -1,12 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Shortcode\GenerateShortcodeCommand;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\GenerateShortUrlCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortener;
|
||||
use Symfony\Component\Console\Application;
|
||||
@@ -27,7 +28,7 @@ class GenerateShortcodeCommandTest extends TestCase
|
||||
public function setUp()
|
||||
{
|
||||
$this->urlShortener = $this->prophesize(UrlShortener::class);
|
||||
$command = new GenerateShortcodeCommand($this->urlShortener->reveal(), Translator::factory([]), [
|
||||
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), Translator::factory([]), [
|
||||
'schema' => 'http',
|
||||
'hostname' => 'foo.com',
|
||||
]);
|
||||
@@ -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',
|
||||
@@ -1,14 +1,16 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Shortcode\GetVisitsCommand;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\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;
|
||||
@@ -57,7 +59,7 @@ class GetVisitsCommandTest extends TestCase
|
||||
$shortCode = 'abc123';
|
||||
$startDate = '2016-01-01';
|
||||
$endDate = '2016-02-01';
|
||||
$this->visitsTracker->info($shortCode, new DateRange(new \DateTime($startDate), new \DateTime($endDate)))
|
||||
$this->visitsTracker->info($shortCode, new DateRange(Chronos::parse($startDate), Chronos::parse($endDate)))
|
||||
->willReturn([])
|
||||
->shouldBeCalledTimes(1);
|
||||
|
||||
@@ -77,7 +79,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 +88,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'));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Shortcode\ListShortcodesCommand;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
@@ -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 ListShortUrlsCommand($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)))
|
||||
@@ -1,11 +1,12 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Shortcode\ResolveUrlCommand;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\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([
|
||||
@@ -11,8 +11,11 @@ use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Service\VisitService;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Zend\I18n\Translator\Translator;
|
||||
use function count;
|
||||
use function round;
|
||||
|
||||
class ProcessVisitsCommandTest extends TestCase
|
||||
{
|
||||
@@ -67,15 +70,15 @@ 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->assertContains('Processing IP 1.2.3.0', $output);
|
||||
$this->assertContains('Processing IP 4.3.2.0', $output);
|
||||
$this->assertContains('Processing IP 12.34.56.0', $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function localhostAddressIsIgnored()
|
||||
public function localhostAndEmptyAddressIsIgnored()
|
||||
{
|
||||
$visits = [
|
||||
(new Visit())->setRemoteAddr('1.2.3.4'),
|
||||
@@ -83,19 +86,22 @@ class ProcessVisitsCommandTest extends TestCase
|
||||
(new Visit())->setRemoteAddr('12.34.56.78'),
|
||||
(new Visit())->setRemoteAddr('127.0.0.1'),
|
||||
(new Visit())->setRemoteAddr('127.0.0.1'),
|
||||
(new Visit())->setRemoteAddr(''),
|
||||
(new Visit())->setRemoteAddr(null),
|
||||
];
|
||||
$this->visitService->getUnlocatedVisits()->willReturn($visits)
|
||||
->shouldBeCalledTimes(1);
|
||||
|
||||
$this->visitService->saveVisit(Argument::any())->shouldBeCalledTimes(count($visits) - 2);
|
||||
$this->visitService->saveVisit(Argument::any())->shouldBeCalledTimes(count($visits) - 4);
|
||||
$this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
|
||||
->shouldBeCalledTimes(count($visits) - 2);
|
||||
->shouldBeCalledTimes(count($visits) - 4);
|
||||
|
||||
$this->commandTester->execute([
|
||||
'command' => 'visit:process',
|
||||
]);
|
||||
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertTrue(strpos($output, 'Ignored localhost address') > 0);
|
||||
$this->assertContains('Ignored localhost address', $output);
|
||||
$this->assertContains('Ignored visit with no IP address', $output);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,8 +136,8 @@ class ProcessVisitsCommandTest extends TestCase
|
||||
'command' => 'visit:process',
|
||||
]);
|
||||
|
||||
$getApiLimit->shouldHaveBeenCalledTimes(\count($visits));
|
||||
$getApiInterval->shouldHaveBeenCalledTimes(\round(\count($visits) / $apiLimit));
|
||||
$resolveIpLocation->shouldHaveBeenCalledTimes(\count($visits));
|
||||
$getApiLimit->shouldHaveBeenCalledTimes(count($visits));
|
||||
$getApiInterval->shouldHaveBeenCalledTimes(round(count($visits) / $apiLimit));
|
||||
$resolveIpLocation->shouldHaveBeenCalledTimes(count($visits));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Install\Plugin;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Install\Plugin\ApplicationConfigCustomizer;
|
||||
use Shlinkio\Shlink\CLI\Model\CustomizableAppConfig;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class ApplicationConfigCustomizerTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @var ApplicationConfigCustomizer
|
||||
*/
|
||||
private $plugin;
|
||||
/**
|
||||
* @var ObjectProphecy
|
||||
*/
|
||||
private $io;
|
||||
|
||||
public function setUp()
|
||||
{
|
||||
$this->io = $this->prophesize(SymfonyStyle::class);
|
||||
$this->io->title(Argument::any())->willReturn(null);
|
||||
|
||||
$this->plugin = new ApplicationConfigCustomizer();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function configIsRequestedToTheUser()
|
||||
{
|
||||
$ask = $this->io->ask(Argument::cetera())->willReturn('the_secret');
|
||||
$config = new CustomizableAppConfig();
|
||||
|
||||
$this->plugin->process($this->io->reveal(), $config);
|
||||
|
||||
$this->assertTrue($config->hasApp());
|
||||
$this->assertEquals([
|
||||
'SECRET' => 'the_secret',
|
||||
'DISABLE_TRACK_PARAM' => 'the_secret',
|
||||
], $config->getApp());
|
||||
$ask->shouldHaveBeenCalledTimes(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function overwriteIsRequestedIfValueIsAlreadySet()
|
||||
{
|
||||
$ask = $this->io->ask(Argument::cetera())->willReturn('the_new_secret');
|
||||
$confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
|
||||
$config = new CustomizableAppConfig();
|
||||
$config->setApp([
|
||||
'SECRET' => 'foo',
|
||||
]);
|
||||
|
||||
$this->plugin->process($this->io->reveal(), $config);
|
||||
|
||||
$this->assertEquals([
|
||||
'SECRET' => 'the_new_secret',
|
||||
'DISABLE_TRACK_PARAM' => 'the_new_secret',
|
||||
], $config->getApp());
|
||||
$ask->shouldHaveBeenCalledTimes(2);
|
||||
$confirm->shouldHaveBeenCalledTimes(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function existingValueIsKeptIfRequested()
|
||||
{
|
||||
$confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
|
||||
|
||||
$config = new CustomizableAppConfig();
|
||||
$config->setApp([
|
||||
'SECRET' => 'foo',
|
||||
]);
|
||||
|
||||
$this->plugin->process($this->io->reveal(), $config);
|
||||
|
||||
$this->assertEquals([
|
||||
'SECRET' => 'foo',
|
||||
], $config->getApp());
|
||||
$confirm->shouldHaveBeenCalledTimes(1);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Common;
|
||||
|
||||
use Doctrine\Common\Cache\Cache;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use GuzzleHttp\Client as GuzzleClient;
|
||||
use Monolog\Logger;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Common\Factory;
|
||||
use Shlinkio\Shlink\Common\Image;
|
||||
use Shlinkio\Shlink\Common\Image\ImageBuilder;
|
||||
use Shlinkio\Shlink\Common\Middleware\LocaleMiddleware;
|
||||
use Shlinkio\Shlink\Common\Service;
|
||||
use Shlinkio\Shlink\Common\Template\Extension\TranslatorExtension;
|
||||
use RKA\Middleware\IpAddress;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Zend\I18n\Translator\Translator;
|
||||
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
@@ -21,14 +19,16 @@ return [
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
EntityManager::class => Factory\EntityManagerFactory::class,
|
||||
GuzzleHttp\Client::class => InvokableFactory::class,
|
||||
GuzzleClient::class => InvokableFactory::class,
|
||||
Cache::class => Factory\CacheFactory::class,
|
||||
'Logger_Shlink' => Factory\LoggerFactory::class,
|
||||
Filesystem::class => InvokableFactory::class,
|
||||
|
||||
Translator::class => Factory\TranslatorFactory::class,
|
||||
TranslatorExtension::class => ConfigAbstractFactory::class,
|
||||
LocaleMiddleware::class => ConfigAbstractFactory::class,
|
||||
Template\Extension\TranslatorExtension::class => ConfigAbstractFactory::class,
|
||||
|
||||
Middleware\LocaleMiddleware::class => ConfigAbstractFactory::class,
|
||||
IpAddress::class => Middleware\IpAddressMiddlewareFactory::class,
|
||||
|
||||
Image\ImageBuilder::class => Image\ImageBuilderFactory::class,
|
||||
|
||||
@@ -37,7 +37,7 @@ return [
|
||||
],
|
||||
'aliases' => [
|
||||
'em' => EntityManager::class,
|
||||
'httpClient' => GuzzleHttp\Client::class,
|
||||
'httpClient' => GuzzleClient::class,
|
||||
'translator' => Translator::class,
|
||||
'logger' => LoggerInterface::class,
|
||||
Logger::class => 'Logger_Shlink',
|
||||
@@ -49,11 +49,11 @@ return [
|
||||
],
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
TranslatorExtension::class => ['translator'],
|
||||
LocaleMiddleware::class => ['translator'],
|
||||
Template\Extension\TranslatorExtension::class => ['translator'],
|
||||
Middleware\LocaleMiddleware::class => ['translator'],
|
||||
Service\IpApiLocationResolver::class => ['httpClient'],
|
||||
Service\PreviewGenerator::class => [
|
||||
ImageBuilder::class,
|
||||
Image\ImageBuilder::class,
|
||||
Filesystem::class,
|
||||
'config.preview_generation.files_location',
|
||||
],
|
||||
|
||||
@@ -3,6 +3,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Common;
|
||||
|
||||
use const JSON_ERROR_NONE;
|
||||
use function array_key_exists;
|
||||
use function array_shift;
|
||||
use function getenv;
|
||||
use function in_array;
|
||||
use function is_array;
|
||||
use function json_last_error;
|
||||
use function json_last_error_msg;
|
||||
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
|
||||
@@ -36,3 +47,54 @@ function env($key, $default = null)
|
||||
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
function contains($needle, array $haystack): bool
|
||||
{
|
||||
return in_array($needle, $haystack, true);
|
||||
}
|
||||
|
||||
function json_decode(string $json, int $depth = 512, int $options = 0): array
|
||||
{
|
||||
$data = \json_decode($json, true, $depth, $options);
|
||||
if (JSON_ERROR_NONE !== json_last_error()) {
|
||||
throw new Exception\InvalidArgumentException('Error decoding JSON: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
function array_path_exists(array $path, array $array): bool
|
||||
{
|
||||
// As soon as a step is not found, the path does not exist
|
||||
$step = array_shift($path);
|
||||
if (! array_key_exists($step, $array)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Once the path is empty, we have found all the parts in the path
|
||||
if (empty($path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If current value is not an array, then we have not found the path
|
||||
$newArray = $array[$step];
|
||||
if (! is_array($newArray)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return array_path_exists($path, $newArray);
|
||||
}
|
||||
|
||||
function array_get_path(array $path, array $array)
|
||||
{
|
||||
do {
|
||||
$step = array_shift($path);
|
||||
if (! is_array($array) || ! array_key_exists($step, $array)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$array = $array[$step];
|
||||
} while (! empty($path));
|
||||
|
||||
return $array;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,9 +3,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Common\Exception;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -6,15 +6,17 @@ namespace Shlinkio\Shlink\Common\Factory;
|
||||
use Doctrine\Common\Cache;
|
||||
use Interop\Container\ContainerInterface;
|
||||
use Interop\Container\Exception\ContainerException;
|
||||
use Shlinkio\Shlink\Common;
|
||||
use Memcached;
|
||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
|
||||
use Zend\ServiceManager\Exception\ServiceNotFoundException;
|
||||
use Zend\ServiceManager\Factory\FactoryInterface;
|
||||
use function Shlinkio\Shlink\Common\contains;
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
|
||||
class CacheFactory implements FactoryInterface
|
||||
{
|
||||
const VALID_CACHE_ADAPTERS = [
|
||||
private const VALID_CACHE_ADAPTERS = [
|
||||
Cache\ApcuCache::class,
|
||||
Cache\ArrayCache::class,
|
||||
Cache\FilesystemCache::class,
|
||||
@@ -51,14 +53,12 @@ class CacheFactory implements FactoryInterface
|
||||
{
|
||||
// Try to get the adapter from config
|
||||
$config = $container->get('config');
|
||||
if (isset($config['cache'], $config['cache']['adapter'])
|
||||
&& in_array($config['cache']['adapter'], self::VALID_CACHE_ADAPTERS)
|
||||
) {
|
||||
if (isset($config['cache']['adapter']) && contains($config['cache']['adapter'], self::VALID_CACHE_ADAPTERS)) {
|
||||
return $this->resolveCacheAdapter($config['cache']);
|
||||
}
|
||||
|
||||
// If the adapter has not been set in config, create one based on environment
|
||||
return Common\env('APP_ENV', 'pro') === 'pro' ? new Cache\ApcuCache() : new Cache\ArrayCache();
|
||||
return env('APP_ENV', 'pro') === 'pro' ? new Cache\ApcuCache() : new Cache\ArrayCache();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,8 +75,8 @@ class CacheFactory implements FactoryInterface
|
||||
case Cache\PhpFileCache::class:
|
||||
return new $cacheConfig['adapter']($cacheConfig['options']['dir']);
|
||||
case Cache\MemcachedCache::class:
|
||||
$memcached = new \Memcached();
|
||||
$servers = isset($cacheConfig['options']['servers']) ? $cacheConfig['options']['servers'] : [];
|
||||
$memcached = new Memcached();
|
||||
$servers = $cacheConfig['options']['servers'] ?? [];
|
||||
|
||||
foreach ($servers as $server) {
|
||||
if (! isset($server['host'])) {
|
||||
|
||||
@@ -5,10 +5,13 @@ namespace Shlinkio\Shlink\Common\Factory;
|
||||
|
||||
use Doctrine\Common\Cache\ArrayCache;
|
||||
use Doctrine\Common\Cache\Cache;
|
||||
use Doctrine\DBAL\DBALException;
|
||||
use Doctrine\DBAL\Types\Type;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\ORMException;
|
||||
use Doctrine\ORM\Tools\Setup;
|
||||
use Interop\Container\ContainerInterface;
|
||||
use Interop\Container\Exception\ContainerException;
|
||||
use Shlinkio\Shlink\Common\Type\ChronosDateTimeType;
|
||||
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
|
||||
use Zend\ServiceManager\Exception\ServiceNotFoundException;
|
||||
use Zend\ServiceManager\Factory\FactoryInterface;
|
||||
@@ -16,15 +19,10 @@ use Zend\ServiceManager\Factory\FactoryInterface;
|
||||
class EntityManagerFactory 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
|
||||
* @throws ORMException
|
||||
* @throws DBALException
|
||||
*/
|
||||
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
|
||||
{
|
||||
@@ -35,6 +33,8 @@ class EntityManagerFactory implements FactoryInterface
|
||||
$connectionConfig = $emConfig['connection'] ?? [];
|
||||
$ormConfig = $emConfig['orm'] ?? [];
|
||||
|
||||
Type::addType(ChronosDateTimeType::CHRONOS_DATETIME, ChronosDateTimeType::class);
|
||||
|
||||
return EntityManager::create($connectionConfig, Setup::createAnnotationMetadataConfiguration(
|
||||
$ormConfig['entities_paths'] ?? [],
|
||||
$isDevMode,
|
||||
|
||||
28
module/Common/src/Middleware/IpAddressMiddlewareFactory.php
Normal file
28
module/Common/src/Middleware/IpAddressMiddlewareFactory.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Common\Middleware;
|
||||
|
||||
use Interop\Container\ContainerInterface;
|
||||
use RKA\Middleware\IpAddress;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
|
||||
use Zend\ServiceManager\Exception\ServiceNotFoundException;
|
||||
use Zend\ServiceManager\Factory\FactoryInterface;
|
||||
|
||||
class IpAddressMiddlewareFactory implements FactoryInterface
|
||||
{
|
||||
/**
|
||||
* Create an object
|
||||
*
|
||||
* @param ContainerInterface $container
|
||||
* @param string $requestedName
|
||||
* @param null|array $options
|
||||
* @throws ServiceNotFoundException if unable to resolve the service.
|
||||
* @throws ServiceNotCreatedException if an exception is raised when creating a service.
|
||||
*/
|
||||
public function __invoke(ContainerInterface $container, $requestedName, array $options = null): IpAddress
|
||||
{
|
||||
return new IpAddress(true, [], Visitor::REMOTE_ADDRESS_ATTR);
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,16 @@ 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): array
|
||||
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(),
|
||||
@@ -22,13 +23,18 @@ trait PaginatorUtilsTrait
|
||||
];
|
||||
}
|
||||
|
||||
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): bool
|
||||
private function isLastPage(Paginator $paginator): bool
|
||||
{
|
||||
return $paginator->getCurrentPageNumber() >= $paginator->count();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -5,7 +5,10 @@ namespace Shlinkio\Shlink\Common\Service;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Common\Exception\WrongIpException;
|
||||
use function Shlinkio\Shlink\Common\json_decode;
|
||||
use function sprintf;
|
||||
|
||||
class IpApiLocationResolver implements IpLocationResolverInterface
|
||||
{
|
||||
@@ -29,10 +32,12 @@ class IpApiLocationResolver implements IpLocationResolverInterface
|
||||
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));
|
||||
$response = $this->httpClient->get(sprintf(self::SERVICE_PATTERN, $ipAddress));
|
||||
return $this->mapFields(json_decode((string) $response->getBody()));
|
||||
} catch (GuzzleException $e) {
|
||||
throw WrongIpException::fromIpAddress($ipAddress, $e);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
throw new WrongIpException('IP-API returned invalid body while locating IP address', 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
52
module/Common/src/Type/ChronosDateTimeType.php
Normal file
52
module/Common/src/Type/ChronosDateTimeType.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Common\Type;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Doctrine\DBAL\Platforms\AbstractPlatform;
|
||||
use Doctrine\DBAL\Types\ConversionException;
|
||||
use Doctrine\DBAL\Types\DateTimeImmutableType;
|
||||
|
||||
class ChronosDateTimeType extends DateTimeImmutableType
|
||||
{
|
||||
public const CHRONOS_DATETIME = 'chronos_datetime';
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return self::CHRONOS_DATETIME;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ConversionException
|
||||
*/
|
||||
public function convertToPHPValue($value, AbstractPlatform $platform): ?Chronos
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dateTime = parent::convertToPHPValue($value, $platform);
|
||||
return Chronos::instance($dateTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ConversionException
|
||||
*/
|
||||
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format($platform->getDateTimeFormatString());
|
||||
}
|
||||
|
||||
throw ConversionException::conversionFailedInvalidType(
|
||||
$value,
|
||||
$this->getName(),
|
||||
['null', \DateTimeInterface::class]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,42 +3,35 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Common\Util;
|
||||
|
||||
class DateRange
|
||||
use Cake\Chronos\Chronos;
|
||||
|
||||
final class DateRange
|
||||
{
|
||||
/**
|
||||
* @var \DateTimeInterface|null
|
||||
* @var Chronos|null
|
||||
*/
|
||||
private $startDate;
|
||||
/**
|
||||
* @var \DateTimeInterface|null
|
||||
* @var Chronos|null
|
||||
*/
|
||||
private $endDate;
|
||||
|
||||
public function __construct(\DateTimeInterface $startDate = null, \DateTimeInterface $endDate = null)
|
||||
public function __construct(?Chronos $startDate = null, ?Chronos $endDate = null)
|
||||
{
|
||||
$this->startDate = $startDate;
|
||||
$this->endDate = $endDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTimeInterface|null
|
||||
*/
|
||||
public function getStartDate()
|
||||
public function getStartDate(): ?Chronos
|
||||
{
|
||||
return $this->startDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTimeInterface|null
|
||||
*/
|
||||
public function getEndDate()
|
||||
public function getEndDate(): ?Chronos
|
||||
{
|
||||
return $this->endDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return $this->startDate === null && $this->endDate === null;
|
||||
|
||||
78
module/Common/src/Util/IpAddress.php
Normal file
78
module/Common/src/Util/IpAddress.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Common\Util;
|
||||
|
||||
use Shlinkio\Shlink\Common\Exception\WrongIpException;
|
||||
use function count;
|
||||
use function explode;
|
||||
use function implode;
|
||||
use function trim;
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Common\Middleware;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionObject;
|
||||
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Zend\ServiceManager\ServiceManager;
|
||||
|
||||
class IpAddressMiddlewareFactoryTest extends TestCase
|
||||
{
|
||||
private $factory;
|
||||
|
||||
public function setUp()
|
||||
{
|
||||
$this->factory = new IpAddressMiddlewareFactory();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function returnedInstanceIsProperlyConfigured()
|
||||
{
|
||||
$instance = $this->factory->__invoke(new ServiceManager(), '');
|
||||
|
||||
$ref = new ReflectionObject($instance);
|
||||
$checkProxyHeaders = $ref->getProperty('checkProxyHeaders');
|
||||
$checkProxyHeaders->setAccessible(true);
|
||||
$trustedProxies = $ref->getProperty('trustedProxies');
|
||||
$trustedProxies->setAccessible(true);
|
||||
$attributeName = $ref->getProperty('attributeName');
|
||||
$attributeName->setAccessible(true);
|
||||
|
||||
$this->assertTrue($checkProxyHeaders->getValue($instance));
|
||||
$this->assertEquals([], $trustedProxies->getValue($instance));
|
||||
$this->assertEquals(Visitor::REMOTE_ADDRESS_ATTR, $attributeName->getValue($instance));
|
||||
}
|
||||
}
|
||||
94
module/Common/test/Type/ChronosDateTimeTypeTest.php
Normal file
94
module/Common/test/Type/ChronosDateTimeTypeTest.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Common\Type;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Doctrine\DBAL\Platforms\AbstractPlatform;
|
||||
use Doctrine\DBAL\Types\ConversionException;
|
||||
use Doctrine\DBAL\Types\Type;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Common\Type\ChronosDateTimeType;
|
||||
|
||||
class ChronosDateTimeTypeTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @var ChronosDateTimeType
|
||||
*/
|
||||
private $type;
|
||||
|
||||
public function setUp()
|
||||
{
|
||||
if (! Type::hasType(ChronosDateTimeType::CHRONOS_DATETIME)) {
|
||||
Type::addType(ChronosDateTimeType::CHRONOS_DATETIME, ChronosDateTimeType::class);
|
||||
}
|
||||
|
||||
$this->type = Type::getType(ChronosDateTimeType::CHRONOS_DATETIME);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function nameIsReturned()
|
||||
{
|
||||
$this->assertEquals(ChronosDateTimeType::CHRONOS_DATETIME, $this->type->getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideValues
|
||||
*/
|
||||
public function valueIsConverted(?string $value, ?string $expected)
|
||||
{
|
||||
$platform = $this->prophesize(AbstractPlatform::class);
|
||||
$platform->getDateTimeFormatString()->willReturn('Y-m-d H:i:s');
|
||||
|
||||
$result = $this->type->convertToPHPValue($value, $platform->reveal());
|
||||
|
||||
if ($expected === null) {
|
||||
$this->assertNull($result);
|
||||
} else {
|
||||
$this->assertInstanceOf($expected, $result);
|
||||
}
|
||||
}
|
||||
|
||||
public function provideValues(): array
|
||||
{
|
||||
return [
|
||||
[null, null],
|
||||
['now', Chronos::class],
|
||||
['2017-01-01', Chronos::class],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider providePhpValues
|
||||
*/
|
||||
public function valueIsConvertedToDatabaseFormat(?\DateTimeInterface $value, ?string $expected)
|
||||
{
|
||||
$platform = $this->prophesize(AbstractPlatform::class);
|
||||
$platform->getDateTimeFormatString()->willReturn('Y-m-d');
|
||||
|
||||
$this->assertEquals($expected, $this->type->convertToDatabaseValue($value, $platform->reveal()));
|
||||
}
|
||||
|
||||
public function providePhpValues(): array
|
||||
{
|
||||
return [
|
||||
[null, null],
|
||||
[new \DateTimeImmutable('2017-01-01'), '2017-01-01'],
|
||||
[Chronos::parse('2017-02-01'), '2017-02-01'],
|
||||
[new \DateTime('2017-03-01'), '2017-03-01'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function exceptionIsThrownIfInvalidValueIsParsedToDatabase()
|
||||
{
|
||||
$this->expectException(ConversionException::class);
|
||||
$this->type->convertToDatabaseValue(new \stdClass(), $this->prophesize(AbstractPlatform::class)->reveal());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Common\Util;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
|
||||
@@ -24,8 +25,8 @@ class DateRangeTest extends TestCase
|
||||
*/
|
||||
public function providedDatesAreSet()
|
||||
{
|
||||
$startDate = new \DateTime();
|
||||
$endDate = new \DateTime();
|
||||
$startDate = Chronos::now();
|
||||
$endDate = Chronos::now();
|
||||
$range = new DateRange($startDate, $endDate);
|
||||
$this->assertSame($startDate, $range->getStartDate());
|
||||
$this->assertSame($endDate, $range->getEndDate());
|
||||
|
||||
@@ -17,6 +17,7 @@ return [
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
Options\AppOptions::class => Options\AppOptionsFactory::class,
|
||||
Options\DeleteShortUrlsOptions::class => Options\DeleteShortUrlsOptionsFactory::class,
|
||||
NotFoundHandler::class => ConfigAbstractFactory::class,
|
||||
|
||||
// Services
|
||||
@@ -25,6 +26,7 @@ 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,
|
||||
@@ -42,7 +44,6 @@ return [
|
||||
Service\UrlShortener::class => [
|
||||
'httpClient',
|
||||
'em',
|
||||
Cache::class,
|
||||
'config.url_shortener.validate_url',
|
||||
'config.url_shortener.shortcode_chars',
|
||||
],
|
||||
@@ -50,6 +51,7 @@ 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 => [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use RKA\Middleware\IpAddress;
|
||||
use Shlinkio\Shlink\Core\Action;
|
||||
use Shlinkio\Shlink\Core\Middleware;
|
||||
|
||||
@@ -10,13 +11,19 @@ return [
|
||||
[
|
||||
'name' => 'long-url-redirect',
|
||||
'path' => '/{shortCode}',
|
||||
'middleware' => Action\RedirectAction::class,
|
||||
'middleware' => [
|
||||
IpAddress::class,
|
||||
Action\RedirectAction::class,
|
||||
],
|
||||
'allowed_methods' => ['GET'],
|
||||
],
|
||||
[
|
||||
'name' => 'pixel-tracking',
|
||||
'path' => '/{shortCode}/track',
|
||||
'middleware' => Action\PixelAction::class,
|
||||
'middleware' => [
|
||||
IpAddress::class,
|
||||
Action\PixelAction::class,
|
||||
],
|
||||
'allowed_methods' => ['GET'],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -12,6 +12,7 @@ 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\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
@@ -65,14 +66,14 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
|
||||
$disableTrackParam = $this->appOptions->getDisableTrackParam();
|
||||
|
||||
try {
|
||||
$longUrl = $this->urlShortener->shortCodeToUrl($shortCode);
|
||||
$url = $this->urlShortener->shortCodeToUrl($shortCode);
|
||||
|
||||
// Track visit to this short code
|
||||
if ($disableTrackParam === null || ! \array_key_exists($disableTrackParam, $query)) {
|
||||
$this->visitTracker->track($shortCode, $request);
|
||||
$this->visitTracker->track($shortCode, Visitor::fromRequest($request));
|
||||
}
|
||||
|
||||
return $this->createResp($longUrl);
|
||||
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);
|
||||
|
||||
@@ -60,7 +60,7 @@ class PreviewAction implements MiddlewareInterface
|
||||
|
||||
try {
|
||||
$url = $this->urlShortener->shortCodeToUrl($shortCode);
|
||||
$imagePath = $this->previewGenerator->generatePreview($url);
|
||||
$imagePath = $this->previewGenerator->generatePreview($url->getLongUrl());
|
||||
return $this->generateImageResponse($imagePath);
|
||||
} catch (InvalidShortCodeException | EntityDoesNotExistException | PreviewGenerationException $e) {
|
||||
$this->logger->warning('An error occurred while generating preview image.' . PHP_EOL . $e);
|
||||
|
||||
@@ -22,6 +22,10 @@ class QrCodeAction implements MiddlewareInterface
|
||||
{
|
||||
use ErrorResponseBuilderTrait;
|
||||
|
||||
private const DEFAULT_SIZE = 300;
|
||||
private const MIN_SIZE = 50;
|
||||
private const MAX_SIZE = 1000;
|
||||
|
||||
/**
|
||||
* @var RouterInterface
|
||||
*/
|
||||
@@ -82,11 +86,11 @@ class QrCodeAction implements MiddlewareInterface
|
||||
*/
|
||||
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,26 +3,28 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Entity;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
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(
|
||||
@@ -33,17 +35,17 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
|
||||
* unique=true
|
||||
* )
|
||||
*/
|
||||
protected $shortCode;
|
||||
private $shortCode;
|
||||
/**
|
||||
* @var \DateTime
|
||||
* @ORM\Column(name="date_created", type="datetime")
|
||||
* @var Chronos
|
||||
* @ORM\Column(name="date_created", type="chronos_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 +55,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)
|
||||
* @var Chronos|null
|
||||
* @ORM\Column(name="valid_since", type="chronos_datetime", nullable=true)
|
||||
*/
|
||||
protected $validSince;
|
||||
private $validSince;
|
||||
/**
|
||||
* @var \DateTime
|
||||
* @ORM\Column(name="valid_until", type="datetime", nullable=true)
|
||||
* @var Chronos|null
|
||||
* @ORM\Column(name="valid_until", type="chronos_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->dateCreated = new \DateTime();
|
||||
$this->visits = new ArrayCollection();
|
||||
$this->shortCode = '';
|
||||
$this->dateCreated = Chronos::now();
|
||||
$this->visits = new ArrayCollection();
|
||||
$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
|
||||
public function getDateCreated(): Chronos
|
||||
{
|
||||
return $this->dateCreated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateTime $dateCreated
|
||||
* @return $this
|
||||
*/
|
||||
public function setDateCreated(\DateTime $dateCreated)
|
||||
public function setDateCreated(Chronos $dateCreated): self
|
||||
{
|
||||
$this->dateCreated = $dateCreated;
|
||||
return $this;
|
||||
@@ -145,55 +139,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(): ?Chronos
|
||||
{
|
||||
return $this->validSince;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateTime|null $validSince
|
||||
* @return $this|self
|
||||
*/
|
||||
public function setValidSince($validSince): self
|
||||
public function setValidSince(?Chronos $validSince): self
|
||||
{
|
||||
$this->validSince = $validSince;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTime|null
|
||||
*/
|
||||
public function getValidUntil()
|
||||
public function getValidUntil(): ?Chronos
|
||||
{
|
||||
return $this->validUntil;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateTime|null $validUntil
|
||||
* @return $this|self
|
||||
*/
|
||||
public function setValidUntil($validUntil): self
|
||||
public function setValidUntil(?Chronos $validUntil): self
|
||||
{
|
||||
$this->validUntil = $validUntil;
|
||||
return $this;
|
||||
@@ -201,11 +176,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 +190,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 +205,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;
|
||||
}
|
||||
|
||||
@@ -3,15 +3,19 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Entity;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
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 +24,120 @@ 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)
|
||||
* @var Chronos
|
||||
* @ORM\Column(type="chronos_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();
|
||||
$this->date = Chronos::now();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getReferer(): string
|
||||
{
|
||||
return $this->referer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $referer
|
||||
* @return $this
|
||||
*/
|
||||
public function setReferer($referer): self
|
||||
public function setReferer(string $referer): self
|
||||
{
|
||||
$this->referer = $referer;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTime
|
||||
*/
|
||||
public function getDate(): \DateTime
|
||||
public function getDate(): Chronos
|
||||
{
|
||||
return $this->date;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \DateTime $date
|
||||
* @return $this
|
||||
*/
|
||||
public function setDate($date): self
|
||||
public function setDate(Chronos $date): self
|
||||
{
|
||||
$this->date = $date;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ShortUrl
|
||||
*/
|
||||
public function getShortUrl(): ShortUrl
|
||||
{
|
||||
return $this->shortUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ShortUrl $shortUrl
|
||||
* @return $this
|
||||
*/
|
||||
public function setShortUrl($shortUrl): self
|
||||
public function setShortUrl(ShortUrl $shortUrl): self
|
||||
{
|
||||
$this->shortUrl = $shortUrl;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getRemoteAddr(): string
|
||||
public function getRemoteAddr(): ?string
|
||||
{
|
||||
return $this->remoteAddr;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $remoteAddr
|
||||
* @return $this
|
||||
*/
|
||||
public function setRemoteAddr($remoteAddr): self
|
||||
public function setRemoteAddr(?string $remoteAddr): self
|
||||
{
|
||||
$this->remoteAddr = $remoteAddr;
|
||||
$this->remoteAddr = $this->obfuscateAddress($remoteAddr);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function hasRemoteAddr(): bool
|
||||
{
|
||||
return ! empty($this->remoteAddr);
|
||||
}
|
||||
|
||||
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): self
|
||||
public function setUserAgent(string $userAgent): self
|
||||
{
|
||||
$this->userAgent = $userAgent;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return VisitLocation
|
||||
*/
|
||||
public function getVisitLocation(): VisitLocation
|
||||
{
|
||||
return $this->visitLocation;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param VisitLocation $visitLocation
|
||||
* @return $this
|
||||
*/
|
||||
public function setVisitLocation($visitLocation): self
|
||||
public function setVisitLocation(VisitLocation $visitLocation): self
|
||||
{
|
||||
$this->visitLocation = $visitLocation;
|
||||
return $this;
|
||||
@@ -173,10 +154,12 @@ class Visit extends AbstractEntity implements \JsonSerializable
|
||||
{
|
||||
return [
|
||||
'referer' => $this->referer,
|
||||
'date' => isset($this->date) ? $this->date->format(\DateTime::ATOM) : null,
|
||||
'remoteAddr' => $this->remoteAddr,
|
||||
'date' => isset($this->date) ? $this->date->toAtomString() : null,
|
||||
'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)) {
|
||||
$this->setCountryCode($array['country_code']);
|
||||
if (\array_key_exists('country_code', $array)) {
|
||||
$this->setCountryCode((string) $array['country_code']);
|
||||
}
|
||||
if (array_key_exists('country_name', $array)) {
|
||||
$this->setCountryName($array['country_name']);
|
||||
if (\array_key_exists('country_name', $array)) {
|
||||
$this->setCountryName((string) $array['country_name']);
|
||||
}
|
||||
if (array_key_exists('region_name', $array)) {
|
||||
$this->setRegionName($array['region_name']);
|
||||
if (\array_key_exists('region_name', $array)) {
|
||||
$this->setRegionName((string) $array['region_name']);
|
||||
}
|
||||
if (array_key_exists('city', $array)) {
|
||||
$this->setCityName($array['city']);
|
||||
if (\array_key_exists('city', $array)) {
|
||||
$this->setCityName((string) $array['city']);
|
||||
}
|
||||
if (array_key_exists('latitude', $array)) {
|
||||
$this->setLatitude($array['latitude']);
|
||||
if (\array_key_exists('latitude', $array)) {
|
||||
$this->setLatitude((string) $array['latitude']);
|
||||
}
|
||||
if (array_key_exists('longitude', $array)) {
|
||||
$this->setLongitude($array['longitude']);
|
||||
if (\array_key_exists('longitude', $array)) {
|
||||
$this->setLongitude((string) $array['longitude']);
|
||||
}
|
||||
if (array_key_exists('time_zone', $array)) {
|
||||
$this->setTimezone($array['time_zone']);
|
||||
if (\array_key_exists('time_zone', $array)) {
|
||||
$this->setTimezone((string) $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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Psr\Http\Message\UriInterface;
|
||||
|
||||
final class CreateShortCodeData
|
||||
final class CreateShortUrlData
|
||||
{
|
||||
/**
|
||||
* @var UriInterface
|
||||
@@ -3,17 +3,19 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||
use function is_string;
|
||||
|
||||
final class ShortUrlMeta
|
||||
{
|
||||
/**
|
||||
* @var \DateTime|null
|
||||
* @var Chronos|null
|
||||
*/
|
||||
private $validSince;
|
||||
/**
|
||||
* @var \DateTime|null
|
||||
* @var Chronos|null
|
||||
*/
|
||||
private $validUntil;
|
||||
/**
|
||||
@@ -43,8 +45,8 @@ final class ShortUrlMeta
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|\DateTimeInterface|null $validSince
|
||||
* @param string|\DateTimeInterface|null $validUntil
|
||||
* @param string|Chronos|null $validSince
|
||||
* @param string|Chronos|null $validUntil
|
||||
* @param string|null $customSlug
|
||||
* @param int|null $maxVisits
|
||||
* @return ShortUrlMeta
|
||||
@@ -86,26 +88,23 @@ final class ShortUrlMeta
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|\DateTime|null $date
|
||||
* @return \DateTime|null
|
||||
* @param string|Chronos|null $date
|
||||
* @return Chronos|null
|
||||
*/
|
||||
private function parseDateField($date): ?\DateTime
|
||||
private function parseDateField($date): ?Chronos
|
||||
{
|
||||
if ($date === null || $date instanceof \DateTime) {
|
||||
if ($date === null || $date instanceof Chronos) {
|
||||
return $date;
|
||||
}
|
||||
|
||||
if (\is_string($date)) {
|
||||
return new \DateTime($date);
|
||||
if (is_string($date)) {
|
||||
return Chronos::parse($date);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTime|null
|
||||
*/
|
||||
public function getValidSince(): ?\DateTime
|
||||
public function getValidSince(): ?Chronos
|
||||
{
|
||||
return $this->validSince;
|
||||
}
|
||||
@@ -115,10 +114,7 @@ final class ShortUrlMeta
|
||||
return $this->validSince !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTime|null
|
||||
*/
|
||||
public function getValidUntil(): ?\DateTime
|
||||
public function getValidUntil(): ?Chronos
|
||||
{
|
||||
return $this->validUntil;
|
||||
}
|
||||
|
||||
60
module/Core/src/Model/Visitor.php
Normal file
60
module/Core/src/Model/Visitor.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class Visitor
|
||||
{
|
||||
public const REMOTE_ADDRESS_ATTR = 'remote_address';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $userAgent;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $referer;
|
||||
/**
|
||||
* @var string|null
|
||||
*/
|
||||
private $remoteAddress;
|
||||
|
||||
public function __construct(string $userAgent, string $referer, ?string $remoteAddress)
|
||||
{
|
||||
$this->userAgent = $userAgent;
|
||||
$this->referer = $referer;
|
||||
$this->remoteAddress = $remoteAddress;
|
||||
}
|
||||
|
||||
public static function fromRequest(ServerRequestInterface $request): self
|
||||
{
|
||||
return new self(
|
||||
$request->getHeaderLine('User-Agent'),
|
||||
$request->getHeaderLine('Referer'),
|
||||
$request->getAttribute(self::REMOTE_ADDRESS_ATTR)
|
||||
);
|
||||
}
|
||||
|
||||
public static function emptyInstance(): self
|
||||
{
|
||||
return new self('', '', null);
|
||||
}
|
||||
|
||||
public function getUserAgent(): string
|
||||
{
|
||||
return $this->userAgent;
|
||||
}
|
||||
|
||||
public function getReferer(): string
|
||||
{
|
||||
return $this->referer;
|
||||
}
|
||||
|
||||
public function getRemoteAddress(): ?string
|
||||
{
|
||||
return $this->remoteAddress;
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? []);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Repository;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use function array_column;
|
||||
use function array_key_exists;
|
||||
use function is_array;
|
||||
use function key;
|
||||
use function Shlinkio\Shlink\Common\contains;
|
||||
|
||||
class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface
|
||||
{
|
||||
@@ -47,20 +53,27 @@ 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)) {
|
||||
if (contains($fieldName, ['visits', 'visitsCount', 'visitCount'])) {
|
||||
$qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
|
||||
->leftJoin('s.visits', 'v')
|
||||
->groupBy('s')
|
||||
->orderBy('totalVisits', $order);
|
||||
|
||||
return \array_column($qb->getQuery()->getResult(), 0);
|
||||
return array_column($qb->getQuery()->getResult(), 0);
|
||||
}
|
||||
|
||||
if (\in_array($fieldName, ['originalUrl', 'shortCode', 'dateCreated'], true)) {
|
||||
$qb->orderBy('s.' . $fieldName, $order);
|
||||
if (array_key_exists($fieldName, $fieldNameMap)) {
|
||||
$qb->orderBy('s.' . $fieldNameMap[$fieldName], $order);
|
||||
}
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
@@ -124,7 +137,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
*/
|
||||
public function findOneByShortCode(string $shortCode): ?ShortUrl
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$now = Chronos::now();
|
||||
|
||||
$qb = $this->createQueryBuilder('s');
|
||||
$qb->where($qb->expr()->eq('s.shortCode', ':shortCode'))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user