1
|
|
|
<?php |
2
|
|
|
declare(strict_types=1); |
3
|
|
|
|
4
|
|
|
namespace Shlinkio\Shlink\Core\Service; |
5
|
|
|
|
6
|
|
|
use Doctrine\ORM\EntityManagerInterface; |
7
|
|
|
use Fig\Http\Message\RequestMethodInterface; |
8
|
|
|
use GuzzleHttp\ClientInterface; |
9
|
|
|
use GuzzleHttp\Exception\GuzzleException; |
10
|
|
|
use GuzzleHttp\RequestOptions; |
11
|
|
|
use Psr\Http\Message\UriInterface; |
12
|
|
|
use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver; |
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\Model\ShortUrlMeta; |
20
|
|
|
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; |
21
|
|
|
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; |
22
|
|
|
use Shlinkio\Shlink\Core\Util\TagManagerTrait; |
23
|
|
|
use Throwable; |
24
|
|
|
|
25
|
|
|
use function array_reduce; |
26
|
|
|
use function floor; |
27
|
|
|
use function fmod; |
28
|
|
|
use function preg_match; |
29
|
|
|
use function strlen; |
30
|
|
|
|
31
|
|
|
class UrlShortener implements UrlShortenerInterface |
32
|
|
|
{ |
33
|
|
|
use TagManagerTrait; |
34
|
|
|
|
35
|
|
|
private const ID_INCREMENT = 200000; |
36
|
|
|
|
37
|
|
|
/** @var ClientInterface */ |
38
|
|
|
private $httpClient; |
39
|
|
|
/** @var EntityManagerInterface */ |
40
|
|
|
private $em; |
41
|
|
|
/** @var UrlShortenerOptions */ |
42
|
|
|
private $options; |
43
|
|
|
|
44
|
15 |
|
public function __construct(ClientInterface $httpClient, EntityManagerInterface $em, UrlShortenerOptions $options) |
45
|
|
|
{ |
46
|
15 |
|
$this->httpClient = $httpClient; |
47
|
15 |
|
$this->em = $em; |
48
|
15 |
|
$this->options = $options; |
49
|
|
|
} |
50
|
|
|
|
51
|
|
|
/** |
52
|
|
|
* @param string[] $tags |
53
|
|
|
* @throws NonUniqueSlugException |
54
|
|
|
* @throws InvalidUrlException |
55
|
|
|
* @throws RuntimeException |
56
|
|
|
*/ |
57
|
13 |
|
public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl |
58
|
|
|
{ |
59
|
13 |
|
$url = (string) $url; |
60
|
|
|
|
61
|
|
|
// First, check if a short URL exists for all provided params |
62
|
13 |
|
$existingShortUrl = $this->findExistingShortUrlIfExists($url, $tags, $meta); |
63
|
13 |
|
if ($existingShortUrl !== null) { |
64
|
8 |
|
return $existingShortUrl; |
65
|
|
|
} |
66
|
|
|
|
67
|
|
|
// If the URL validation is enabled, check that the URL actually exists |
68
|
5 |
|
if ($this->options->isUrlValidationEnabled()) { |
69
|
1 |
|
$this->checkUrlExists($url); |
70
|
|
|
} |
71
|
4 |
|
$this->verifyCustomSlug($meta); |
72
|
|
|
|
73
|
|
|
// Transactionally insert the short url, then generate the short code and finally update the short code |
74
|
|
|
try { |
75
|
3 |
|
$this->em->beginTransaction(); |
76
|
|
|
|
77
|
|
|
// First, create the short URL with an empty short code |
78
|
3 |
|
$shortUrl = new ShortUrl($url, $meta, new PersistenceDomainResolver($this->em)); |
79
|
3 |
|
$this->em->persist($shortUrl); |
80
|
3 |
|
$this->em->flush(); |
81
|
|
|
|
82
|
|
|
// Generate the short code and persist it if no custom slug was provided |
83
|
2 |
|
if (! $meta->hasCustomSlug()) { |
84
|
|
|
// TODO Somehow provide the logic to calculate the shortCode to avoid the need of a setter |
85
|
2 |
|
$shortCode = $this->convertAutoincrementIdToShortCode((float) $shortUrl->getId()); |
86
|
2 |
|
$shortUrl->setShortCode($shortCode); |
87
|
|
|
} |
88
|
2 |
|
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags)); |
89
|
2 |
|
$this->em->flush(); |
90
|
|
|
|
91
|
2 |
|
$this->em->commit(); |
92
|
2 |
|
return $shortUrl; |
93
|
1 |
|
} catch (Throwable $e) { |
94
|
1 |
|
if ($this->em->getConnection()->isTransactionActive()) { |
95
|
1 |
|
$this->em->rollback(); |
96
|
1 |
|
$this->em->close(); |
97
|
|
|
} |
98
|
|
|
|
99
|
1 |
|
throw new RuntimeException('An error occurred while persisting the short URL', -1, $e); |
100
|
|
|
} |
101
|
|
|
} |
102
|
|
|
|
103
|
13 |
|
private function findExistingShortUrlIfExists(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl |
104
|
|
|
{ |
105
|
13 |
|
if (! $meta->findIfExists()) { |
106
|
4 |
|
return null; |
107
|
|
|
} |
108
|
|
|
|
109
|
9 |
|
$criteria = ['longUrl' => $url]; |
110
|
9 |
|
if ($meta->hasCustomSlug()) { |
111
|
1 |
|
$criteria['shortCode'] = $meta->getCustomSlug(); |
112
|
|
|
} |
113
|
|
|
/** @var ShortUrl[] $shortUrls */ |
114
|
9 |
|
$shortUrls = $this->em->getRepository(ShortUrl::class)->findBy($criteria); |
115
|
9 |
|
if (empty($shortUrls)) { |
116
|
1 |
|
return null; |
117
|
|
|
} |
118
|
|
|
|
119
|
|
|
// Iterate short URLs until one that matches is found, or return null otherwise |
120
|
|
|
return array_reduce($shortUrls, function (?ShortUrl $found, ShortUrl $shortUrl) use ($tags, $meta) { |
121
|
8 |
|
if ($found !== null) { |
122
|
1 |
|
return $found; |
123
|
|
|
} |
124
|
|
|
|
125
|
8 |
|
return $shortUrl->matchesCriteria($meta, $tags) ? $shortUrl : null; |
126
|
8 |
|
}); |
127
|
|
|
} |
128
|
|
|
|
129
|
1 |
|
private function checkUrlExists(string $url): void |
130
|
|
|
{ |
131
|
|
|
try { |
132
|
1 |
|
$this->httpClient->request(RequestMethodInterface::METHOD_GET, $url, [ |
133
|
1 |
|
RequestOptions::ALLOW_REDIRECTS => ['max' => 15], |
134
|
|
|
]); |
135
|
1 |
|
} catch (GuzzleException $e) { |
136
|
1 |
|
throw InvalidUrlException::fromUrl($url, $e); |
137
|
|
|
} |
138
|
|
|
} |
139
|
|
|
|
140
|
4 |
|
private function verifyCustomSlug(ShortUrlMeta $meta): void |
141
|
|
|
{ |
142
|
4 |
|
if (! $meta->hasCustomSlug()) { |
143
|
3 |
|
return; |
144
|
|
|
} |
145
|
|
|
|
146
|
1 |
|
$customSlug = $meta->getCustomSlug(); |
147
|
1 |
|
$domain = $meta->getDomain(); |
148
|
|
|
|
149
|
|
|
/** @var ShortUrlRepository $repo */ |
150
|
1 |
|
$repo = $this->em->getRepository(ShortUrl::class); |
151
|
1 |
|
$shortUrlsCount = $repo->slugIsInUse($customSlug, $domain); |
152
|
1 |
|
if ($shortUrlsCount > 0) { |
153
|
1 |
|
throw NonUniqueSlugException::fromSlug($customSlug, $domain); |
154
|
|
|
} |
155
|
|
|
} |
156
|
|
|
|
157
|
2 |
|
private function convertAutoincrementIdToShortCode(float $id): string |
158
|
|
|
{ |
159
|
2 |
|
$id += self::ID_INCREMENT; // Increment the Id so that the generated shortcode is not too short |
160
|
2 |
|
$chars = $this->options->getChars(); |
161
|
|
|
|
162
|
2 |
|
$length = strlen($chars); |
163
|
2 |
|
$code = ''; |
164
|
|
|
|
165
|
2 |
|
while ($id > 0) { |
166
|
|
|
// Determine the value of the next higher character in the short code and prepend it |
167
|
2 |
|
$code = $chars[(int) fmod($id, $length)] . $code; |
168
|
2 |
|
$id = floor($id / $length); |
169
|
|
|
} |
170
|
|
|
|
171
|
2 |
|
return $chars[(int) $id] . $code; |
172
|
|
|
} |
173
|
|
|
|
174
|
|
|
/** |
175
|
|
|
* @throws InvalidShortCodeException |
176
|
|
|
* @throws EntityDoesNotExistException |
177
|
|
|
*/ |
178
|
2 |
|
public function shortCodeToUrl(string $shortCode): ShortUrl |
179
|
|
|
{ |
180
|
2 |
|
$chars = $this->options->getChars(); |
181
|
|
|
|
182
|
|
|
// Validate short code format |
183
|
2 |
|
if (! preg_match('|[' . $chars . ']+|', $shortCode)) { |
184
|
1 |
|
throw InvalidShortCodeException::fromCharset($shortCode, $chars); |
185
|
|
|
} |
186
|
|
|
|
187
|
|
|
/** @var ShortUrlRepository $shortUrlRepo */ |
188
|
1 |
|
$shortUrlRepo = $this->em->getRepository(ShortUrl::class); |
189
|
1 |
|
$shortUrl = $shortUrlRepo->findOneByShortCode($shortCode); |
190
|
1 |
|
if ($shortUrl === null) { |
191
|
|
|
throw EntityDoesNotExistException::createFromEntityAndConditions(ShortUrl::class, [ |
192
|
|
|
'shortCode' => $shortCode, |
193
|
|
|
]); |
194
|
|
|
} |
195
|
|
|
|
196
|
1 |
|
return $shortUrl; |
197
|
|
|
} |
198
|
|
|
} |
199
|
|
|
|