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,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* BaseController provides a convenient place for loading components
|
||||
* and performing functions that are needed by all your controllers.
|
||||
*
|
||||
* Extend this class in any new controllers:
|
||||
* ```
|
||||
* class Home extends BaseController
|
||||
* ```
|
||||
*
|
||||
* For security, be sure to declare any new methods as protected or private.
|
||||
*/
|
||||
abstract class BaseController extends Controller
|
||||
{
|
||||
/**
|
||||
* Be sure to declare properties for any property fetch you initialized.
|
||||
* The creation of dynamic property is deprecated in PHP 8.2.
|
||||
*/
|
||||
|
||||
// protected $session;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
|
||||
{
|
||||
// Load here all helpers you want to be available in your controllers that extend BaseController.
|
||||
// Caution: Do not put the this below the parent::initController() call below.
|
||||
// $this->helpers = ['form', 'url'];
|
||||
|
||||
// Caution: Do not edit this line.
|
||||
parent::initController($request, $response, $logger);
|
||||
|
||||
// Preload any models, libraries, etc, here.
|
||||
// $this->session = service('session');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Libraries\BookApi;
|
||||
use App\Libraries\Mongo;
|
||||
|
||||
class Books extends BaseController
|
||||
{
|
||||
private BookApi $api;
|
||||
|
||||
/** School level groupings for the browse homepage. */
|
||||
private const LEVELS = [
|
||||
['key' => 'th', 'label' => 'Bậc Tiểu học', 'grades' => [1, 2, 3, 4, 5]],
|
||||
['key' => 'thcs','label' => 'Bậc THCS', 'grades' => [6, 7, 8, 9]],
|
||||
['key' => 'thpt','label' => 'Bậc THPT', 'grades' => [10, 11, 12]],
|
||||
];
|
||||
|
||||
/**
|
||||
* Subject display order: important subjects first, the rest alphabetical.
|
||||
* Values are matched after diacritics/case normalization (e.g. "Vật lí" → "vat li").
|
||||
*/
|
||||
private const SUBJECT_PRIORITY = [
|
||||
'Toán' => 10,
|
||||
'Đại số' => 10,
|
||||
'Giải tích' => 10,
|
||||
'Hình học' => 10,
|
||||
'Tiếng Việt' => 20,
|
||||
'Ngữ văn' => 20,
|
||||
'Tiếng Anh' => 30,
|
||||
'Vật lí' => 40,
|
||||
'Hóa học' => 50,
|
||||
'Sinh học' => 60,
|
||||
'Lịch sử' => 70,
|
||||
'Địa lí' => 80,
|
||||
'Giáo dục công dân' => 90,
|
||||
'Giáo dục Kinh tế và Pháp luật' => 95,
|
||||
'Giáo dục Quốc phòng' => 96,
|
||||
'Giáo dục thể chất' => 97,
|
||||
'Tin học' => 110,
|
||||
'Công nghệ' => 120,
|
||||
'Kĩ thuật' => 125,
|
||||
'Tự nhiên và Xã hội' => 130,
|
||||
'Khoa học' => 135,
|
||||
'Đạo đức' => 140,
|
||||
'Hoạt động trải nghiệm' => 150,
|
||||
'Âm nhạc' => 160,
|
||||
'Mĩ thuật' => 170,
|
||||
'Atlat Địa lí' => 181,
|
||||
'Bài giải' => 199,
|
||||
];
|
||||
|
||||
/** Cache of normalized subject → rank. */
|
||||
private array $subjectRankCache = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->api = new BookApi();
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog: browse view when no filters; grouped, paginated list when filtered.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$grade = $this->request->getGet('lop');
|
||||
$publisher = $this->request->getGet('nxb');
|
||||
$subject = $this->request->getGet('mon');
|
||||
$q = trim((string) $this->request->getGet('q'));
|
||||
$page = max(1, (int) ($this->request->getGet('trang') ?: 1));
|
||||
$perPage = 48;
|
||||
|
||||
try {
|
||||
$books = $this->api->catalog();
|
||||
} catch (\Throwable $e) {
|
||||
return view('pages/error_api', ['message' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
$isFiltered = ($grade !== null && $grade !== '')
|
||||
|| ($publisher !== null && $publisher !== '')
|
||||
|| ($subject !== null && $subject !== '')
|
||||
|| $q !== '';
|
||||
|
||||
// ------- Browse homepage (no filters): level → grade → subjects -------
|
||||
if (! $isFiltered) {
|
||||
return $this->browseView($books);
|
||||
}
|
||||
|
||||
// ------- Filtered list view -------
|
||||
$filtered = array_values(array_filter($books, function ($b) use ($grade, $publisher, $subject, $q) {
|
||||
if ($grade !== null && $grade !== '' && (string) $b['grade'] !== (string) $grade) return false;
|
||||
if ($publisher !== null && $publisher !== '' && $b['publisher'] !== $publisher) return false;
|
||||
if ($subject !== null && $subject !== '' && $b['subject'] !== $subject) return false;
|
||||
if ($q !== '' && mb_strpos($b['search'] ?? '', mb_strtolower($q)) === false) return false;
|
||||
return true;
|
||||
}));
|
||||
|
||||
// Group key: by subject when a grade is chosen, else by grade
|
||||
$hasGrade = $grade !== null && $grade !== '';
|
||||
$groupFn = $hasGrade
|
||||
? fn($b) => $b['subject'] !== '' ? $b['subject'] : 'Khác'
|
||||
: fn($b) => $b['grade'] !== '' ? 'Lớp ' . $b['grade'] : 'Không phân lớp';
|
||||
|
||||
// sort: group label, then title (within grade groups keep subject order too)
|
||||
if ($hasGrade) {
|
||||
usort($filtered, function ($a, $b2) use ($groupFn) {
|
||||
$cmp = $this->compareSubjects($groupFn($a), $groupFn($b2));
|
||||
return $cmp ?: strcmp((string) $a['title'], (string) $b2['title']);
|
||||
});
|
||||
} else {
|
||||
usort($filtered, function ($a, $b2) {
|
||||
$ga = (int) ($a['grade'] ?: 99);
|
||||
$gb = (int) ($b2['grade'] ?: 99);
|
||||
return $ga <=> $gb
|
||||
?: $this->compareSubjects((string) $a['subject'], (string) $b2['subject'])
|
||||
?: strcmp((string) $a['title'], (string) $b2['title']);
|
||||
});
|
||||
}
|
||||
|
||||
$total = count($filtered);
|
||||
$totalPage = max(1, (int) ceil($total / $perPage));
|
||||
$page = min($page, $totalPage);
|
||||
$slice = array_slice($filtered, ($page - 1) * $perPage, $perPage);
|
||||
|
||||
// group the page slice for rendering
|
||||
$grouped = [];
|
||||
foreach ($slice as $b) {
|
||||
$grouped[$groupFn($b)][] = $b;
|
||||
}
|
||||
if ($hasGrade) {
|
||||
// group by subject, ordered by subject priority
|
||||
uksort($grouped, fn($a, $b2) => $this->compareSubjects($a, $b2));
|
||||
} else {
|
||||
ksort($grouped, SORT_NATURAL);
|
||||
}
|
||||
|
||||
return view('books/index', [
|
||||
'grouped' => $grouped,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'totalPage' => $totalPage,
|
||||
'grades' => $this->gradeList($books),
|
||||
'publishers' => $this->facet($books, 'publisher'),
|
||||
'subjects' => $this->facet($books, 'subject'),
|
||||
'filters' => ['lop' => $grade, 'nxb' => $publisher, 'mon' => $subject, 'q' => $q],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the grouped browse homepage.
|
||||
*/
|
||||
private function browseView(array $books): string
|
||||
{
|
||||
$publishers = $this->facet($books, 'publisher');
|
||||
|
||||
// grade => [subject => count], grade => total
|
||||
$byGrade = [];
|
||||
foreach ($books as $b) {
|
||||
$g = (string) $b['grade'];
|
||||
if ($g === '') {
|
||||
$g = '0';
|
||||
}
|
||||
$byGrade[$g]['total'] = ($byGrade[$g]['total'] ?? 0) + 1;
|
||||
$s = $b['subject'] !== '' ? $b['subject'] : 'Khác';
|
||||
$byGrade[$g]['subjects'][$s] = ($byGrade[$g]['subjects'][$s] ?? 0) + 1;
|
||||
// keep up to 5 cover previews per grade (spread across subjects)
|
||||
$used = array_column($byGrade[$g]['covers'] ?? [], 'subject');
|
||||
if (count($byGrade[$g]['covers'] ?? []) < 5 && ! in_array($s, $used, true)) {
|
||||
$byGrade[$g]['covers'][] = ['id' => $b['id'], 'title' => $b['title'], 'subject' => $s];
|
||||
}
|
||||
}
|
||||
|
||||
$levels = [];
|
||||
foreach (self::LEVELS as $lv) {
|
||||
$grades = [];
|
||||
foreach ($lv['grades'] as $g) {
|
||||
if (isset($byGrade[(string) $g])) {
|
||||
$subs = $byGrade[(string) $g]['subjects'] ?? [];
|
||||
uksort($subs, fn($a, $b2) => $this->compareSubjects($a, $b2));
|
||||
$grades[] = [
|
||||
'grade' => $g,
|
||||
'total' => $byGrade[(string) $g]['total'],
|
||||
'subjects'=> $subs,
|
||||
'covers' => $byGrade[(string) $g]['covers'] ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
if ($grades) {
|
||||
$levels[] = ['label' => $lv['label'], 'grades' => $grades];
|
||||
}
|
||||
}
|
||||
|
||||
return view('books/browse', [
|
||||
'total' => count($books),
|
||||
'levels' => $levels,
|
||||
'publishers' => $publishers,
|
||||
]);
|
||||
}
|
||||
|
||||
private function gradeList(array $books): array
|
||||
{
|
||||
$grades = [];
|
||||
foreach ($books as $b) {
|
||||
if ($b['grade'] !== '') {
|
||||
$grades[(int) $b['grade']] = true;
|
||||
}
|
||||
}
|
||||
$grades = array_keys($grades);
|
||||
sort($grades);
|
||||
return $grades;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparator for subjects: priority rank first, then Vietnamese-awarded
|
||||
* alphabetical (normalized to strip diacritics). "Khác" / empty last.
|
||||
*/
|
||||
private function compareSubjects(string $a, string $b): int
|
||||
{
|
||||
$ra = $this->subjectRank($a);
|
||||
$rb = $this->subjectRank($b);
|
||||
if ($ra !== $rb) {
|
||||
return $ra <=> $rb;
|
||||
}
|
||||
// equal rank → compare stripped-of-diacritics names, then raw names
|
||||
$na = $this->normalizeSubject($a);
|
||||
$nb = $this->normalizeSubject($b);
|
||||
if ($na !== $nb) {
|
||||
return strcmp($na, $nb);
|
||||
}
|
||||
return strcmp($a, $b);
|
||||
}
|
||||
|
||||
/** Rank for a subject name: priority index, 500 for unknown, 999 for "Khác"/empty. */
|
||||
private function subjectRank(string $name): int
|
||||
{
|
||||
$key = $this->normalizeSubject($name);
|
||||
if ($key === '' || $key === 'khac') {
|
||||
return 999;
|
||||
}
|
||||
if (isset($this->subjectRankCache[$key])) {
|
||||
return $this->subjectRankCache[$key];
|
||||
}
|
||||
$rank = 500;
|
||||
foreach (self::SUBJECT_PRIORITY as $subject => $r) {
|
||||
if ($this->normalizeSubject($subject) === $key) {
|
||||
$rank = $r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->subjectRankCache[$key] = $rank;
|
||||
return $rank;
|
||||
}
|
||||
|
||||
/** Lowercase + strip diacritics + collapse whitespace for matching. */
|
||||
private function normalizeSubject(string $s): string
|
||||
{
|
||||
static $map = null;
|
||||
if ($map === null) {
|
||||
$map = [
|
||||
'à'=>'a','á'=>'a','ạ'=>'a','ả'=>'a','ã'=>'a','â'=>'a','ầ'=>'a','ấ'=>'a','ậ'=>'a','ẩ'=>'a','ẫ'=>'a','ă'=>'a','ằ'=>'a','ắ'=>'a','ặ'=>'a','ẳ'=>'a','ẵ'=>'a',
|
||||
'è'=>'e','é'=>'e','ẹ'=>'e','ẻ'=>'e','ẽ'=>'e','ê'=>'e','ề'=>'e','ế'=>'e','ệ'=>'e','ể'=>'e','ễ'=>'e',
|
||||
'ì'=>'i','í'=>'i','ị'=>'i','ỉ'=>'i','ĩ'=>'i',
|
||||
'ò'=>'o','ó'=>'o','ọ'=>'o','ỏ'=>'o','õ'=>'o','ô'=>'o','ồ'=>'o','ố'=>'o','ộ'=>'o','ổ'=>'o','ỗ'=>'o','ơ'=>'o','ờ'=>'o','ớ'=>'o','ợ'=>'o','ở'=>'o','ỡ'=>'o',
|
||||
'ù'=>'u','ú'=>'u','ụ'=>'u','ủ'=>'u','ũ'=>'u','ư'=>'u','ừ'=>'u','ứ'=>'u','ự'=>'u','ử'=>'u','ữ'=>'u',
|
||||
'ỳ'=>'y','ý'=>'y','ỵ'=>'y','ỷ'=>'y','ỹ'=>'y',
|
||||
'đ'=>'d','ĩ'=>'i','ũ'=>'u',
|
||||
];
|
||||
}
|
||||
$s = mb_strtolower($s);
|
||||
$s = strtr($s, $map);
|
||||
return (string) preg_replace('/\s+/', ' ', trim($s));
|
||||
}
|
||||
|
||||
private function facet(array $books, string $key): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($books as $b) {
|
||||
$out[$b[$key]] = ($out[$b[$key]] ?? 0) + 1;
|
||||
}
|
||||
if ($key === 'subject') {
|
||||
uksort($out, fn($a, $b2) => $this->compareSubjects($a, $b2));
|
||||
} else {
|
||||
uksort($out, fn($a, $b2) => strcoll($a, $b2));
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Book detail + PDF viewer.
|
||||
*/
|
||||
public function show(string $id)
|
||||
{
|
||||
$book = $this->api->book($id);
|
||||
if (! $book) {
|
||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
$stats = Mongo::col('stats')->findOne(['_id' => 'book:' . $id]);
|
||||
$downloads = $stats['downloads'] ?? 0;
|
||||
|
||||
return view('books/show', [
|
||||
'book' => $book,
|
||||
'downloads' => $downloads,
|
||||
'countdown' => (int) env('download.countdown', 15),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
class Home extends BaseController
|
||||
{
|
||||
public function index(): string
|
||||
{
|
||||
return view('welcome_message');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Libraries\BookApi;
|
||||
|
||||
/**
|
||||
* Proxies cover + inline PDF streaming from the upstream API.
|
||||
*/
|
||||
class Media extends BaseController
|
||||
{
|
||||
private BookApi $api;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->api = new BookApi();
|
||||
}
|
||||
|
||||
public function cover(string $id)
|
||||
{
|
||||
$book = $this->api->book($id);
|
||||
if (! $book || ! $book['coverEndpoint']) {
|
||||
$this->response->setStatusCode(404);
|
||||
return;
|
||||
}
|
||||
|
||||
// serve from local disk cache once fetched
|
||||
$dir = WRITEPATH . 'cache/covers';
|
||||
$file = $dir . '/' . preg_replace('/[^a-zA-Z0-9_-]/', '_', $id) . '.jpg';
|
||||
if (! is_file($file)) {
|
||||
$tmp = $file . '.tmp' . getmypid();
|
||||
$ok = $this->api->fetchToFile($book['coverEndpoint'], $tmp);
|
||||
if ($ok) {
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0775, true);
|
||||
}
|
||||
rename($tmp, $file);
|
||||
} else {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
if (is_file($file)) {
|
||||
header('Content-Type: image/jpeg');
|
||||
header('Content-Length: ' . (string) filesize($file));
|
||||
header('Cache-Control: public, max-age=604800');
|
||||
readfile($file);
|
||||
exit;
|
||||
}
|
||||
|
||||
$this->response->setHeader('Cache-Control', 'public, max-age=86400');
|
||||
$this->api->stream($book['coverEndpoint']);
|
||||
exit;
|
||||
}
|
||||
|
||||
public function pdf(string $id)
|
||||
{
|
||||
// the PDF stream requires an access grant (captcha-verified session)
|
||||
if (! \App\Libraries\PdfAccess::has($id)) {
|
||||
return $this->response->setStatusCode(403)->setJSON([
|
||||
'error' => 'Truy cập bị từ chối. Vui lòng xác nhận captcha tại trang sách để xem nội dung.',
|
||||
]);
|
||||
}
|
||||
|
||||
$book = $this->api->book($id);
|
||||
if (! $book || ! $book['pdfEndpoint']) {
|
||||
$this->response->setStatusCode(404);
|
||||
return;
|
||||
}
|
||||
// inline disposition: viewer only, not a download
|
||||
header('Content-Disposition: inline');
|
||||
header('X-Robots-Tag: noindex');
|
||||
$this->api->stream($book['pdfEndpoint']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
class Pages extends BaseController
|
||||
{
|
||||
public function view(string $page)
|
||||
{
|
||||
if (! is_file(APPPATH . 'Views/pages/' . $page . '.php')) {
|
||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
return view('pages/' . $page, ['page' => $page]);
|
||||
}
|
||||
|
||||
public function contact()
|
||||
{
|
||||
$sent = false;
|
||||
$error = null;
|
||||
|
||||
if ($this->request->getPost('submit')) {
|
||||
$name = trim((string) $this->request->getPost('name'));
|
||||
$email = trim((string) $this->request->getPost('email'));
|
||||
$message = trim((string) $this->request->getPost('message'));
|
||||
$honey = trim((string) $this->request->getPost('website'));
|
||||
|
||||
if ($honey !== '') {
|
||||
$sent = true; // honeypot: silently accept
|
||||
} elseif (! $name || ! filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($message) < 10) {
|
||||
$error = 'Vui lòng điền đầy đủ và đúng định dạng các trường.';
|
||||
} else {
|
||||
// store in mongo (no mail server in local dev)
|
||||
\App\Libraries\Mongo::col('contact_messages')->insertOne([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'message' => $message,
|
||||
'ip' => $this->request->getIPAddress(),
|
||||
'at' => new \MongoDB\BSON\UTCDateTime(),
|
||||
'handled' => false,
|
||||
]);
|
||||
$sent = true;
|
||||
}
|
||||
}
|
||||
|
||||
return view('pages/contact', ['sent' => $sent, 'error' => $error]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Libraries\BookApi;
|
||||
use App\Libraries\CoverArt;
|
||||
|
||||
/**
|
||||
* Thumbnail endpoint: /thumb/{W}x{H}/{id}.jpg
|
||||
* Lazily generates art-directed thumbnails from the cached original cover.
|
||||
*/
|
||||
class Thumb extends BaseController
|
||||
{
|
||||
private BookApi $api;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->api = new BookApi();
|
||||
}
|
||||
|
||||
public function serve(string $size, string $file)
|
||||
{
|
||||
// parse size + id
|
||||
if (! preg_match('/^(\d{2,4})x(\d{2,4})$/', $size, $m)) {
|
||||
return $this->response->setStatusCode(400);
|
||||
}
|
||||
[, $w, $h] = $m;
|
||||
$id = preg_replace('/\.jpg$/', '', $file);
|
||||
if (! $id || ! preg_match('/^[a-zA-Z0-9._-]+$/', $id)) {
|
||||
return $this->response->setStatusCode(400);
|
||||
}
|
||||
|
||||
$book = $this->api->book($id);
|
||||
if (! $book || ! $book['coverEndpoint']) {
|
||||
return $this->response->setStatusCode(404);
|
||||
}
|
||||
|
||||
// ensure the original is cached locally (fetch on demand)
|
||||
$dir = WRITEPATH . 'cache/covers';
|
||||
$orig = $dir . '/' . preg_replace('/[^a-zA-Z0-9_-]/', '_', $id) . '.jpg';
|
||||
if (! is_file($orig)) {
|
||||
$tmp = $orig . '.tmp' . getmypid();
|
||||
if ($this->api->fetchToFile($book['coverEndpoint'], $tmp)) {
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0775, true);
|
||||
}
|
||||
rename($tmp, $orig);
|
||||
} else {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
if (! is_file($orig)) {
|
||||
return $this->response->setStatusCode(404);
|
||||
}
|
||||
|
||||
$thumb = CoverArt::thumb($id, $orig, $book, (int) $w, (int) $h);
|
||||
if (! $thumb || ! is_file($thumb)) {
|
||||
return $this->response->setStatusCode(404);
|
||||
}
|
||||
|
||||
header('Content-Type: image/jpeg');
|
||||
header('Content-Length: ' . (string) filesize($thumb));
|
||||
header('Cache-Control: public, max-age=604800');
|
||||
readfile($thumb);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user