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