*/ public static function roots(): array { $candidates = [ (string)Config::get('storage.models_dir', ''), SOON_SERVER_ROOT . '/soonModels', ]; $out = []; foreach ($candidates as $path) { $path = rtrim($path, '/\\'); if ($path === '' || isset($out[$path])) { continue; } if (@is_dir($path) && @is_readable($path)) { $out[$path] = true; } } return array_keys($out); } /** @return list> */ public static function list(): array { $apiBase = rtrim((string)Config::get('site.base_url', ''), '/'); $items = []; $seen = []; foreach (self::roots() as $root) { foreach (self::scanSoonNames($root) as $base) { if (isset($seen[$base])) { continue; } $path = $root . DIRECTORY_SEPARATOR . $base; $item = self::buildItem($path, $base, $apiBase); if ($item !== null) { $seen[$base] = true; $items[] = $item; } } } usort($items, static fn(array $a, array $b): int => strnatcasecmp((string)$a['name'], (string)$b['name'])); return $items; } public static function resolvePath(string $filename): ?string { $base = basename(urldecode($filename)); if ($base === '' || $base[0] === '.' || !self::isSoonName($base)) { return null; } foreach (self::roots() as $root) { $path = $root . DIRECTORY_SEPARATOR . $base; if (@is_file($path) && @is_readable($path)) { return $path; } } return null; } /** @return list */ private static function scanSoonNames(string $root): array { $entries = @scandir($root); if ($entries === false) { return []; } $names = []; foreach ($entries as $entry) { if ($entry === '.' || $entry === '..' || $entry === '' || $entry[0] === '.') { continue; } if (!self::isSoonName($entry)) { continue; } $path = $root . DIRECTORY_SEPARATOR . $entry; if (@is_file($path)) { $names[] = $entry; } } return $names; } private static function isSoonName(string $name): bool { return str_ends_with(strtolower($name), '.soon'); } /** @return array|null */ private static function buildItem(string $path, string $base, string $apiBase): ?array { $size = @filesize($path); if ($size === false || $size <= 0) { return null; } $meta = self::readMetadata((int)$size, $path); $name = pathinfo($base, PATHINFO_FILENAME); if ($meta['title'] !== '') { $name = $meta['title']; } elseif ($meta['name'] !== '') { $name = $meta['name']; } $fileUrl = ($apiBase !== '' ? $apiBase : '') . '/api/v1/soon-models/files/' . rawurlencode($base); return [ 'name' => $name, 'type' => $meta['type'], 'file' => $base, 'file_url' => $fileUrl, 'size' => (int)$size, ]; } /** * @return array{title:string,name:string,type:int} */ private static function readMetadata(int $size, string $path): array { $head = self::readBytes($path, 0, min(self::META_HEAD_BYTES, $size)); $tail = $size > self::META_HEAD_BYTES ? self::readBytes($path, max(0, $size - self::META_TAIL_BYTES), min(self::META_TAIL_BYTES, $size)) : ''; $raw = $head . $tail; $type = 1; if (preg_match('/"soonType"\s*:\s*(\d+)/', $raw, $m)) { $type = (int)$m[1] === 2 ? 2 : 1; } elseif (preg_match('/"backBlackPic"\s*:\s*"(?!")/', $raw)) { $type = 2; } return [ 'title' => self::matchJsonString($raw, 'title'), 'name' => self::matchJsonString($raw, 'name'), 'type' => $type, ]; } private static function readBytes(string $path, int $offset, int $length): string { if ($length <= 0) { return ''; } $fh = @fopen($path, 'rb'); if ($fh === false) { return ''; } if ($offset > 0) { fseek($fh, $offset); } $data = fread($fh, $length); fclose($fh); return is_string($data) ? $data : ''; } private static function matchJsonString(string $raw, string $key): string { if (!preg_match('/"' . preg_quote($key, '/') . '"\s*:\s*"((?:\\\\.|[^"\\\\])*)"/u', $raw, $m)) { return ''; } $decoded = json_decode('"' . $m[1] . '"'); return is_string($decoded) ? trim($decoded) : ''; } }