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