Sách Giáo Khoa Việt Nam - sachgiaokhoa.org
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ệ
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user