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:
sachgiaokhoaorg
2026-08-23 07:05:49 +00:00
commit e6b265db99
494 changed files with 134829 additions and 0 deletions
+45
View File
@@ -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');
}
}
+309
View File
@@ -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),
]);
}
}
+132
View File
@@ -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;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Controllers;
class Home extends BaseController
{
public function index(): string
{
return view('welcome_message');
}
}
+77
View File
@@ -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;
}
}
+49
View File
@@ -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]);
}
}
+69
View File
@@ -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;
}
}