baseUrl = rtrim((string) env('api.baseUrl', ''), '/'); $this->token = (string) env('api.token', ''); } /** * Fetch catalog, refresh MongoDB cache when stale. Returns array of books. */ public function catalog(bool $forceRefresh = false): array { $col = Mongo::col('catalog'); $cacheTs = $col->findOne(['_id' => 'meta']); $ttl = (int) env('api.cacheTtl', 21600); $fresh = $cacheTs !== null && isset($cacheTs['updatedAt']) && ((time() - (int) ((string) $cacheTs['updatedAt'] / 1000)) < $ttl) && ($cacheTs['count'] ?? 0) > 0; if (! $forceRefresh && $fresh) { return $this->sorted($this->queryAll($col)); } $data = $this->request('/api/books'); $books = $data['books'] ?? []; if (empty($books)) { // fall back to stale cache rather than nothing $stale = $this->queryAll($col); if (! empty($stale)) { return $this->sorted($stale); } throw new Exception('Không tải được danh mục sách từ API nguồn.'); } $newSourceUpdatedAt = (string) ($data['updatedAt'] ?? ''); $oldSourceUpdatedAt = (string) ($cacheTs['sourceUpdatedAt'] ?? ''); $mediaVersion = (string) ($cacheTs['mediaVersion'] ?? ''); // upstream data changed → purge local cover cache + thumbs, bump version // so Cloudflare/browser see covers as new resources (fetched lazily again). if ($forceRefresh || $newSourceUpdatedAt !== $oldSourceUpdatedAt) { foreach (['covers', 'covers/thumbs'] as $rel) { $dir = WRITEPATH . 'cache/' . $rel; if (! is_dir($dir)) { continue; } if ($rel === 'covers/thumbs') { // recursive delete (thumbnails live in {W}x{H} subdirs) self::rrmdir($dir); } else { foreach (glob($dir . '/*') ?: [] as $f) { if (is_file($f)) { @unlink($f); } } } } $mediaVersion = (string) time(); } $col->deleteMany(['bookId' => ['$exists' => true]]); $docs = []; foreach ($books as $b) { $docs[] = $this->toDoc($b); } $col->insertMany($docs); $col->updateOne( ['_id' => 'meta'], ['$set' => [ 'updatedAt' => new \MongoDB\BSON\UTCDateTime(), 'count' => count($docs), 'sourceUpdatedAt' => $newSourceUpdatedAt, 'mediaVersion' => $mediaVersion, ]], ['upsert' => true] ); return $this->sorted($this->queryAll($col)); } private function queryAll(\MongoDB\Collection $col): array { return $col->find(['bookId' => ['$exists' => true]], ['projection' => ['_id' => 0]])->toArray(); } private function sorted(array $books): array { usort($books, function ($a, $b) { $ga = (int) ($a['grade'] ?? 0); $gb = (int) ($b['grade'] ?? 0); if ($ga !== $gb) { return $ga <=> $gb; } return strcmp((string) ($a['subject'] ?? ''), (string) ($b['subject'] ?? '')) ?: strcmp((string) ($a['title'] ?? ''), (string) ($b['title'] ?? '')); }); return $books; } public function book(string $id): ?array { foreach ($this->catalog() as $b) { if (($b['id'] ?? null) === $id) { return $this->docToArray($b); } } return null; } /** * Stream a resource (pdf/cover) from upstream to the client with auth injected. */ public function stream(string $endpoint, string $downloadName = null): void { $url = $this->baseUrl . $endpoint; $ch = curl_init($url); // forward range headers for pdf.js partial requests $range = $_SERVER['HTTP_RANGE'] ?? null; curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => array_filter([ 'Authorization: Bearer ' . $this->token, $range ? 'Range: ' . $range : null, ]), CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 0, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) { $trim = trim($line); foreach (['content-type', 'content-length', 'accept-ranges', 'content-range', 'content-disposition', 'cache-control'] as $h) { if (stripos($trim, $h) === 0) { if ($h === 'content-disposition') { return strlen($line); // we set our own } header($trim); } } return strlen($line); }, CURLOPT_WRITEFUNCTION => function ($ch, $data) { echo $data; if (ob_get_level()) { ob_flush(); } flush(); return strlen($data); }, ]); if ($downloadName) { header('Content-Disposition: attachment; filename="' . rawurlencode($downloadName) . '"'); } $ok = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if (! $ok || $code >= 400) { http_response_code($ok ? $code : 502); } } /** * Download a resource from upstream to a local file. Returns bytes written or false. */ public function fetchToFile(string $endpoint, string $dest): int|false { $ch = curl_init($this->baseUrl . $endpoint); $fp = fopen($dest, 'wb'); if (! $fp) { return false; } curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->token], CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 60, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_FILE => $fp, ]); curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $size = (int) curl_getinfo($ch, CURLINFO_SIZE_DOWNLOAD); $type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE); curl_close($ch); fclose($fp); if ($code !== 200 || $size === 0 || ! str_starts_with($type, 'image/')) { return false; } return $size; } private function request(string $path): array { $ch = curl_init($this->baseUrl . $path); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->token], CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 120, CURLOPT_CONNECTTIMEOUT => 15, ]); $body = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code !== 200 || ! is_string($body)) { throw new Exception("API lỗi HTTP {$code}"); } return json_decode($body, true) ?? throw new Exception('API trả về JSON không hợp lệ.'); } private function toDoc(array $b): array { $meta = $b['meta'] ?? []; return [ 'bookId' => $b['id'], 'id' => $b['id'], 'title' => $b['title'] ?? '', 'grade' => (string) ($b['grade'] ?? ''), 'publisher' => $b['publisher'] ?? '', 'subject' => $b['subject'] ?? '', 'localFile' => $b['localFile'] ?? null, 'pdfEndpoint' => $b['pdfEndpoint'] ?? null, 'coverEndpoint' => $b['coverEndpoint'] ?? null, 'meta' => [ 'authors' => $meta['authors'] ?? null, 'publisherFull' => $meta['publisherFull'] ?? null, 'pages' => $meta['pages'] ?? null, 'sizeMB' => $meta['sizeMB'] ?? null, 'isbn' => $meta['isbn'] ?? null, 'price' => $meta['price'] ?? null, 'pdfVersion' => $meta['pdfVersion'] ?? null, 'creationDate' => $meta['creationDate'] ?? null, 'cover' => $meta['cover'] ?? null, 'coverSource' => $meta['coverSource'] ?? null, ], 'search' => mb_strtolower(($b['title'] ?? '') . ' ' . ($b['subject'] ?? '') . ' ' . ($b['publisher'] ?? '')), ]; } private function docToArray(array|object $doc): array { $doc = (array) $doc; if (isset($doc['meta']) && is_object($doc['meta'])) { $doc['meta'] = (array) $doc['meta']; } unset($doc['_id']); return $doc; } }