Files
preland/admin/src/LinkBuilder.php
T
2026-10-25 18:17:23 +03:00

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