Files
zyt/server/app/common/service/DifyUploadedFileReuse.php
2026-10-27 14:03:35 +08:00

155 lines
8.2 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\service;
/** Private, bounded transport-descriptor reuse. Never stores source bytes or OCR. */
final class DifyUploadedFileReuse
{
private const VERSION = 1;
private const SLOTS = 256;
private const MAX_RECORD_BYTES = 1024;
private const DIRECTORY = '.dify-upload-reuse-v1';
public static function token(string $apiKey, string $endpoint, string $user, string $type,
string $mime, string $filename, string $digest): string
{
return hash_hmac('sha256', serialize([self::VERSION, $endpoint, $user, $type, $mime, $filename, $digest]), $apiKey);
}
/** The caller must freshly authorize/read immutable source bytes before calling. */
public static function resolve(string $root, string $token, int $ttl, float $deadline,
?callable $abort, callable $upload): array
{
self::check($deadline, $abort);
try { $lock = self::open($root, $token); } catch (\Throwable $ignored) { $lock = null; }
$waitStarted = microtime(true);
if ($lock === null) {
$id = $upload();
self::check($deadline, $abort);
return ['id' => $id, 'hit' => false, 'wait_ms' => 0, 'generation' => null];
}
try {
$acquired = false;
while (true) {
$blocked = 0;
try { $acquired = @flock($lock, LOCK_EX | LOCK_NB, $blocked); }
catch (\Throwable $ignored) { $blocked = 0; }
if ($acquired || !$blocked) break;
self::check($deadline, $abort);
usleep(20000);
}
self::check($deadline, $abort);
$wait = max(0, (int) round((microtime(true) - $waitStarted) * 1000));
if (!$acquired) {
$id = $upload(); self::check($deadline, $abort);
return ['id' => $id, 'hit' => false, 'wait_ms' => $wait, 'generation' => null];
}
try { $record = self::read($lock, $token); } catch (\Throwable $ignored) { $record = null; }
if ($record !== null) return ['id' => $record['id'], 'hit' => true, 'wait_ms' => $wait, 'generation' => null];
$id = $upload(); // Upload exceptions are real failures, never cache fallback.
self::check($deadline, $abort);
if (!self::validId($id)) throw new \RuntimeException('REQUIRED_FILES_UPLOAD_FAILED');
$now = microtime(true);
$generation = null;
// Readers and writers hold this same stable inode's exclusive lock.
// A crash during truncate/write produces a cache miss, never a partial ID.
try {
$candidate = bin2hex(random_bytes(16));
$data = ['v' => self::VERSION, 'token' => $token, 'id' => $id, 'created' => $now,
'expires' => $now + max(1, min(120, $ttl)), 'generation' => $candidate];
if (self::write($lock, $data)) $generation = $candidate;
} catch (\Throwable $ignored) { self::clear($lock); }
try { self::check($deadline, $abort); }
catch (\Throwable $error) { self::clear($lock); throw $error; }
return ['id' => $id, 'hit' => false, 'wait_ms' => $wait, 'generation' => $generation];
} finally {
@flock($lock, LOCK_UN);
fclose($lock);
}
}
/** Drop matching records only; stable lock files are never unlinked. */
public static function drop(string $root, array $tokens, array $generations = []): void
{
foreach (array_unique(array_filter($tokens, static fn ($token): bool =>
is_string($token) && preg_match('/^[a-f0-9]{64}$/D', $token) === 1)) as $token) {
try { $lock = self::open($root, $token); } catch (\Throwable $ignored) { $lock = null; }
if ($lock === null) continue;
try {
// Invalidation must never wait on another request's active upload.
if (!@flock($lock, LOCK_EX | LOCK_NB)) continue;
$record = self::read($lock, $token, false);
if ($record !== null && (!isset($generations[$token])
|| hash_equals($generations[$token], $record['generation']))) self::clear($lock);
} catch (\Throwable $ignored) { /* Invalidation is best effort; callers bypass reuse on retry. */ }
finally { @flock($lock, LOCK_UN); fclose($lock); }
}
}
private static function open(string $root, string $token)
{
if (preg_match('/^[a-f0-9]{64}$/D', $token) !== 1 || is_link($root)) return null;
$real = realpath($root);
if ($real === false || !is_dir($real) || !is_writable($real)) return null;
$directory = $real . DIRECTORY_SEPARATOR . self::DIRECTORY;
clearstatcache(true, $directory);
if (is_link($directory)) return null;
if (!is_dir($directory) && !@mkdir($directory, 0700) && !is_dir($directory)) return null;
clearstatcache(true, $directory);
if (is_link($directory) || realpath($directory) !== $directory || !is_dir($directory)) return null;
if (!@chmod($directory, 0700)) return null;
$directoryStat = @stat($directory);
if ($directoryStat === false || (PHP_OS_FAMILY !== 'Windows' && ($directoryStat['mode'] & 077) !== 0)) return null;
$path = $directory . DIRECTORY_SEPARATOR . sprintf('%03d.cache', hexdec(substr($token, 0, 4)) % self::SLOTS);
clearstatcache(true, $path);
if (is_link($path) || (file_exists($path) && !is_file($path))) return null;
$handle = @fopen($path, 'x+b');
if ($handle === false) $handle = @fopen($path, 'r+b');
if ($handle === false) return null;
clearstatcache(true, $path);
$opened = fstat($handle); $current = @lstat($path);
if (is_link($path) || realpath($path) !== $path || $opened === false || $current === false
|| ($opened['mode'] & 0170000) !== 0100000 || ($current['mode'] & 0170000) !== 0100000
|| $opened['dev'] !== $current['dev'] || $opened['ino'] !== $current['ino']
|| $opened['nlink'] !== 1 || $current['nlink'] !== 1
|| !@chmod($path, 0600)) { fclose($handle); return null; }
return $handle;
}
private static function read($handle, string $token, bool $fresh = true): ?array
{
$size = (fstat($handle) ?: [])['size'] ?? 0;
if ($size < 1 || $size > self::MAX_RECORD_BYTES || !rewind($handle)) return null;
$raw = stream_get_contents($handle, self::MAX_RECORD_BYTES + 1);
try { $data = is_string($raw) ? json_decode($raw, true, 8, JSON_THROW_ON_ERROR) : null; }
catch (\Throwable $ignored) { return null; }
if (!is_array($data) || ($data['v'] ?? null) !== self::VERSION
|| !is_string($data['token'] ?? null) || !hash_equals($token, $data['token'])
|| !self::validId($data['id'] ?? null)
|| !is_string($data['generation'] ?? null) || preg_match('/^[a-f0-9]{32}$/D', $data['generation']) !== 1
|| !is_numeric($data['created'] ?? null) || !is_numeric($data['expires'] ?? null)) return null;
$created = (float) $data['created']; $expires = (float) $data['expires']; $now = microtime(true);
if (!is_finite($created) || !is_finite($expires) || $expires <= $created || $expires - $created > 120.001
|| ($fresh && ($now < $created - 1 || $now >= $expires))) return null;
return $data;
}
private static function write($handle, array $record): bool
{
$raw = json_encode($record, JSON_THROW_ON_ERROR);
if (strlen($raw) > self::MAX_RECORD_BYTES || !self::clear($handle)) return false;
if (@fwrite($handle, $raw) !== strlen($raw) || !@fflush($handle)) { self::clear($handle); return false; }
return true;
}
private static function clear($handle): bool { return @rewind($handle) && @ftruncate($handle, 0) && @fflush($handle); }
private static function validId($id): bool { return is_string($id) && preg_match('/^[a-zA-Z0-9_-]{1,128}$/D', $id) === 1; }
private static function check(float $deadline, ?callable $abort): void
{
if ($abort !== null && $abort()) throw new \RuntimeException('AI_ASSISTANT_CANCELLED');
if (microtime(true) >= $deadline) throw new \RuntimeException('REQUIRED_FILES_TIMEOUT');
}
}