|
1
|
|
|
<?php |
|
2
|
|
|
declare(strict_types=1); |
|
3
|
|
|
|
|
4
|
|
|
namespace Shlinkio\Shlink\Core\Service; |
|
5
|
|
|
|
|
6
|
|
|
use Cake\Chronos\Chronos; |
|
7
|
|
|
use Cocur\Slugify\Slugify; |
|
8
|
|
|
use Cocur\Slugify\SlugifyInterface; |
|
9
|
|
|
use Doctrine\ORM\EntityManagerInterface; |
|
10
|
|
|
use GuzzleHttp\ClientInterface; |
|
11
|
|
|
use GuzzleHttp\Exception\GuzzleException; |
|
12
|
|
|
use Psr\Http\Message\UriInterface; |
|
13
|
|
|
use Shlinkio\Shlink\Core\Entity\ShortUrl; |
|
14
|
|
|
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; |
|
15
|
|
|
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; |
|
16
|
|
|
use Shlinkio\Shlink\Core\Exception\InvalidUrlException; |
|
17
|
|
|
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; |
|
18
|
|
|
use Shlinkio\Shlink\Core\Exception\RuntimeException; |
|
19
|
|
|
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; |
|
20
|
|
|
use Shlinkio\Shlink\Core\Util\TagManagerTrait; |
|
21
|
|
|
use Throwable; |
|
22
|
|
|
use function floor; |
|
23
|
|
|
use function fmod; |
|
24
|
|
|
use function preg_match; |
|
25
|
|
|
use function strlen; |
|
26
|
|
|
|
|
27
|
|
|
class UrlShortener implements UrlShortenerInterface |
|
28
|
|
|
{ |
|
29
|
|
|
use TagManagerTrait; |
|
30
|
|
|
|
|
31
|
|
|
public const DEFAULT_CHARS = '123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ'; |
|
32
|
|
|
private const ID_INCREMENT = 200000; |
|
33
|
|
|
|
|
34
|
|
|
/** |
|
35
|
|
|
* @var ClientInterface |
|
36
|
|
|
*/ |
|
37
|
|
|
private $httpClient; |
|
38
|
|
|
/** |
|
39
|
|
|
* @var EntityManagerInterface |
|
40
|
|
|
*/ |
|
41
|
|
|
private $em; |
|
42
|
|
|
/** |
|
43
|
|
|
* @var string |
|
44
|
|
|
*/ |
|
45
|
|
|
private $chars; |
|
46
|
|
|
/** |
|
47
|
|
|
* @var SlugifyInterface |
|
48
|
|
|
*/ |
|
49
|
|
|
private $slugger; |
|
50
|
|
|
/** |
|
51
|
|
|
* @var bool |
|
52
|
|
|
*/ |
|
53
|
|
|
private $urlValidationEnabled; |
|
54
|
|
|
|
|
55
|
7 |
|
public function __construct( |
|
56
|
|
|
ClientInterface $httpClient, |
|
57
|
|
|
EntityManagerInterface $em, |
|
58
|
|
|
$urlValidationEnabled, |
|
59
|
|
|
$chars = self::DEFAULT_CHARS, |
|
60
|
|
|
SlugifyInterface $slugger = null |
|
61
|
|
|
) { |
|
62
|
7 |
|
$this->httpClient = $httpClient; |
|
63
|
7 |
|
$this->em = $em; |
|
64
|
7 |
|
$this->urlValidationEnabled = (bool) $urlValidationEnabled; |
|
65
|
7 |
|
$this->chars = empty($chars) ? self::DEFAULT_CHARS : $chars; |
|
66
|
7 |
|
$this->slugger = $slugger ?: new Slugify(); |
|
67
|
7 |
|
} |
|
68
|
|
|
|
|
69
|
|
|
/** |
|
70
|
|
|
* @throws NonUniqueSlugException |
|
71
|
|
|
* @throws InvalidUrlException |
|
72
|
|
|
* @throws RuntimeException |
|
73
|
|
|
*/ |
|
74
|
5 |
|
public function urlToShortCode( |
|
75
|
|
|
UriInterface $url, |
|
76
|
|
|
array $tags = [], |
|
77
|
|
|
?Chronos $validSince = null, |
|
78
|
|
|
?Chronos $validUntil = null, |
|
79
|
|
|
?string $customSlug = null, |
|
80
|
|
|
?int $maxVisits = null |
|
81
|
|
|
): ShortUrl { |
|
82
|
|
|
// If the URL validation is enabled, check that the URL actually exists |
|
83
|
5 |
|
if ($this->urlValidationEnabled) { |
|
84
|
1 |
|
$this->checkUrlExists($url); |
|
85
|
|
|
} |
|
86
|
4 |
|
$customSlug = $this->processCustomSlug($customSlug); |
|
87
|
|
|
|
|
88
|
|
|
// Transactionally insert the short url, then generate the short code and finally update the short code |
|
89
|
|
|
try { |
|
90
|
3 |
|
$this->em->beginTransaction(); |
|
91
|
|
|
|
|
92
|
|
|
// First, create the short URL with an empty short code |
|
93
|
3 |
|
$shortUrl = new ShortUrl(); |
|
94
|
3 |
|
$shortUrl->setOriginalUrl((string) $url) |
|
|
|
|
|
|
95
|
3 |
|
->setValidSince($validSince) |
|
96
|
3 |
|
->setValidUntil($validUntil) |
|
97
|
3 |
|
->setMaxVisits($maxVisits); |
|
98
|
3 |
|
$this->em->persist($shortUrl); |
|
99
|
3 |
|
$this->em->flush(); |
|
100
|
|
|
|
|
101
|
|
|
// Generate the short code and persist it |
|
102
|
2 |
|
$shortCode = $customSlug ?? $this->convertAutoincrementIdToShortCode((float) $shortUrl->getId()); |
|
103
|
2 |
|
$shortUrl->setShortCode($shortCode) |
|
104
|
2 |
|
->setTags($this->tagNamesToEntities($this->em, $tags)); |
|
105
|
2 |
|
$this->em->flush(); |
|
106
|
|
|
|
|
107
|
2 |
|
$this->em->commit(); |
|
108
|
2 |
|
return $shortUrl; |
|
109
|
1 |
|
} catch (Throwable $e) { |
|
110
|
1 |
|
if ($this->em->getConnection()->isTransactionActive()) { |
|
111
|
1 |
|
$this->em->rollback(); |
|
112
|
1 |
|
$this->em->close(); |
|
113
|
|
|
} |
|
114
|
|
|
|
|
115
|
1 |
|
throw new RuntimeException('An error occurred while persisting the short URL', -1, $e); |
|
116
|
|
|
} |
|
117
|
|
|
} |
|
118
|
|
|
|
|
119
|
|
|
/** |
|
120
|
|
|
* Tries to perform a GET request to provided url, returning true on success and false on failure |
|
121
|
|
|
* |
|
122
|
|
|
* @param UriInterface $url |
|
123
|
|
|
* @return void |
|
124
|
|
|
*/ |
|
125
|
1 |
|
private function checkUrlExists(UriInterface $url) |
|
126
|
|
|
{ |
|
127
|
|
|
try { |
|
128
|
1 |
|
$this->httpClient->request('GET', $url, ['allow_redirects' => [ |
|
129
|
|
|
'max' => 15, |
|
130
|
|
|
]]); |
|
131
|
1 |
|
} catch (GuzzleException $e) { |
|
132
|
1 |
|
throw InvalidUrlException::fromUrl($url, $e); |
|
133
|
|
|
} |
|
134
|
|
|
} |
|
135
|
|
|
|
|
136
|
|
|
/** |
|
137
|
|
|
* Generates the unique shortcode for an autoincrement ID |
|
138
|
|
|
* |
|
139
|
|
|
* @param float $id |
|
140
|
|
|
* @return string |
|
141
|
|
|
*/ |
|
142
|
1 |
|
private function convertAutoincrementIdToShortCode(float $id): string |
|
143
|
|
|
{ |
|
144
|
1 |
|
$id += self::ID_INCREMENT; // Increment the Id so that the generated shortcode is not too short |
|
145
|
1 |
|
$length = strlen($this->chars); |
|
146
|
1 |
|
$code = ''; |
|
147
|
|
|
|
|
148
|
1 |
|
while ($id > 0) { |
|
149
|
|
|
// Determine the value of the next higher character in the short code and prepend it |
|
150
|
1 |
|
$code = $this->chars[(int) fmod($id, $length)] . $code; |
|
151
|
1 |
|
$id = floor($id / $length); |
|
152
|
|
|
} |
|
153
|
|
|
|
|
154
|
1 |
|
return $this->chars[(int) $id] . $code; |
|
155
|
|
|
} |
|
156
|
|
|
|
|
157
|
4 |
|
private function processCustomSlug($customSlug) |
|
158
|
|
|
{ |
|
159
|
4 |
|
if ($customSlug === null) { |
|
160
|
2 |
|
return null; |
|
161
|
|
|
} |
|
162
|
|
|
|
|
163
|
|
|
// If a custom slug was provided, make sure it's unique |
|
164
|
2 |
|
$customSlug = $this->slugger->slugify($customSlug); |
|
165
|
2 |
|
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy(['shortCode' => $customSlug]); |
|
166
|
2 |
|
if ($shortUrl !== null) { |
|
167
|
1 |
|
throw NonUniqueSlugException::fromSlug($customSlug); |
|
168
|
|
|
} |
|
169
|
|
|
|
|
170
|
1 |
|
return $customSlug; |
|
171
|
|
|
} |
|
172
|
|
|
|
|
173
|
|
|
/** |
|
174
|
|
|
* Tries to find the mapped URL for provided short code. Returns null if not found |
|
175
|
|
|
* |
|
176
|
|
|
* @throws InvalidShortCodeException |
|
177
|
|
|
* @throws EntityDoesNotExistException |
|
178
|
|
|
*/ |
|
179
|
2 |
|
public function shortCodeToUrl(string $shortCode): ShortUrl |
|
180
|
|
|
{ |
|
181
|
|
|
// Validate short code format |
|
182
|
2 |
|
if (! preg_match('|[' . $this->chars . ']+|', $shortCode)) { |
|
183
|
1 |
|
throw InvalidShortCodeException::fromCharset($shortCode, $this->chars); |
|
184
|
|
|
} |
|
185
|
|
|
|
|
186
|
|
|
/** @var ShortUrlRepository $shortUrlRepo */ |
|
187
|
1 |
|
$shortUrlRepo = $this->em->getRepository(ShortUrl::class); |
|
188
|
1 |
|
$shortUrl = $shortUrlRepo->findOneByShortCode($shortCode); |
|
189
|
1 |
|
if ($shortUrl === null) { |
|
190
|
|
|
throw EntityDoesNotExistException::createFromEntityAndConditions(ShortUrl::class, [ |
|
191
|
|
|
'shortCode' => $shortCode, |
|
192
|
|
|
]); |
|
193
|
|
|
} |
|
194
|
|
|
|
|
195
|
1 |
|
return $shortUrl; |
|
196
|
|
|
} |
|
197
|
|
|
} |
|
198
|
|
|
|
This function has been deprecated. The supplier of the function has supplied an explanatory message.
The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.