Compare commits

...

41 Commits

Author SHA1 Message Date
Alejandro Celaya
92858aebd6 Merge pull request #204 from acelaya/master
Updated changelog with v1.12
2018-09-15 18:52:38 +02:00
Alejandro Celaya
1910724a98 Updated changelog with v1.12 2018-09-15 18:47:10 +02:00
Alejandro Celaya
ff8441fa95 Merge pull request #199 from acelaya/feature/delete-short-codes
Delete short URLs
2018-09-15 18:20:06 +02:00
Alejandro Celaya
9d8fb055b1 Updated translations 2018-09-15 18:03:54 +02:00
Alejandro Celaya
9651b3d692 Created command to delete short URLs 2018-09-15 17:57:12 +02:00
Alejandro Celaya
9d10c8627a Created migration fixing cascade delete on visits table 2018-09-15 13:20:13 +02:00
Alejandro Celaya
929d7670cb Documented delete short URLs endpoint in swagger 2018-09-15 13:07:52 +02:00
Alejandro Celaya
5714a8f884 Created action to delete short URLs 2018-09-15 12:56:17 +02:00
Alejandro Celaya
159529937d Created specific service to delete short URLs 2018-09-15 11:54:58 +02:00
Alejandro Celaya
394d9ff4d2 Defined config and implementation to delete short URLs 2018-09-15 11:01:28 +02:00
Alejandro Celaya
07165f344f Normalized entities adding missing type hints and removing superfluous comments 2018-09-15 10:03:42 +02:00
Alejandro Celaya
4f2146dd9c Replaced commands namespace shortcode by short-code, using the old one as an alias 2018-09-14 19:38:52 +02:00
Alejandro Celaya
5b9784cd9e Merge pull request #195 from acelaya/feature/gdpr
Fix GDPR compliance
2018-09-14 19:30:14 +02:00
Alejandro Celaya
9d9b61cf14 Fixed message displayed during installation process 2018-09-14 19:18:10 +02:00
Alejandro Celaya
9d7db96e4b Added country name to console comand that lists visits 2018-09-14 19:12:23 +02:00
Alejandro Celaya
3d0bca2781 Finally dropped the hashing of the address 2018-09-14 19:04:40 +02:00
Alejandro Celaya
ffb54c4f7a Fixed typehint 2018-09-13 23:52:22 +02:00
Alejandro Celaya
a01031303f Created migration which parses existing IP addresses, generating hashes and droping already used IPs 2018-09-13 23:50:09 +02:00
Alejandro Celaya
7808f6d182 Added remoteAddrHash field to Visit entity 2018-09-13 22:46:28 +02:00
Alejandro Celaya
a0c3b9412f Updated system to obfuscate IP addresses before persisting them 2018-09-13 22:36:28 +02:00
Alejandro Celaya
c32e2053c3 Merge pull request #194 from acelaya/feature/create-url-resp
Updated short URL creation responses to include more information
2018-09-12 20:49:03 +02:00
Alejandro Celaya
a33151248d Removed duplicated code by using a utils trait 2018-09-12 20:40:32 +02:00
Alejandro Celaya
038ba3b006 Fixed wrong typehint 2018-09-12 20:34:36 +02:00
Alejandro Celaya
f3c92f4110 Updated short URL creation responses to include more information 2018-09-12 20:32:58 +02:00
Alejandro Celaya
17779dbbc6 Merge pull request #192 from acelaya/feature/non-unique-long-urls
Ensured same long URL can be used multiple times for different short URLs
2018-09-11 20:44:40 +02:00
Alejandro Celaya
c2dd5b8c47 Ensured same long URL can be used multiple times for different short URLs 2018-09-11 19:44:33 +02:00
Alejandro Celaya
fcb9121e5a Merge pull request #191 from acelaya/feature/how-to-release
Added release instructions to readme file
2018-09-11 19:28:41 +02:00
Alejandro Celaya
0af1004860 Ordered gitignore placing all composer-related files together 2018-09-11 19:25:13 +02:00
Alejandro Celaya
917f668cf3 Added release instructions to readme file, and improved how to build instructions 2018-09-11 19:17:29 +02:00
Alejandro Celaya
436499b7c4 Merge pull request #186 from robwent/robots-txt
Adds robots.txt and disallows all
2018-09-09 18:50:53 +02:00
Robert Went
66af9866f0 Adds robots.txt and disallows all 2018-09-09 17:41:57 +01:00
Alejandro Celaya
3703dedad9 Merge pull request #184 from acelaya/master
Improved documentation in README file
2018-09-09 18:28:35 +02:00
Alejandro Celaya
37502151ef Updated date in license 2018-09-09 18:23:36 +02:00
Alejandro Celaya
3816a10de3 Improved documentation in README file 2018-09-09 18:20:12 +02:00
Alejandro Celaya
bdda6067ab Replaced hardcoded donate link by short URL 2018-09-02 08:05:39 +02:00
Alejandro Celaya
0f62af241f Updated badges and added donate badge 2018-08-30 19:43:11 +02:00
Alejandro Celaya
987919e87a Merge pull request #179 from acelaya/feature/1.11.0
v1.11.0
2018-08-13 16:40:37 +02:00
Alejandro Celaya
0c03a4b7ff Added v1.11.0 to changelog 2018-08-13 16:29:40 +02:00
Alejandro Celaya
5d6d13c95f Updated API docs including new response structure 2018-08-13 16:17:43 +02:00
Alejandro Celaya
563021bdc1 Updated resolve short url action to return all data for that short url 2018-08-11 10:40:44 +02:00
Alejandro Celaya
2d6d35a398 Added shortUrl field to serialized ShortUrl objects, both from CLI and REST 2018-08-10 23:14:45 +02:00
92 changed files with 2002 additions and 705 deletions

