105 lines
5.1 KiB
PHP
105 lines
5.1 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace PrelandAdmin;
|
|
|
|
/** Независимый выбор меток: читает реестр целей, пишет только приватное состояние ссылки. */
|
|
final class LinkBuilder
|
|
{
|
|
private const KEYS = ['indpic', 'theme', 'degr', 'reg', 'banch', 'tp', 'cons', 'focus'];
|
|
private const ENUMS = ['theme', 'reg', 'banch', 'focus'];
|
|
|
|
public function __construct(private readonly Store $store) {}
|
|
|
|
public function get(string $targetId): array
|
|
{
|
|
$this->assertId($targetId);
|
|
return $this->store->locked('link-builder-' . $targetId, function () use ($targetId): array {
|
|
$origin = $this->origin($this->store->target($targetId));
|
|
$name = $this->filename($targetId);
|
|
$saved = $this->store->read($name, null);
|
|
if ($saved === null) return ['selection'=>null, 'url'=>null, 'saved_at'=>null];
|
|
if (!is_array($saved) || !is_array($saved['selection'] ?? null)
|
|
|| !is_string($saved['url'] ?? null) || !is_string($saved['saved_at'] ?? null)) {
|
|
throw new Problem('Повреждено сохранённое состояние конструктора ссылки.', 503);
|
|
}
|
|
$selection = $this->selection($saved['selection']);
|
|
$query = parse_url($saved['url'], PHP_URL_QUERY);
|
|
if (!is_string($query)) throw new Problem('Повреждена сохранённая ссылка.', 503);
|
|
parse_str($query, $parameters);
|
|
if (array_keys($parameters) !== self::KEYS) throw new Problem('Повреждена сохранённая ссылка.', 503);
|
|
foreach ($selection as $key=>$value) {
|
|
$digit = $value === 'rnd' ? '0' : $value;
|
|
if (!is_string($parameters[$key]) || !preg_match('/^[a-f0-9]{4}' . $digit . '[a-f0-9]{12}$/D', $parameters[$key])) {
|
|
throw new Problem('Повреждена сохранённая ссылка.', 503);
|
|
}
|
|
}
|
|
// Переименование цели меняет только origin, сохранённый шум не генерируется заново.
|
|
$url = $origin . '/?' . http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
|
|
if ($saved['url'] !== $url) {
|
|
$saved['url'] = $url;
|
|
$this->store->write($name, $saved);
|
|
}
|
|
return ['selection'=>$selection, 'url'=>$url, 'saved_at'=>$saved['saved_at']];
|
|
});
|
|
}
|
|
|
|
public function save(string $targetId, array $selection): array
|
|
{
|
|
$this->assertId($targetId);
|
|
$selection = $this->selection($selection);
|
|
return $this->store->locked('link-builder-' . $targetId, function () use ($targetId, $selection): array {
|
|
$origin = $this->origin($this->store->target($targetId));
|
|
$parameters = [];
|
|
foreach ($selection as $key=>$value) {
|
|
$parameters[$key] = bin2hex(random_bytes(2)) . ($value === 'rnd' ? '0' : $value) . bin2hex(random_bytes(6));
|
|
}
|
|
$saved = [
|
|
'selection'=>$selection,
|
|
'url'=>$origin . '/?' . http_build_query($parameters, '', '&', PHP_QUERY_RFC3986),
|
|
'saved_at'=>gmdate(DATE_ATOM),
|
|
];
|
|
$this->store->write($this->filename($targetId), $saved);
|
|
return $saved;
|
|
});
|
|
}
|
|
|
|
private function selection(array $input): array
|
|
{
|
|
if (count($input) !== count(self::KEYS) || array_diff(array_keys($input), self::KEYS)) {
|
|
throw new Problem('Передайте ровно восемь параметров конструктора ссылки.');
|
|
}
|
|
$selection = [];
|
|
foreach (self::KEYS as $key) {
|
|
$value = $input[$key] ?? null;
|
|
if (!is_string($value) || ($value !== 'rnd' && !preg_match('/^[1-9]$/D', $value))
|
|
|| (in_array($key, self::ENUMS, true) && !in_array($value, ['1', '2', 'rnd'], true))) {
|
|
throw new Problem("Недопустимое значение параметра {$key} конструктора ссылки.");
|
|
}
|
|
$selection[$key] = $value;
|
|
}
|
|
return $selection;
|
|
}
|
|
|
|
private function origin(array $target): string
|
|
{
|
|
$domain = $target['domain'] ?? null;
|
|
if (!is_string($domain) || strlen($domain)>253 || !preg_match('/^[a-z0-9.-]+$/iD', $domain)
|
|
|| str_starts_with($domain, '.') || str_ends_with($domain, '.')
|
|
|| filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) === false) {
|
|
throw new Problem('В подключении указан небезопасный домен для HTTPS-ссылки.');
|
|
}
|
|
return 'https://' . strtolower($domain);
|
|
}
|
|
|
|
private function assertId(string $targetId): void
|
|
{
|
|
if (!preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $targetId)) throw new Problem('Некорректный ID подключения.');
|
|
}
|
|
|
|
private function filename(string $targetId): string
|
|
{
|
|
return 'link-builder-' . $targetId . '.json';
|
|
}
|
|
}
|