37 lines
1.5 KiB
PHP
37 lines
1.5 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Core\RedisClient;
|
|
|
|
/** Short-lived, asset-scoped browser grant. Never place a session JWT in a URL. */
|
|
final class AssetDownloadTicketService
|
|
{
|
|
private const TTL_SECONDS = 120;
|
|
|
|
public static function issue(int $studentId, string $assetUuid): array
|
|
{
|
|
if ($studentId <= 0 || $assetUuid === '') throw new \InvalidArgumentException('Invalid download scope');
|
|
$ticket = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
|
|
$key = 'asset_download:' . hash('sha256', $ticket);
|
|
$saved = RedisClient::getInstance()->setex($key, self::TTL_SECONDS, json_encode([
|
|
'student_id' => $studentId,
|
|
'asset_uuid' => $assetUuid,
|
|
], JSON_THROW_ON_ERROR));
|
|
if (!$saved) throw new \RuntimeException('Download grant unavailable');
|
|
return ['ticket' => $ticket, 'expires_in_seconds' => self::TTL_SECONDS];
|
|
}
|
|
|
|
public static function resolve(string $ticket, string $assetUuid): ?int
|
|
{
|
|
if (!preg_match('/^[A-Za-z0-9_-]{43}$/', $ticket)) return null;
|
|
$payload = RedisClient::getInstance()->get('asset_download:' . hash('sha256', $ticket));
|
|
if (!is_string($payload)) return null;
|
|
$grant = json_decode($payload, true);
|
|
if (!is_array($grant) || !hash_equals((string)($grant['asset_uuid'] ?? ''), $assetUuid)) return null;
|
|
$studentId = (int)($grant['student_id'] ?? 0);
|
|
return $studentId > 0 ? $studentId : null;
|
|
}
|
|
}
|