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,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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user