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
View File
+280
View File
@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
use Exception;
/**
* Client for the upstream SGK books API, with MongoDB catalog cache.
*/
class BookApi
{
/** Recursively remove a directory tree (contents + dir). */
public static function rrmdir(string $dir): void
{
$items = glob($dir . '/*') ?: [];
foreach ($items as $item) {
is_dir($item) ? self::rrmdir($item) : @unlink($item);
}
@rmdir($dir);
}
private string $baseUrl;
private string $token;
public function __construct()
{
$this->baseUrl = rtrim((string) env('api.baseUrl', ''), '/');
$this->token = (string) env('api.token', '');
}
/**
* Fetch catalog, refresh MongoDB cache when stale. Returns array of books.
*/
public function catalog(bool $forceRefresh = false): array
{
$col = Mongo::col('catalog');
$cacheTs = $col->findOne(['_id' => 'meta']);
$ttl = (int) env('api.cacheTtl', 21600);
$fresh = $cacheTs !== null
&& isset($cacheTs['updatedAt'])
&& ((time() - (int) ((string) $cacheTs['updatedAt'] / 1000)) < $ttl)
&& ($cacheTs['count'] ?? 0) > 0;
if (! $forceRefresh && $fresh) {
return $this->sorted($this->queryAll($col));
}
$data = $this->request('/api/books');
$books = $data['books'] ?? [];
if (empty($books)) {
// fall back to stale cache rather than nothing
$stale = $this->queryAll($col);
if (! empty($stale)) {
return $this->sorted($stale);
}
throw new Exception('Không tải được danh mục sách từ API nguồn.');
}
$newSourceUpdatedAt = (string) ($data['updatedAt'] ?? '');
$oldSourceUpdatedAt = (string) ($cacheTs['sourceUpdatedAt'] ?? '');
$mediaVersion = (string) ($cacheTs['mediaVersion'] ?? '');
// upstream data changed → purge local cover cache + thumbs, bump version
// so Cloudflare/browser see covers as new resources (fetched lazily again).
if ($forceRefresh || $newSourceUpdatedAt !== $oldSourceUpdatedAt) {
foreach (['covers', 'covers/thumbs'] as $rel) {
$dir = WRITEPATH . 'cache/' . $rel;
if (! is_dir($dir)) {
continue;
}
if ($rel === 'covers/thumbs') {
// recursive delete (thumbnails live in {W}x{H} subdirs)
self::rrmdir($dir);
} else {
foreach (glob($dir . '/*') ?: [] as $f) {
if (is_file($f)) {
@unlink($f);
}
}
}
}
$mediaVersion = (string) time();
}
$col->deleteMany(['bookId' => ['$exists' => true]]);
$docs = [];
foreach ($books as $b) {
$docs[] = $this->toDoc($b);
}
$col->insertMany($docs);
$col->updateOne(
['_id' => 'meta'],
['$set' => [
'updatedAt' => new \MongoDB\BSON\UTCDateTime(),
'count' => count($docs),
'sourceUpdatedAt' => $newSourceUpdatedAt,
'mediaVersion' => $mediaVersion,
]],
['upsert' => true]
);
return $this->sorted($this->queryAll($col));
}
private function queryAll(\MongoDB\Collection $col): array
{
return $col->find(['bookId' => ['$exists' => true]], ['projection' => ['_id' => 0]])->toArray();
}
private function sorted(array $books): array
{
usort($books, function ($a, $b) {
$ga = (int) ($a['grade'] ?? 0);
$gb = (int) ($b['grade'] ?? 0);
if ($ga !== $gb) {
return $ga <=> $gb;
}
return strcmp((string) ($a['subject'] ?? ''), (string) ($b['subject'] ?? ''))
?: strcmp((string) ($a['title'] ?? ''), (string) ($b['title'] ?? ''));
});
return $books;
}
public function book(string $id): ?array
{
foreach ($this->catalog() as $b) {
if (($b['id'] ?? null) === $id) {
return $this->docToArray($b);
}
}
return null;
}
/**
* Stream a resource (pdf/cover) from upstream to the client with auth injected.
*/
public function stream(string $endpoint, string $downloadName = null): void
{
$url = $this->baseUrl . $endpoint;
$ch = curl_init($url);
// forward range headers for pdf.js partial requests
$range = $_SERVER['HTTP_RANGE'] ?? null;
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => array_filter([
'Authorization: Bearer ' . $this->token,
$range ? 'Range: ' . $range : null,
]),
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 0,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
$trim = trim($line);
foreach (['content-type', 'content-length', 'accept-ranges', 'content-range', 'content-disposition', 'cache-control'] as $h) {
if (stripos($trim, $h) === 0) {
if ($h === 'content-disposition') {
return strlen($line); // we set our own
}
header($trim);
}
}
return strlen($line);
},
CURLOPT_WRITEFUNCTION => function ($ch, $data) {
echo $data;
if (ob_get_level()) {
ob_flush();
}
flush();
return strlen($data);
},
]);
if ($downloadName) {
header('Content-Disposition: attachment; filename="' . rawurlencode($downloadName) . '"');
}
$ok = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (! $ok || $code >= 400) {
http_response_code($ok ? $code : 502);
}
}
/**
* Download a resource from upstream to a local file. Returns bytes written or false.
*/
public function fetchToFile(string $endpoint, string $dest): int|false
{
$ch = curl_init($this->baseUrl . $endpoint);
$fp = fopen($dest, 'wb');
if (! $fp) {
return false;
}
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->token],
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_FILE => $fp,
]);
curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$size = (int) curl_getinfo($ch, CURLINFO_SIZE_DOWNLOAD);
$type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
fclose($fp);
if ($code !== 200 || $size === 0 || ! str_starts_with($type, 'image/')) {
return false;
}
return $size;
}
private function request(string $path): array
{
$ch = curl_init($this->baseUrl . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->token],
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 120,
CURLOPT_CONNECTTIMEOUT => 15,
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200 || ! is_string($body)) {
throw new Exception("API lỗi HTTP {$code}");
}
return json_decode($body, true) ?? throw new Exception('API trả về JSON không hợp lệ.');
}
private function toDoc(array $b): array
{
$meta = $b['meta'] ?? [];
return [
'bookId' => $b['id'],
'id' => $b['id'],
'title' => $b['title'] ?? '',
'grade' => (string) ($b['grade'] ?? ''),
'publisher' => $b['publisher'] ?? '',
'subject' => $b['subject'] ?? '',
'localFile' => $b['localFile'] ?? null,
'pdfEndpoint' => $b['pdfEndpoint'] ?? null,
'coverEndpoint' => $b['coverEndpoint'] ?? null,
'meta' => [
'authors' => $meta['authors'] ?? null,
'publisherFull' => $meta['publisherFull'] ?? null,
'pages' => $meta['pages'] ?? null,
'sizeMB' => $meta['sizeMB'] ?? null,
'isbn' => $meta['isbn'] ?? null,
'price' => $meta['price'] ?? null,
'pdfVersion' => $meta['pdfVersion'] ?? null,
'creationDate' => $meta['creationDate'] ?? null,
'cover' => $meta['cover'] ?? null,
'coverSource' => $meta['coverSource'] ?? null,
],
'search' => mb_strtolower(($b['title'] ?? '') . ' ' . ($b['subject'] ?? '') . ' ' . ($b['publisher'] ?? '')),
];
}
private function docToArray(array|object $doc): array
{
$doc = (array) $doc;
if (isset($doc['meta']) && is_object($doc['meta'])) {
$doc['meta'] = (array) $doc['meta'];
}
unset($doc['_id']);
return $doc;
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* Simple self-hosted image captcha (GD), answer stored in session.
*/
class Captcha
{
public static function issue(): string
{
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$code = '';
for ($i = 0; $i < 5; $i++) {
$code .= $chars[random_int(0, strlen($chars) - 1)];
}
session()->set('captcha_code', $code);
session()->set('captcha_expires', time() + 600);
return $code;
}
public static function verify(?string $input): bool
{
$code = session()->get('captcha_code');
$exp = session()->get('captcha_expires');
session()->remove('captcha_code');
session()->remove('captcha_expires');
if (! $code || ! $exp || time() > $exp || ! $input) {
return false;
}
return hash_equals(strtoupper(trim($code)), strtoupper(trim($input)));
}
public static function render(string $code): void
{
$w = 180;
$h = 60;
$img = imagecreatetruecolor($w, $h);
$bg = imagecolorallocate($img, 243, 246, 252);
imagefilledrectangle($img, 0, 0, $w, $h, $bg);
// noise
for ($i = 0; $i < 6; $i++) {
$c = imagecolorallocate($img, random_int(190, 230), random_int(190, 230), random_int(220, 245));
imageline($img, random_int(0, $w), random_int(0, $h), random_int(0, $w), random_int(0, $h), $c);
}
for ($i = 0; $i < 220; $i++) {
$c = imagecolorallocate($img, random_int(150, 220), random_int(150, 220), random_int(150, 220));
imagesetpixel($img, random_int(0, $w - 1), random_int(0, $h - 1), $c);
}
$fonts = glob(SYSTEMPATH . 'fonts/*.{ttf,TTF}', GLOB_BRACE) ?: [];
for ($i = 0; $i < strlen($code); $i++) {
$color = imagecolorallocate($img, random_int(10, 90), random_int(30, 100), random_int(120, 200));
$x = 15 + $i * 32 + random_int(-4, 4);
$y = random_int(36, 48);
if ($fonts) {
imagettftext($img, random_int(22, 26), random_int(-12, 12), $x, $y, $color, $fonts[array_rand($fonts)], $code[$i]);
} else {
imagestring($img, 5, $x, $y - 16, $code[$i], $color);
}
}
header('Content-Type: image/png');
header('Cache-Control: no-store, no-cache, must-revalidate');
imagepng($img);
imagedestroy($img);
exit;
}
}
+223
View File
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* Art-directed cover thumbnails served from /thumb/{W}x{H}/{id}.jpg.
* Generated on demand from the cached original cover into
* writable/cache/covers/thumbs/{W}x{H}/ and served from disk afterwards.
*/
class CoverArt
{
/** Allowed thumbnail sizes — requests snap to the nearest one. */
public const SIZES = [
'small' => [240, 320], // grade previews, dense grids
'medium' => [360, 480], // catalog cards
'large' => [540, 720], // detail sidebar / og:image
];
public static function sizes(): array
{
return self::SIZES;
}
public static function thumb(string $id, string $fullFile, array $book = [], int $w = 360, int $h = 480): ?string
{
[$w, $h] = self::closestSize($w, $h);
$dir = dirname($fullFile) . '/thumbs/' . $w . 'x' . $h;
$file = $dir . '/' . basename($fullFile);
if (is_file($file) && filemtime($file) >= filemtime($fullFile)) {
return $file;
}
$src = @imagecreatefromstring((string) file_get_contents($fullFile));
if (! $src) {
return $book ? self::fallback($dir, $file, $book, $w, $h) : null;
}
$sw = imagesx($src);
$sh = imagesy($src);
// landscape low-res = upstream placeholder → designed fallback
if ($sw <= 400 && $sh <= 200) {
imagedestroy($src);
return $book ? self::fallback($dir, $file, $book, $w, $h) : null;
}
// flatten alpha (upstream PNGs have transparency) onto light background
$flat = imagecreatetruecolor($sw, $sh);
imagefill($flat, 0, 0, imagecolorallocate($flat, 232, 236, 244));
imagecopy($flat, $src, 0, 0, 0, 0, $sw, $sh);
imagedestroy($src);
$src = $flat;
// center-crop to target ratio (favor top 40% when cropping height:
// Vietnamese textbook covers carry their title in the upper part)
$targetRatio = $w / $h;
$ratio = $sw / $sh;
if ($ratio > $targetRatio) {
$cw = (int) round($sh * $targetRatio);
$cx = (int) (($sw - $cw) / 2);
$cy = 0;
$ch = $sh;
} else {
$ch = (int) round($sw / $targetRatio);
$cy = (int) max(0, ($sh - $ch) * 0.40);
$cx = 0;
$cw = $sw;
}
$dst = imagecreatetruecolor($w, $h);
imagefill($dst, 0, 0, imagecolorallocate($dst, 232, 236, 244));
imagecopyresampled($dst, $src, 0, 0, $cx, $cy, $w, $h, $cw, $ch);
self::sharpen($dst);
self::save($dst, $dir, $file, 86);
imagedestroy($src);
imagedestroy($dst);
return $file;
}
private static function closestSize(int $w, int $h): array
{
$best = null;
$bestDiff = PHP_INT_MAX;
foreach (self::SIZES as [$tw, $th]) {
$diff = abs($tw - $w) + abs($th - $h);
if ($diff < $bestDiff) {
$bestDiff = $diff;
$best = [$tw, $th];
}
}
return $best;
}
private static function save($img, string $dir, string $file, int $quality): void
{
if (! is_dir($dir)) {
@mkdir($dir, 0775, true);
}
$tmp = $file . '.tmp' . getmypid();
imagejpeg($img, $tmp, $quality);
rename($tmp, $file);
}
/**
* Designed fallback cover: vertical brand gradient, SGK badge,
* subject, wrapped title, grade + publisher footer.
*/
private static function fallback(string $dir, string $file, array $book, int $w, int $h): ?string
{
$dst = imagecreatetruecolor($w, $h);
// vertical gradient: deep blue → azure
$r0 = 30; $g0 = 58; $b0 = 138;
$r1 = 59; $g1 = 130; $b1 = 246;
for ($y = 0; $y < $h; $y++) {
$t = $y / $h;
imageline($dst, 0, $y, $w, $y, imagecolorallocate($dst,
(int) ($r0 + ($r1 - $r0) * $t),
(int) ($g0 + ($g1 - $g0) * $t),
(int) ($b0 + ($b1 - $b0) * $t)));
}
// decorative translucent circles (scale with size)
$s = $w / 360;
$acc = imagecolorallocatealpha($dst, 255, 255, 255, 100);
imagefilledellipse($dst, (int) (400 * $s), (int) (90 * $s), (int) (220 * $s), (int) (220 * $s), $acc);
imagefilledellipse($dst, (int) (80 * $s), (int) (560 * $s), (int) (260 * $s), (int) (260 * $s), $acc);
$white = imagecolorallocate($dst, 255, 255, 255);
$soft = imagecolorallocate($dst, 219, 231, 254);
$gold = imagecolorallocate($dst, 245, 158, 11);
// SGK brand badge
imagefilledrectangle($dst, (int) (24 * $s), (int) (28 * $s), (int) (122 * $s), (int) (66 * $s),
imagecolorallocatealpha($dst, 255, 255, 255, 88));
imagestring($dst, 5, (int) (38 * $s), (int) (39 * $s), 'SGK.ORG', $gold);
// subject
$subject = mb_strtoupper($book['subject'] !== '' ? $book['subject'] : 'Giao khoa');
imagestring($dst, 5, (int) (26 * $s), (int) (120 * $s), self::ascii($subject), $soft);
// wrapped title
$lines = self::wrapAscii((string) ($book['title'] ?? ''), (int) ($w - 52 * $s), 5);
$y = (int) (170 * $s);
foreach ($lines as $line) {
imagestring($dst, 5, (int) (26 * $s), $y, self::ascii($line), $white);
$y += (int) (24 * $s);
if ($y > $h - 110 * $s) break;
}
// footer: grade + publisher
$bottom = trim(($book['grade'] ? 'LOP ' . $book['grade'] . ' . ' : '') . $book['publisher']);
imageline($dst, (int) (26 * $s), (int) ($h - 86 * $s), (int) ($w - 26 * $s), (int) ($h - 86 * $s), $gold);
imagestring($dst, 4, (int) (26 * $s), (int) ($h - 72 * $s), self::ascii($bottom), $gold);
self::save($dst, $dir, $file, 88);
imagedestroy($dst);
return $file;
}
/** Vietnamese diacritics → ASCII for GD built-in fonts. */
private static function ascii(string $s): string
{
$map = [
'à'=>'a','á'=>'a','ạ'=>'a','ả'=>'a','ã'=>'a','â'=>'a','ầ'=>'a','ấ'=>'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','È'=>'E','É'=>'E','Ẹ'=>'E','Ẻ'=>'E','Ẽ'=>'E','Ê'=>'E','ì'=>'i','í'=>'i','ị'=>'i',
'ỉ'=>'i','ĩ'=>'i','Ì'=>'I','Í'=>'I','Ị'=>'I','Ỉ'=>'I','Ĩ'=>'I','ò'=>'o','ó'=>'o','ọ'=>'o','ỏ'=>'o',
'õ'=>'o','ô'=>'o','ồ'=>'o','ố'=>'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','Ù'=>'U','Ú'=>'U',
'Ụ'=>'U','Ủ'=>'U','Ũ'=>'U','Ư'=>'U','ỳ'=>'y','ý'=>'y','ỵ'=>'y','ỷ'=>'y','ỹ'=>'y','Ỳ'=>'Y',
'Ý'=>'Y','Ỵ'=>'Y','Ỷ'=>'Y','Ỹ'=>'Y','đ'=>'d','Đ'=>'D','Ừ'=>'U','Ữ'=>'U',
];
$s = strtr($s, $map);
return preg_replace('/[^\x20-\x7E]/', '', $s) ?: 'SGK';
}
/** Word-wrap for GD font 5 (~9px/char). */
private static function wrapAscii(string $text, int $maxW, int $maxLines): array
{
$maxChars = max(8, (int) ($maxW / 9));
$words = preg_split('/\s+/', trim($text)) ?: [];
$lines = [];
$cur = '';
foreach ($words as $wd) {
$try = $cur === '' ? $wd : "$cur $wd";
if (strlen($try) <= $maxChars) {
$cur = $try;
continue;
}
if ($cur !== '') $lines[] = $cur;
$cur = strlen($wd) > $maxChars ? substr($wd, 0, $maxChars - 1) . '-' : $wd;
if (count($lines) >= $maxLines) {
$cur = '';
break;
}
}
if ($cur !== '' && count($lines) < $maxLines) $lines[] = $cur;
if (count($lines) === $maxLines) {
$lines[$maxLines - 1] = rtrim($lines[$maxLines - 1]) . '...';
}
return $lines ?: ['SGK'];
}
private static function sharpen($img): void
{
// mild unsharp mask
imageconvolution($img, [
[0.0, -0.6, 0.0],
[-0.6, 3.4, -0.6],
[0.0, -0.6, 0.0],
], 1.0, 0.0);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
use MongoDB\Client;
/**
* MongoDB connection singleton (mongo-harness).
*/
class Mongo
{
private static ?Client $client = null;
public static function client(): Client
{
if (self::$client === null) {
$uri = env('mongo.uri', 'mongodb://127.0.0.1:27017');
self::$client = new Client($uri, ['serverSelectionTimeoutMS' => 5000]);
}
return self::$client;
}
public static function db(): \MongoDB\Database
{
return self::client()->selectDatabase((string) env('mongo.db', 'sachgiaokhoa'));
}
public static function col(string $name): \MongoDB\Collection
{
return self::db()->selectCollection($name, ['typeMap' => ['root' => 'array', 'document' => 'array', 'array' => 'array']]);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* Session-based PDF view access, granted after captcha.
* One grant (2h) unlocks both the online viewer and the download flow for a book.
*/
class PdfAccess
{
private const KEY = 'pdf_access';
public static function grant(string $bookId, int $ttl = 7200): void
{
$map = session()->get(self::KEY) ?? [];
// keep map bounded
if (count($map) > 50) {
$map = array_filter($map, fn($ts) => $ts > time());
}
$map[$bookId] = time() + $ttl;
session()->set(self::KEY, $map);
}
public static function has(string $bookId): bool
{
$map = session()->get(self::KEY) ?? [];
return isset($map[$bookId]) && $map[$bookId] > time();
}
}