155 lines
8.2 KiB
PHP
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');
|
|
}
|
|
}
|