api = new BookApi(); helper(['text', 'form']); } /** * Reader route /doc/{id}: captcha gate first, then the reading page. */ public function page(string $id) { $book = $this->api->book($id); if (! $book) { throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); } $error = null; if ($this->request->getPost('download')) { if (Captcha::verify($this->request->getPost('captcha'))) { PdfAccess::grant($id); return redirect()->to(site_url('doc/' . $id)); } $error = 'Mã captcha không đúng hoặc đã hết hạn. Vui lòng thử lại.'; } // access granted → reading page with viewer if (PdfAccess::has($id)) { return view('books/read', ['book' => $book]); } // otherwise → captcha gate return view('books/download', [ 'book' => $book, 'error' => $error, 'countdown' => (int) env('download.countdown', 15), ]); } public function captcha() { Captcha::render(Captcha::issue()); } /** * Token-gated actual download. Requires an active PDF access grant. */ public function fetch(string $id, string $token) { if (! PdfAccess::has($id)) { return view('pages/error_api', ['message' => 'Phiên truy cập đã hết hạn. Vui lòng xác nhận captcha lại để tải xuống.']); } if (! $this->consumeToken($id, $token)) { return view('pages/error_api', ['message' => 'Liên kết tải về không hợp lệ hoặc đã hết hạn. Vui lòng thực hiện lại quy trình tải xuống.']); } $book = $this->api->book($id); if (! $book) { throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); } // stats $stats = Mongo::col('stats'); $stats->updateOne(['_id' => 'book:' . $id], ['$inc' => ['downloads' => 1]], ['upsert' => true]); $stats->updateOne(['_id' => 'global'], ['$inc' => ['downloads' => 1]], ['upsert' => true]); $safeName = preg_replace('/[^\p{L}\p{N} _.-]/u', '_', $book['title']) . '.pdf'; $this->response->setHeader('Content-Type', 'application/pdf'); $this->api->stream($book['pdfEndpoint'], $safeName); exit; } /** * Issue a one-time download token (requires access grant). */ public function token(string $id) { if (! $this->request->isAJAX() && strtolower($this->request->getMethod()) !== 'post') { return $this->response->setStatusCode(405); } if (! PdfAccess::has($id)) { return $this->response->setStatusCode(403)->setJSON(['ok' => false, 'error' => 'expired']); } $token = bin2hex(random_bytes(16)); Mongo::col('download_tokens')->insertOne([ '_id' => $token, 'bookId' => $id, 'session' => session_id(), 'expiresAt' => new \MongoDB\BSON\UTCDateTime((time() + 300) * 1000), 'used' => false, ]); return $this->response->setJSON(['ok' => true, 'url' => site_url("tai-ve/{$id}/{$token}")]); } private function consumeToken(string $id, string $token): bool { $col = Mongo::col('download_tokens'); $doc = $col->findOne(['_id' => $token, 'bookId' => $id, 'used' => false]); if (! $doc) { return false; } if ($doc['expiresAt']->toDateTime()->getTimestamp() < time()) { $col->deleteOne(['_id' => $token]); return false; } $col->updateOne(['_id' => $token], ['$set' => ['used' => true]]); return true; } }