1
.gitignore vendored
View File

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

View File

@@ -1,5 +1,99 @@
# CHANGELOG
## 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

View File

@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2017 Alejandro Celaya
Copyright (c) 2018 Alejandro Celaya
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

150
README.md
View File

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

View File

@@ -10,6 +10,7 @@ fi
version=$1
builtcontent=$(readlink -f "../shlink_${version}_dist")
projectdir=$(pwd)
[ -f ./composer.phar ] && composerBin='./composer.phar' || composerBin='composer'
# Copy project content to temp dir
echo 'Copying project files...'
@@ -20,10 +21,11 @@ cp -R "${projectdir}"/* "${builtcontent}"
cd "${builtcontent}"
# Install dependencies
echo "Installing dependencies with $composerBin..."
rm -rf vendor
rm -f composer.lock
composer self-update
composer install --no-dev --optimize-autoloader --no-progress --no-interaction
$composerBin self-update
$composerBin install --no-dev --optimize-autoloader --no-progress --no-interaction
# Delete development files
echo 'Deleting dev files...'
@@ -50,6 +52,9 @@ rm -rf config/autoload/{{,*.}local.php{,.dist},.gitignore}
sed -i "s/%SHLINK_VERSION%/${version}/g" config/autoload/app_options.global.php
# Compressing file
echo 'Compressing files...'
rm -f "${projectdir}"/build/shlink_${version}_dist.zip
zip -ry "${projectdir}"/build/shlink_${version}_dist.zip "../shlink_${version}_dist"
rm -rf "${builtcontent}"
echo 'Done!'

View File

@@ -13,6 +13,8 @@
],
"require": {
"php": "^7.1",
"ext-json": "*",
"ext-pdo": "*",
"acelaya/ze-content-based-error-handler": "^2.2",
"cocur/slugify": "^3.0",
"doctrine/cache": "^1.6",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,7 +44,7 @@
"schema": {
"type": "string",
"enum": [
"originalUrl",
"longUrl",
"shortCode",
"dateCreated",
"visits"
@@ -89,7 +89,8 @@
"data": [
{
"shortCode": "12C18",
"originalUrl": "https://store.steampowered.com",
"shortUrl": "https://doma.in/12C18",
"longUrl": "https://store.steampowered.com",
"dateCreated": "2016-08-21T20:34:16+02:00",
"visitsCount": 328,
"tags": [
@@ -99,7 +100,8 @@
},
{
"shortCode": "12Kb3",
"originalUrl": "https://shlink.io",
"shortUrl": "https://doma.in/12Kb3",
"longUrl": "https://shlink.io",
"dateCreated": "2016-05-01T20:34:16+02:00",
"visitsCount": 1029,
"tags": [
@@ -108,7 +110,8 @@
},
{
"shortCode": "123bA",
"originalUrl": "https://www.google.com",
"shortUrl": "https://doma.in/123bA",
"longUrl": "https://www.google.com",
"dateCreated": "2015-10-01T20:34:16+02:00",
"visitsCount": 25,
"tags": []

View File

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

View File

@@ -70,20 +70,28 @@
{
"referer": "https://twitter.com",
"date": "2015-08-20T05:05:03+04:00",
"remoteAddr": "10.20.30.40",
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0"
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
"visitLocation": null
},
{
"referer": "https://t.co",
"date": "2015-08-20T05:05:03+04:00",
"remoteAddr": "11.22.33.44",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36"
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
"visitLocation": {
"cityName": "Cupertino",
"countryCode": "US",
"countryName": "United States",
"latitude": "37.3042",
"longitude": "-122.0946",
"regionName": "California",
"timezone": "America/Los_Angeles"
}
},
{
"referer": null,
"date": "2015-08-20T05:05:03+04:00",
"remoteAddr": "110.220.5.6",
"userAgent": "some_web_crawler/1.4"
"userAgent": "some_web_crawler/1.4",
"visitLocation": null
}
]
}

View File

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

View File

@@ -22,12 +22,17 @@ return [
Command\Shortcode\ListShortcodesCommand::class => ConfigAbstractFactory::class,
Command\Shortcode\GetVisitsCommand::class => ConfigAbstractFactory::class,
Command\Shortcode\GeneratePreviewCommand::class => ConfigAbstractFactory::class,
Command\Shortcode\DeleteShortCodeCommand::class => ConfigAbstractFactory::class,
Command\Visit\ProcessVisitsCommand::class => ConfigAbstractFactory::class,
Command\Config\GenerateCharsetCommand::class => ConfigAbstractFactory::class,
Command\Config\GenerateSecretCommand::class => ConfigAbstractFactory::class,
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class,
Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class,
Command\Tag\CreateTagCommand::class => ConfigAbstractFactory::class,
Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class,
@@ -42,23 +47,35 @@ return [
'config.url_shortener.domain',
],
Command\Shortcode\ResolveUrlCommand::class => [Service\UrlShortener::class, 'translator'],
Command\Shortcode\ListShortcodesCommand::class => [Service\ShortUrlService::class, 'translator'],
Command\Shortcode\ListShortcodesCommand::class => [
Service\ShortUrlService::class,
'translator',
'config.url_shortener.domain',
],
Command\Shortcode\GetVisitsCommand::class => [Service\VisitsTracker::class, 'translator'],
Command\Shortcode\GeneratePreviewCommand::class => [
Service\ShortUrlService::class,
PreviewGenerator::class,
'translator',
],
Command\Shortcode\DeleteShortCodeCommand::class => [
Service\ShortUrl\DeleteShortUrlService::class,
'translator',
],
Command\Visit\ProcessVisitsCommand::class => [
Service\VisitService::class,
IpApiLocationResolver::class,
'translator',
],
Command\Config\GenerateCharsetCommand::class => ['translator'],
Command\Config\GenerateSecretCommand::class => ['translator'],
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, 'translator'],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class, 'translator'],
Command\Api\ListKeysCommand::class => [ApiKeyService::class, 'translator'],
Command\Tag\ListTagsCommand::class => [Service\Tag\TagService::class, Translator::class],
Command\Tag\CreateTagCommand::class => [Service\Tag\TagService::class, Translator::class],
Command\Tag\RenameTagCommand::class => [Service\Tag\TagService::class, Translator::class],

Binary file not shown.

View File

@@ -1,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-15 17:57+0200\n"
"PO-Revision-Date: 2018-09-15 18:02+0200\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
@@ -80,6 +80,41 @@ msgstr ""
msgid "Secret key: \"%s\""
msgstr "Clave secreta: \"%s\""
msgid "Deletes a short URL"
msgstr "Elimina una URL"
msgid "The short code to be deleted"
msgstr "El código corto a eliminar"
msgid ""
"Ignores the safety visits threshold check, which could make short URLs with "
"many visits to be accidentally deleted"
msgstr ""
"Ignora el límite de seguridad de visitas, pudiendo resultar en el borrado "
"accidental de URLs con muchas visitas"
#, php-format
msgid "Provided short code \"%s\" could not be found."
msgstr "El código corto proporcionado \"%s\" no ha podido ser encontrado."
#, php-format
msgid ""
"It was not possible to delete the short URL with short code \"%s\" because "
"it has more than %s visits."
msgstr ""
"No se pudo eliminar la URL acortada con código corto \"%s\" porque tiene más "
"de %s visitas."
msgid "Do you want to delete it anyway?"
msgstr "¿Aún así quieres eliminarla?"
msgid "Short URL was not deleted."
msgstr "La URL corta no ha sido eliminada."
#, php-format
msgid "Short URL with short code \"%s\" successfully deleted."
msgstr "La URL acortada con el código corto \"%s\" eliminada correctamente."
msgid ""
"Processes and generates the previews for every URL, improving performance "
"for later web requests."
@@ -183,12 +218,12 @@ msgstr "Origen"
msgid "Date"
msgstr "Fecha"
msgid "Remote Address"
msgstr "Dirección remota"
msgid "User agent"
msgstr "Agente de usuario"
msgid "Country"
msgstr "País"
msgid "List all short URLs"
msgstr "Listar todas las URLs cortas"
@@ -218,8 +253,11 @@ msgstr "Si se desea mostrar las etiquetas o no"
msgid "Short code"
msgstr "Código corto"
msgid "Original URL"
msgstr "URL original"
msgid "Short URL"
msgstr "URL corta"
msgid "Long URL"
msgstr "URL larga"
msgid "Date created"
msgstr "Fecha de creación"
@@ -253,10 +291,6 @@ msgstr "URL larga:"
msgid "Provided short code \"%s\" has an invalid format."
msgstr "El código corto proporcionado \"%s\" tiene un formato inválido."
#, php-format
msgid "Provided short code \"%s\" could not be found."
msgstr "El código corto proporcionado \"%s\" no ha podido ser encontrado."
msgid "Creates one or more tags."
msgstr "Crea una o más etiquetas."
@@ -327,6 +361,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"

View File

@@ -235,7 +235,11 @@ class InstallCommand extends Command
$this->phpBinary = $this->phpFinder->find(false) ?: 'php';
}
$this->io->writeln('Running "' . sprintf('%s %s', $this->phpBinary, $command) . '"');
$this->io->write(
' <options=bold>[Running "' . sprintf('%s %s', $this->phpBinary, $command) . '"]</> ',
false,
OutputInterface::VERBOSITY_VERBOSE
);
$process = $this->processHelper->run($output, sprintf('%s %s', $this->phpBinary, $command));
if ($process->isSuccessful()) {
$this->io->writeln(' <info>Success!</info>');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\Service\IpLocationResolverInterface;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Service\VisitServiceInterface;
use Symfony\Component\Console\Command\Command;
@@ -15,7 +16,6 @@ use Zend\I18n\Translator\TranslatorInterface;
class ProcessVisitsCommand extends Command
{
private const LOCALHOST = '127.0.0.1';
public const NAME = 'visit:process';
/**
@@ -59,7 +59,7 @@ class ProcessVisitsCommand extends Command
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 ($ipAddr === IpAddress::LOCALHOST) {
$io->writeln(
\sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address'))
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -67,9 +67,9 @@ class ProcessVisitsCommandTest extends TestCase
'command' => 'visit:process',
]);
$output = $this->commandTester->getDisplay();
$this->assertTrue(strpos($output, 'Processing IP 1.2.3.4') === 0);
$this->assertTrue(strpos($output, 'Processing IP 4.3.2.1') > 0);
$this->assertTrue(strpos($output, 'Processing IP 12.34.56.78') > 0);
$this->assertEquals(0, \strpos($output, 'Processing IP 1.2.3.0'));
$this->assertGreaterThan(0, \strpos($output, 'Processing IP 4.3.2.0'));
$this->assertGreaterThan(0, \strpos($output, 'Processing IP 12.34.56.0'));
}
/**
@@ -87,15 +87,15 @@ class ProcessVisitsCommandTest extends TestCase
$this->visitService->getUnlocatedVisits()->willReturn($visits)
->shouldBeCalledTimes(1);
$this->visitService->saveVisit(Argument::any())->shouldBeCalledTimes(count($visits) - 2);
$this->visitService->saveVisit(Argument::any())->shouldBeCalledTimes(\count($visits) - 2);
$this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
->shouldBeCalledTimes(count($visits) - 2);
->shouldBeCalledTimes(\count($visits) - 2);
$this->commandTester->execute([
'command' => 'visit:process',
]);
$output = $this->commandTester->getDisplay();
$this->assertTrue(strpos($output, 'Ignored localhost address') > 0);
$this->assertGreaterThan(0, \strpos($output, 'Ignored localhost address'));
}
/**

View File

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

View File

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

View File

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

View File

@@ -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();
}

View File

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

View File

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

View File

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

View File

@@ -65,14 +65,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);
}
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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,6 +47,13 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
protected function processOrderByForList(QueryBuilder $qb, $orderBy)
{
// 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';
@@ -59,8 +66,8 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
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();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -112,4 +112,28 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->assertCount(1, $result);
$this->assertSame($foo, $result[0]);
}
/**
* @test
*/
public function findListProperlyMapsFieldNamesToColumnNamesWhenOrdering()
{
$urls = ['a', 'z', 'c', 'b'];
foreach ($urls as $url) {
$this->getEntityManager()->persist(
(new ShortUrl())->setShortCode($url)
->setLongUrl($url)
);
}
$this->getEntityManager()->flush();
$result = $this->repo->findList(null, null, null, [], ['longUrl' => 'ASC']);
$this->assertCount(\count($urls), $result);
$this->assertEquals('a', $result[0]->getLongUrl());
$this->assertEquals('b', $result[1]->getLongUrl());
$this->assertEquals('c', $result[2]->getLongUrl());
$this->assertEquals('z', $result[3]->getLongUrl());
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

@@ -11,10 +11,10 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\CreateShortCodeData;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
use Zend\Diactoros\Uri;
use Zend\I18n\Translator\TranslatorInterface;
abstract class AbstractCreateShortCodeAction extends AbstractRestAction
@@ -65,7 +65,7 @@ abstract class AbstractCreateShortCodeAction extends AbstractRestAction
}
try {
$shortCode = $this->urlShortener->urlToShortCode(
$shortUrl = $this->urlShortener->urlToShortCode(
$longUrl,
$shortCodeData->getTags(),
$shortCodeMeta->getValidSince(),
@@ -73,15 +73,9 @@ abstract class AbstractCreateShortCodeAction extends AbstractRestAction
$customSlug,
$shortCodeMeta->getMaxVisits()
);
$shortUrl = (new Uri())->withPath($shortCode)
->withScheme($this->domainConfig['schema'])
->withHost($this->domainConfig['hostname']);
$transformer = new ShortUrlDataTransformer($this->domainConfig);
return new JsonResponse([
'longUrl' => (string) $longUrl,
'shortUrl' => (string) $shortUrl,
'shortCode' => $shortCode,
]);
return new JsonResponse($transformer->transform($shortUrl));
} catch (InvalidUrlException $e) {
$this->logger->warning('Provided Invalid URL.' . PHP_EOL . $e);
return new JsonResponse([

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\ShortCode;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\EmptyResponse;
use Zend\Diactoros\Response\JsonResponse;
use Zend\I18n\Translator\TranslatorInterface;
class DeleteShortCodeAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/short-codes/{shortCode}';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_DELETE];
/**
* @var DeleteShortUrlServiceInterface
*/
private $deleteShortUrlService;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(
DeleteShortUrlServiceInterface $deleteShortUrlService,
TranslatorInterface $translator,
LoggerInterface $logger = null
) {
parent::__construct($logger);
$this->deleteShortUrlService = $deleteShortUrlService;
$this->translator = $translator;
}
/**
* Handle the request and return a response.
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
$shortCode = $request->getAttribute('shortCode', '');
try {
$this->deleteShortUrlService->deleteByShortCode($shortCode);
return new EmptyResponse();
} catch (Exception\InvalidShortCodeException $e) {
$this->logger->warning(
\sprintf('Provided short code %s does not belong to any URL.', $shortCode) . PHP_EOL . $e
);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => \sprintf($this->translator->translate('No URL found for short code "%s"'), $shortCode),
], self::STATUS_NOT_FOUND);
} catch (Exception\DeleteShortUrlException $e) {
$this->logger->warning('Provided data is invalid.' . PHP_EOL . $e);
$messagePlaceholder = $this->translator->translate(
'It is not possible to delete URL with short code "%s" because it has reached more than "%s" visits.'
);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => \sprintf($messagePlaceholder, $shortCode, $e->getVisitsThreshold()),
], self::STATUS_BAD_REQUEST);
}
}
}

View File

@@ -8,6 +8,7 @@ use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
@@ -28,15 +29,21 @@ class ListShortCodesAction extends AbstractRestAction
* @var TranslatorInterface
*/
private $translator;
/**
* @var array
*/
private $domainConfig;
public function __construct(
ShortUrlServiceInterface $shortUrlService,
TranslatorInterface $translator,
array $domainConfig,
LoggerInterface $logger = null
) {
parent::__construct($logger);
$this->shortUrlService = $shortUrlService;
$this->translator = $translator;
$this->domainConfig = $domainConfig;
}
/**
@@ -49,7 +56,9 @@ class ListShortCodesAction extends AbstractRestAction
try {
$params = $this->queryToListParams($request->getQueryParams());
$shortUrls = $this->shortUrlService->listShortUrls(...$params);
return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls)]);
return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls, new ShortUrlDataTransformer(
$this->domainConfig
))]);
} catch (\Exception $e) {
$this->logger->error('Unexpected error while listing short URLs.' . PHP_EOL . $e);
return new JsonResponse([
@@ -66,7 +75,7 @@ class ListShortCodesAction extends AbstractRestAction
private function queryToListParams(array $query): array
{
return [
$query['page'] ?? 1,
(int) ($query['page'] ?? 1),
$query['searchTerm'] ?? null,
$query['tags'] ?? [],
$query['orderBy'] ?? null,

View File

@@ -9,6 +9,7 @@ use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
@@ -27,15 +28,21 @@ class ResolveUrlAction extends AbstractRestAction
* @var TranslatorInterface
*/
private $translator;
/**
* @var array
*/
private $domainConfig;
public function __construct(
UrlShortenerInterface $urlShortener,
TranslatorInterface $translator,
array $domainConfig,
LoggerInterface $logger = null
) {
parent::__construct($logger);
$this->urlShortener = $urlShortener;
$this->translator = $translator;
$this->domainConfig = $domainConfig;
}
/**
@@ -46,17 +53,16 @@ class ResolveUrlAction extends AbstractRestAction
public function handle(Request $request): Response
{
$shortCode = $request->getAttribute('shortCode');
$transformer = new ShortUrlDataTransformer($this->domainConfig);
try {
$longUrl = $this->urlShortener->shortCodeToUrl($shortCode);
return new JsonResponse([
'longUrl' => $longUrl,
]);
$url = $this->urlShortener->shortCodeToUrl($shortCode);
return new JsonResponse($transformer->transform($url));
} catch (InvalidShortCodeException $e) {
$this->logger->warning('Provided short code with invalid format.' . PHP_EOL . $e);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => sprintf(
'message' => \sprintf(
$this->translator->translate('Provided short code "%s" has an invalid format'),
$shortCode
),
@@ -65,7 +71,7 @@ class ResolveUrlAction extends AbstractRestAction
$this->logger->warning('Provided short code couldn\'t be found.' . PHP_EOL . $e);
return new JsonResponse([
'error' => RestUtils::INVALID_ARGUMENT_ERROR,
'message' => sprintf($this->translator->translate('No URL found for short code "%s"'), $shortCode),
'message' => \sprintf($this->translator->translate('No URL found for short code "%s"'), $shortCode),
], self::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Unexpected error while resolving the URL behind a short code.' . PHP_EOL . $e);

View File

@@ -23,17 +23,17 @@ class ApiKey extends AbstractEntity
* @var string
* @ORM\Column(name="`key`", nullable=false, unique=true)
*/
protected $key;
private $key;
/**
* @var \DateTime|null
* @ORM\Column(name="expiration_date", nullable=true, type="datetime")
*/
protected $expirationDate;
private $expirationDate;
/**
* @var bool
* @ORM\Column(type="boolean")
*/
protected $enabled;
private $enabled;
public function __construct()
{
@@ -41,45 +41,28 @@ class ApiKey extends AbstractEntity
$this->key = $this->generateV4Uuid();
}
/**
* @return string
*/
public function getKey(): string
{
return $this->key;
}
/**
* @param string $key
* @return $this
*/
public function setKey($key): self
public function setKey(string $key): self
{
$this->key = $key;
return $this;
}
/**
* @return \DateTime|null
*/
public function getExpirationDate(): ?\DateTime
{
return $this->expirationDate;
}
/**
* @param \DateTime $expirationDate
* @return $this
*/
public function setExpirationDate($expirationDate): self
public function setExpirationDate(\DateTime $expirationDate): self
{
$this->expirationDate = $expirationDate;
return $this;
}
/**
* @return bool
*/
public function isExpired(): bool
{
if ($this->expirationDate === null) {
@@ -89,29 +72,17 @@ class ApiKey extends AbstractEntity
return $this->expirationDate < new \DateTime();
}
/**
* @return boolean
*/
public function isEnabled(): bool
{
return $this->enabled;
}
/**
* @param boolean $enabled
* @return $this
*/
public function setEnabled($enabled): self
public function setEnabled(bool $enabled): self
{
$this->enabled = $enabled;
return $this;
}
/**
* Disables this API key
*
* @return $this
*/
public function disable(): self
{
return $this->setEnabled(false);
@@ -119,19 +90,12 @@ class ApiKey extends AbstractEntity
/**
* Tells if this api key is enabled and not expired
*
* @return bool
*/
public function isValid(): bool
{
return $this->isEnabled() && ! $this->isExpired();
}
/**
* The string representation of an API key is the key itself
*
* @return string
*/
public function __toString(): string
{
return $this->getKey();

View File

@@ -10,6 +10,7 @@ use Shlinkio\Shlink\Rest\Exception as Rest;
class RestUtils
{
public const INVALID_SHORTCODE_ERROR = 'INVALID_SHORTCODE';
public const INVALID_SHORTCODE_DELETION_ERROR = 'INVALID_SHORTCODE_DELETION';
public const INVALID_URL_ERROR = 'INVALID_URL';
public const INVALID_ARGUMENT_ERROR = 'INVALID_ARGUMENT';
public const INVALID_SLUG_ERROR = 'INVALID_SLUG';
@@ -35,6 +36,8 @@ class RestUtils
return self::INVALID_ARGUMENT_ERROR;
case $e instanceof Rest\AuthenticationException:
return self::INVALID_CREDENTIALS_ERROR;
case $e instanceof Core\DeleteShortUrlException:
return self::INVALID_SHORTCODE_DELETION_ERROR;
default:
return self::UNKNOWN_ERROR;
}

View File

@@ -55,7 +55,7 @@ class AuthenticateActionTest extends TestCase
*/
public function properApiKeyReturnsTokenInResponse()
{
$this->apiKeyService->getByKey('foo')->willReturn((new ApiKey())->setId(5))
$this->apiKeyService->getByKey('foo')->willReturn((new ApiKey())->setId('5'))
->shouldBeCalledTimes(1);
$request = ServerRequestFactory::fromGlobals()->withParsedBody([

View File

@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Rest\Action\ShortCode;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
@@ -50,7 +51,10 @@ class CreateShortCodeActionTest extends TestCase
public function properShortcodeConversionReturnsData()
{
$this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array'), Argument::cetera())
->willReturn('abc123')
->willReturn(
(new ShortUrl())->setShortCode('abc123')
->setLongUrl('')
)
->shouldBeCalledTimes(1);
$request = ServerRequestFactory::fromGlobals()->withParsedBody([

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action\ShortCode;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\ShortCode\DeleteShortCodeAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
use Zend\Diactoros\ServerRequestFactory;
use Zend\I18n\Translator\Translator;
class DeleteShortCodeActionTest extends TestCase
{
/**
* @var DeleteShortCodeAction
*/
private $action;
/**
* @var ObjectProphecy
*/
private $service;
public function setUp()
{
$this->service = $this->prophesize(DeleteShortUrlServiceInterface::class);
$this->action = new DeleteShortCodeAction($this->service->reveal(), Translator::factory([]));
}
/**
* @test
*/
public function emptyResponseIsReturnedIfProperlyDeleted()
{
$deleteByShortCode = $this->service->deleteByShortCode(Argument::any())->will(function () {
});
$resp = $this->action->handle(ServerRequestFactory::fromGlobals());
$this->assertEquals(204, $resp->getStatusCode());
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
}
/**
* @test
* @dataProvider provideExceptions
*/
public function returnsErrorResponseInCaseOfException(\Throwable $e, string $error, int $statusCode)
{
$deleteByShortCode = $this->service->deleteByShortCode(Argument::any())->willThrow($e);
/** @var JsonResponse $resp */
$resp = $this->action->handle(ServerRequestFactory::fromGlobals());
$payload = $resp->getPayload();
$this->assertEquals($statusCode, $resp->getStatusCode());
$this->assertEquals($error, $payload['error']);
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
}
public function provideExceptions(): array
{
return [
[new Exception\InvalidShortCodeException(), RestUtils::INVALID_SHORTCODE_ERROR, 404],
[new Exception\DeleteShortUrlException(5), RestUtils::INVALID_SHORTCODE_DELETION_ERROR, 400],
];
}
}

View File

@@ -26,7 +26,10 @@ class ListShortCodesActionTest extends TestCase
public function setUp()
{
$this->service = $this->prophesize(ShortUrlService::class);
$this->action = new ListShortCodesAction($this->service->reveal(), Translator::factory([]));
$this->action = new ListShortCodesAction($this->service->reveal(), Translator::factory([]), [
'hostname' => 'doma.in',
'schema' => 'https',
]);
}
/**

View File

@@ -5,6 +5,7 @@ namespace ShlinkioTest\Shlink\Rest\Action\ShortCode;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
@@ -27,7 +28,7 @@ class ResolveUrlActionTest extends TestCase
public function setUp()
{
$this->urlShortener = $this->prophesize(UrlShortener::class);
$this->action = new ResolveUrlAction($this->urlShortener->reveal(), Translator::factory([]));
$this->action = new ResolveUrlAction($this->urlShortener->reveal(), Translator::factory([]), []);
}
/**
@@ -51,8 +52,9 @@ class ResolveUrlActionTest extends TestCase
public function correctShortCodeReturnsSuccess()
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn('http://domain.com/foo/bar')
->shouldBeCalledTimes(1);
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn(
(new ShortUrl())->setLongUrl('http://domain.com/foo/bar')
)->shouldBeCalledTimes(1);
$request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
$response = $this->action->handle($request);

View File

@@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Rest\Action\ShortCode\SingleStepCreateShortCodeAction;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@@ -112,7 +113,7 @@ class SingleStepCreateShortCodeActionTest extends TestCase
null,
null,
null
);
)->willReturn((new ShortUrl())->setLongUrl(''));
$resp = $this->action->handle($request);

View File

@@ -30,7 +30,7 @@ class JWTServiceTest extends TestCase
*/
public function tokenIsProperlyCreated()
{
$id = 34;
$id = '34';
$token = $this->service->create((new ApiKey())->setId($id));
$payload = (array) JWT::decode($token, 'foo', [JWTService::DEFAULT_ENCRYPTION_ALG]);
$this->assertGreaterThanOrEqual($payload['iat'], time());

5
public/robots.txt Normal file
View File

@@ -0,0 +1,5 @@
# For more information about the robots.txt standard, see:
# http://www.robotstxt.org/orig.html
User-agent: *
Disallow: /