CodeIgniter 4 website:
- Catalog 999 SGK (lớp 1-12) từ API, lọc theo lớp/môn/NXB, tìm kiếm
- Trang chủ grouped browse (bậc học -> lớp -> môn, ưu tiên môn quan trọng)
- Chi tiết sách: metadata đầy đủ, cover nghệ thuật /thumb/{W}x{H}/
- Đọc online qua gate captcha + countdown, PDF viewer pdf.js
- Download: captcha + token một lần + auto cache-bust version
- Tự đồng bộ catalog/cover khi API thay đổi (refresh:catalog)
- Legal: DMCA, miễn trừ trách nhiệm, điều khoản, bảo mật, liên hệ
133 lines
4.1 KiB
PHP
133 lines
4.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controllers;
|
|
|
|
use App\Libraries\BookApi;
|
|
use App\Libraries\Captcha;
|
|
use App\Libraries\Mongo;
|
|
use App\Libraries\PdfAccess;
|
|
|
|
/**
|
|
* Access gate: captcha + countdown before viewing/downloading a book's PDF.
|
|
*/
|
|
class Download extends BaseController
|
|
{
|
|
private BookApi $api;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->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;
|
|
}
|
|
}
|