แอปเก็บเอกสารหน้าเดียวด้วย PHP กับ SQLite ที่เก็บต้นฉบับไว้เสมอ
ครอปรูปเพื่อให้มันพอดีกรอบแล้วเก็บแต่ผลลัพธ์ไว้ คือการทำลายข้อมูลถาวรโดยไม่รู้ตัว กว่าจะรู้ว่าครอปพลาดคือตอนที่รูปต้นฉบับหายไปจากมือถือคนถ่ายแล้ว
พอดีผมทำระบบเก็บเอกสารสำหรับงานยื่นขออนุญาตกับหน่วยงานราชการชุดหนึ่ง ลักษณะงานคือมีรายการเอกสารที่ต้องเตรียมสามสิบกว่ารายการ คนหน้างานถ่ายรูปเอกสารด้วยมือถือแล้วอัปเข้าเว็บทีละใบ ระบบเก็บให้ แล้วเอาไปวางในเอกสารพร้อมพิมพ์อีกทีหนึ่ง
ตอนแรกผมทำแบบตรงไปตรงมาที่สุด คือรับรูปมาแล้วครอปให้พอดีกรอบที่จะแสดง เก็บผลลัพธ์ไว้ไฟล์เดียว ใช้ได้อยู่พักหนึ่งจนวันที่มีเอกสารแผ่นใหญ่ใบหนึ่งโดนครอปหลุดขอบไปสองสามมิลลิเมตร แล้วปรากฏว่ากลับไปแก้ไม่ได้เลย เพราะรูปต้นฉบับมีอยู่ที่เดียวคือในมือถือของคนถ่ายซึ่งลบไปแล้ว บทเรียนของวันนั้นเลยกลายเป็นสถาปัตยกรรมของทั้งระบบ
ชื่อกิจการ ชื่อบุคคล คีย์ รหัสผ่าน และที่อยู่เว็บทั้งหมดในบทความนี้เป็นตัวอย่างที่ผมสมมติขึ้น ส่วนโครงสร้าง ตัวเลข และโค้ดคือของจริงจากงานนั้น
หนึ่งการอัปโหลด เท่ากับสามไฟล์
กฎข้อเดียวที่ผมยึดตลอดทั้งระบบคือ ห้ามให้ผลลัพธ์ของการครอปเป็นข้อมูลชุดเดียวที่เหลืออยู่ เพราะฉะนั้นการอัปหนึ่งครั้งเขียนสามไฟล์เสมอ ไฟล์แรกคือรูปที่จะเอาไปแสดงและพิมพ์ ไฟล์ที่สองคือต้นฉบับที่ย่อลงมาแล้วแต่ยังไม่ได้ตัดอะไรเลย ไฟล์ที่สามคือค่าซูมกับตำแหน่งที่ใช้ครอปครั้งนั้น
<?php
// action=savestep — รับรูปที่ครอปแล้ว + ต้นฉบับ + ค่าครอป ในคำขอเดียว
if (isset($_GET['action']) && $_GET['action'] === 'savestep') {
header('Content-Type: application/json; charset=utf-8');
if (!authed()) { echo json_encode(['ok'=>false,'err'=>'ยังไม่ได้เข้าสู่ระบบ']); exit; }
$k = preg_replace('/[^a-z0-9_]/i', '', (string)($_POST['key'] ?? '')); // คีย์มาจากผู้ใช้ ต้องล้าง
if ($k === '' || empty($_FILES['file']['tmp_name']) || !is_uploaded_file($_FILES['file']['tmp_name'])) {
echo json_encode(['ok'=>false,'err'=>'ไม่พบไฟล์ที่อัปโหลด']); exit;
}
$bin = (string)file_get_contents($_FILES['file']['tmp_name']);
$sz = strlen($bin);
// เช็กสองไบต์แรกว่าเป็น JPEG จริง ไม่เชื่อนามสกุลและไม่เชื่อ content-type ที่เบราว์เซอร์ส่งมา
if ($sz < 500 || $sz > 8*1024*1024 || substr($bin, 0, 2) !== chr(0xFF).chr(0xD8)) {
echo json_encode(['ok'=>false,'err'=>'ไฟล์ไม่ถูกต้องหรือใหญ่เกินไป']); exit;
}
if (!is_dir($STEPDIR)) {
@mkdir($STEPDIR, 0755, true);
@file_put_contents($STEPDIR.'/.htaccess', 'Deny from all'); // ปิดตั้งแต่วินาทีที่สร้างโฟลเดอร์
}
file_put_contents($STEPDIR.'/'.$k.'.jpg', $bin);
// เก็บ 'ต้นฉบับ' ไว้เสมอ เพื่อให้กลับมาปรับครอปหรือซูมใหม่ได้ภายหลัง
if (!empty($_FILES['orig']['tmp_name']) && is_uploaded_file($_FILES['orig']['tmp_name'])) {
$ob = (string)file_get_contents($_FILES['orig']['tmp_name']);
$os = strlen($ob);
if ($os >= 500 && $os <= 16*1024*1024 && substr($ob, 0, 2) === chr(0xFF).chr(0xD8)) {
file_put_contents($STEPDIR.'/'.$k.'.orig.jpg', $ob);
}
}
// ค่าครอปและซูมล่าสุด ไว้เปิดกลับมาแก้ต่อจากเดิมได้
$meta = (string)($_POST['meta'] ?? '');
if ($meta !== '' && strlen($meta) < 2000) {
$j = json_decode($meta, true);
if (is_array($j)) file_put_contents($STEPDIR.'/'.$k.'.crop.json', json_encode($j));
}
echo json_encode(['ok'=>true, 'key'=>$k, 't'=>time(),
'orig'=>is_file($STEPDIR.'/'.$k.'.orig.jpg') ? 1 : 0]); exit;
}
สังเกตว่าเพดานขนาดของสองไฟล์ไม่เท่ากัน ไฟล์แสดงจำกัดที่ 8 เมกะไบต์ ส่วนต้นฉบับให้ถึง 16 เมกะไบต์ เพราะต้นฉบับความละเอียดสูงกว่าโดยธรรมชาติ
ตัวปรับรูปฝั่งเบราว์เซอร์ ค่าตั้งต้นคือเก็บทั้งรูป
การย่อและครอปทำในเบราว์เซอร์ทั้งหมด เซิร์ฟเวอร์ไม่ต้องแตะรูปเลยสักครั้ง ซึ่งบนโฮสต์ที่ทรัพยากรจำกัดนี่ช่วยได้เยอะมาก
จุดที่ผมออกแบบใหม่หลังเจอปัญหาคือ ค่าตั้งต้นของช่องติ๊กคือ "เก็บทั้งรูป ไม่ตัดขอบ" คนหน้างานที่แค่กดอัปแล้วกดบันทึกโดยไม่แตะอะไรเลย จะได้รูปเต็มใบทุกครั้ง การครอปตามสัดส่วนกรอบเกิดขึ้นเฉพาะเมื่อเขาตั้งใจถ่างนิ้วซูมหรือเลื่อนรูปเท่านั้น
function cropSave() {
var im = document.getElementById('cropimg'), v = document.getElementById('cropview');
var vw = v.clientWidth, vh = v.clientHeight;
var cv = document.createElement('canvas'), g;
var whole = document.getElementById('cropwhole').checked;
// "ไม่ได้แตะอะไรเลย" = ซูมยังเป็น 1 และไม่ได้เลื่อนเกิน 3 พิกเซล
var untouched = (Math.abs(CROP.scale - 1) < 0.02 && Math.abs(CROP.x) < 3 && Math.abs(CROP.y) < 3);
if (whole && untouched) { /* เก็บทั้งรูป ไม่ตัดขอบ — ย่อด้านยาวสุดเหลือ 1800 */
var L = 1800, kk = Math.min(1, L / Math.max(CROP.natural[0], CROP.natural[1]));
cv.width = Math.round(CROP.natural[0] * kk);
cv.height = Math.round(CROP.natural[1] * kk);
g = cv.getContext('2d'); g.fillStyle = '#fff'; g.fillRect(0, 0, cv.width, cv.height);
g.drawImage(im, 0, 0, cv.width, cv.height);
} else { /* ครอปตามสัดส่วนกรอบที่กำลังเห็นอยู่ */
var OUT = 1400;
cv.width = OUT; cv.height = Math.round(OUT * vh / vw);
g = cv.getContext('2d'); g.fillStyle = '#fff'; g.fillRect(0, 0, cv.width, cv.height);
var r = OUT / vw, k = CROP.base * CROP.scale;
var dw = CROP.natural[0] * k * r, dh = CROP.natural[1] * k * r;
var dx = (cv.width / 2) + (CROP.x * r) - dw / 2;
var dy = (cv.height / 2) + (CROP.y * r) - dh / 2;
g.drawImage(im, dx, dy, dw, dh);
}
cv.toBlob(function (blob) {
var fd = new FormData();
fd.append('key', CROP.key);
fd.append('file', blob, CROP.key + '.jpg');
// เก็บค่าครอปไว้ทั้งชุด เพื่อคืนสภาพหน้าตัวปรับรูปให้เหมือนเดิมตอนกลับมาแก้
fd.append('meta', JSON.stringify({ whole: whole ? 1 : 0, ar: CROP.ar,
scale: CROP.scale, x: CROP.x, y: CROP.y, vw: vw, vh: vh }));
cropSend(fd);
}, 'image/jpeg', 0.86);
}
อีกจุดที่ต้องมีคือ ถ้าผู้ใช้ติ๊ก "เก็บทั้งรูป" ไว้แล้วเผลอไปเลื่อนหรือซูม ให้ปลดติ๊กออกให้เอง ไม่งั้นเขาจะงงว่าทำไมปรับแล้วไม่มีผล
function cropApply() {
var im = document.getElementById('cropimg');
var w = document.getElementById('cropwhole');
// ผู้ใช้ลงมือปรับเมื่อไร แปลว่าเขาไม่ได้ต้องการทั้งรูปแล้ว ปลดติ๊กให้เอง
if (w && w.checked && (Math.abs(CROP.scale - 1) > 0.02 ||
Math.abs(CROP.x) > 3 || Math.abs(CROP.y) > 3)) w.checked = false;
var k = CROP.base * CROP.scale;
im.style.width = (CROP.natural[0] * k) + 'px';
im.style.height = (CROP.natural[1] * k) + 'px';
im.style.transform = 'translate(-50%,-50%) translate(' + CROP.x + 'px,' + CROP.y + 'px)';
}
กลับมาแก้ครอปจากต้นฉบับ โดยไม่อัปต้นฉบับซ้ำ
ตรงนี้คือส่วนที่ทำให้ทั้งเรื่องคุ้มค่ากับความยุ่งยาก เมื่อผู้ใช้กดปุ่มปรับรูปบนกล่องที่มีรูปอยู่แล้ว ระบบไม่ได้ให้เขาไปหารูปในมือถือใหม่ แต่โหลด ต้นฉบับ ที่เก็บไว้กลับมา พร้อมคืนค่าซูมและตำแหน่งเดิมให้ตรงเป๊ะ เขาแค่ขยับต่อจากที่ค้างไว้
/** แนบสถานะของกล่องรูปไปกับ HTML: มีต้นฉบับไหม และค่าครอปเดิมคืออะไร */
function stepattrs($k) {
global $STEPDIR;
$o = '';
$of = $STEPDIR.'/'.$k.'.orig.jpg';
if (is_file($of)) $o .= ' data-orig="'.filemtime($of).'"'; // เวลาแก้ไข = ตัวกัน cache ในตัว
$cf = $STEPDIR.'/'.$k.'.crop.json';
if (is_file($cf)) {
$j = (string)file_get_contents($cf);
// ค่านี้ไปอยู่ในแอตทริบิวต์ HTML ต้องถอดอักขระที่ทำให้หลุดออกจากแอตทริบิวต์ได้
if (strlen($j) < 2000) $o .= " data-crop='".str_replace(["'", '<', '>'], ['', '', ''], $j)."'";
}
return $o;
}
- ผู้ใช้กดปุ่มปรับรูปบนกล่องที่มีรูปอยู่แล้ว โค้ดอ่านค่าครอปเดิมจากแอตทริบิวต์ แล้วตั้งธงว่ารอบนี้ทำงานจากต้นฉบับ
- ตัวปรับรูปเปิดโดยโหลดไฟล์ต้นฉบับผ่านสคริปต์ ไม่ใช่ไฟล์ที่ครอปแล้ว รูปเลยยังคมเท่าเดิม
- คืนค่าซูมและตำแหน่งเดิม โดยปรับตามอัตราส่วนของขนาดหน้าจอตอนนี้กับตอนที่บันทึกไว้ครั้งก่อน
- ผู้ใช้ขยับต่อ แล้วกดบันทึก ระบบเขียนทับเฉพาะไฟล์แสดงกับไฟล์ค่าครอป ไม่แตะต้นฉบับ
- ผลคือปรับกี่รอบก็ได้ คุณภาพไม่ลดลงสะสม เพราะทุกรอบเริ่มจากต้นฉบับเดิมเสมอ
/* ส่งขึ้นเซิร์ฟเวอร์ พร้อมต้นฉบับ เฉพาะครั้งแรกที่แนบรูปนั้น */
function cropSend(fd) {
var im = document.getElementById('cropimg');
function post() {
fetch('doc.php?action=savestep', { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j.ok) { alert(j.err || 'บันทึกไม่สำเร็จ'); return; }
var fr = document.getElementById('fr_' + j.key);
fr.innerHTML = '<img src="doc.php?stepimg=' + j.key + '&v=' + j.t + '" alt="">';
cropClose();
})
.catch(function () { alert('เชื่อมต่อไม่ได้'); });
}
// แก้จากต้นฉบับเดิม -> ไม่ต้องอัปต้นฉบับซ้ำ ประหยัดทั้งเวลาและแบนด์วิดท์
if (CROP.fromOrig) { post(); return; }
// ครั้งแรกเท่านั้น: ย่อต้นฉบับด้านยาวสุดเหลือ 2400 คุณภาพ 0.90 แล้วแนบไปด้วย
var L = 2400, k = Math.min(1, L / Math.max(CROP.natural[0], CROP.natural[1]));
var oc = document.createElement('canvas');
oc.width = Math.round(CROP.natural[0] * k);
oc.height = Math.round(CROP.natural[1] * k);
var og = oc.getContext('2d');
og.fillStyle = '#fff'; og.fillRect(0, 0, oc.width, oc.height);
og.drawImage(im, 0, 0, oc.width, oc.height);
oc.toBlob(function (ob) {
if (ob) fd.append('orig', ob, CROP.key + '.orig.jpg');
post();
}, 'image/jpeg', 0.9);
}
เก็บข้อมูลส่วนบุคคล ปิดที่เว็บเซิร์ฟเวอร์แล้วเสิร์ฟผ่านสคริปต์
เอกสารในระบบแบบนี้มีข้อมูลส่วนบุคคลปนอยู่แน่นอน ทั้งสำเนาบัตรประจำตัว สำเนาทะเบียนบ้าน หนังสือรับรอง ซึ่งถ้าปล่อยให้ไฟล์อยู่ในโฟลเดอร์ที่เว็บเซิร์ฟเวอร์เสิร์ฟตรงได้ ใครเดาชื่อไฟล์ถูกก็เปิดดูได้ทันทีโดยไม่ต้องล็อกอิน
ชั้นแรกคือไฟล์กำหนดค่าในโฟลเดอร์เก็บของ ที่สั่งปฏิเสธทุกคำขอที่วิ่งเข้าไฟล์ตรงๆ ชั้นที่สองคือสคริปต์ PHP ตัวเดียวที่เป็นทางเข้าเดียวของรูป และตรวจสถานะล็อกอินก่อนส่งไบต์แรกออกไปเสมอ อย่าพึ่งชั้นใดชั้นหนึ่งเดี่ยวๆ ถ้าเว็บเซิร์ฟเวอร์เปลี่ยนรุ่นหรือย้ายโฮสต์แล้วไฟล์กำหนดค่าไม่ถูกอ่าน ชั้นที่สองยังกันอยู่ และถ้าโค้ดพลาด ชั้นแรกก็ยังกันอยู่
<?php
// ชั้นแรก: สร้างไฟล์ปฏิเสธการเข้าถึงตรงตั้งแต่ตอนสร้างโฟลเดอร์ ไม่ต้องรอคนมาตั้งค่าเอง
if (!is_dir($DOCS)) @mkdir($DOCS, 0775, true);
$ht = $DOCS . '/.htaccess';
if (!is_file($ht)) {
@file_put_contents($ht,
"Require all denied\n" // เว็บเซิร์ฟเวอร์รุ่นใหม่
. "<IfModule !mod_authz_core.c>\n"
. "Order allow,deny\nDeny from all\n" // รุ่นเก่าที่ยังไม่มีโมดูลนั้น
. "</IfModule>\n");
}
// ชั้นที่สอง: ทางเข้าเดียวของไฟล์ ตรวจล็อกอินก่อนส่งไบต์แรกเสมอ
if (($_GET['action'] ?? '') === 'view') {
if (!authed()) { http_response_code(403); exit; }
$key = preg_replace('/[^a-z0-9_]/', '', (string)($_GET['item'] ?? ''));
$file = basename((string)($_GET['file'] ?? '')); // basename กันการไต่ไดเรกทอรี
$p = "$DOCS/$key/$file";
if (!$key || !$file || !is_file($p)) { http_response_code(404); exit; }
$ext = strtolower(pathinfo($p, PATHINFO_EXTENSION));
$ct = ['jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png',
'webp'=>'image/webp','pdf'=>'application/pdf'][$ext] ?? 'application/octet-stream';
header('Content-Type: '.$ct);
header('Content-Length: '.filesize($p));
header('Cache-Control: private, max-age=3600'); // private เท่านั้น ห้ามให้พร็อกซีเก็บ
readfile($p); exit;
}
จุดเล็กๆ ที่คนพลาดกันบ่อยคือ Cache-Control ถ้าเผลอปล่อยเป็นค่าปริยายหรือใส่ public ตัวกลางระหว่างทางเก็บสำเนารูปเอกสารส่วนบุคคลไว้ได้ ซึ่งเสียของที่อุตส่าห์ทำสองชั้นมา
ตอนทดสอบระบบ ผมใช้รูปเอกสารสมมติที่พิมพ์ตารางเปล่าออกมาถ่ายเอง ไม่ใช้สำเนาบัตรประจำตัวหรือทะเบียนบ้านของใครจริงๆ แม้จะเบลอแล้วก็ตาม เพราะไฟล์ทดสอบมีนิสัยหลุดไปอยู่ในที่ที่เราไม่ได้ตั้งใจเสมอ ทั้งในสกรีนช็อต ในรายงาน ในโฟลเดอร์ชั่วคราวที่ลืมลบ และในไฟล์สำรองข้อมูล
รับไฟล์เข้ามาแล้วอย่าเชื่อสิ่งที่ผู้ใช้ส่งมา
อีกทางเข้าหนึ่งของระบบคือหน้าเช็กลิสต์ ที่อัปได้ทีละหลายไฟล์และรับ PDF ด้วย ตรงนี้ผมย่อรูปฝั่งเซิร์ฟเวอร์ด้วยไลบรารีสร้างภาพของ PHP เพราะไฟล์มาจากหลายทางกว่า
<?php
const MAX_BYTES = 25 * 1024 * 1024; // 25MB ต่อไฟล์
const MAX_SIDE = 2000; // ย่อด้านยาวสุดเหลือ 2000 พิกเซล
function save_downscaled($tmp, $dst, $max, $q) {
$info = @getimagesize($tmp); // อ่านจากเนื้อไฟล์จริง ไม่ใช่จากนามสกุล
if (!$info) return false;
switch ($info['mime']) {
case 'image/jpeg': $im = @imagecreatefromjpeg($tmp); break;
case 'image/png': $im = @imagecreatefrompng($tmp); break;
case 'image/webp': $im = @imagecreatefromwebp($tmp); break;
default: return false; // อย่างอื่นไม่รับ รวมถึงไฟล์ที่แอบเปลี่ยนนามสกุลมา
}
if (!$im) return false;
$w = imagesx($im); $h = imagesy($im); $nw = $w; $nh = $h;
if (max($w, $h) > $max) { $r = $max / max($w, $h); $nw = (int)round($w*$r); $nh = (int)round($h*$r); }
$dstIm = imagecreatetruecolor($nw, $nh);
// ถมพื้นขาวก่อนเสมอ ไม่งั้น PNG ที่มีพื้นโปร่งจะกลายเป็นพื้นดำตอนบันทึกเป็น JPEG
imagefilledrectangle($dstIm, 0, 0, $nw, $nh, imagecolorallocate($dstIm, 255, 255, 255));
imagecopyresampled($dstIm, $im, 0, 0, 0, 0, $nw, $nh, $w, $h);
imageinterlace($dstIm, true); // โหลดแบบค่อยๆ ชัด บนเน็ตมือถือรู้สึกเร็วกว่า
$ok = imagejpeg($dstIm, $dst, $q);
imagedestroy($im); imagedestroy($dstIm);
return $ok;
}
ชื่อไฟล์ที่เขียนลงดิสก์ผมไม่ใช้ชื่อเดิมที่ผู้ใช้ส่งมาเลยแม้แต่ครั้งเดียว ตั้งใหม่เป็นเวลาบวกค่าสุ่มสั้นๆ แล้วบังคับนามสกุลตามชนิดที่ตรวจได้จริง วิธีนี้ปิดปัญหาเรื่องอักขระแปลกในชื่อไฟล์ ชื่อซ้ำ และไฟล์ที่แอบใส่นามสกุลสองชั้นไปพร้อมกันทีเดียว
ย้ายจาก JSON มาเป็น SQLite แล้วดูดของเดิมเข้าให้เอง
ตอนเริ่มผมเก็บสถานะเป็นไฟล์ JSON สามไฟล์ คือหมายเหตุ ผู้รับผิดชอบ และสถานะติ๊กแล้ว ส่วนรายการไฟล์อ่านด้วยการไล่สแกนโฟลเดอร์ทีละรายการ ใช้ได้ดีตอนของยังน้อย พอรูปเริ่มเยอะ การเปิดหน้าเดียวต้องสแกนสามสิบกว่าโฟลเดอร์ทุกครั้ง
เริ่มง่าย แต่โตแล้วเจ็บ
- เปิดหน้าเดียวสแกนสามสิบกว่าโฟลเดอร์
- เขียนพร้อมกันสองคำขอแล้วทับกันเงียบๆ
- ถามว่า "รายการไหนยังไม่มีไฟล์" ต้องไล่เองทั้งหมด
- ขนาดไฟล์กับชนิดต้องไปถามดิสก์ทุกครั้ง
คิวรีเดียวจบ ไม่ต้องลงอะไรเพิ่ม
- รายการทั้งหน้าได้จากคิวรีเดียว
- เขียนผ่านคำสั่งเดียวแบบมีก็อัปเดตไม่มีก็เพิ่ม
- ดัชนีทำให้ถามอะไรก็เร็วเท่าเดิม
- ไฟล์รูปยังอยู่บนดิสก์ ฐานข้อมูลเก็บแค่ข้อมูลกำกับ
สิ่งที่ผมคิดว่าสำคัญกว่าตัวเลือกฐานข้อมูลคือ การย้ายต้องเกิดขึ้นเอง ไม่ใช่งานที่ต้องมีคนไปสั่ง ผมเลยผูกการย้ายไว้กับการสร้างฐานข้อมูลครั้งแรก ครั้งที่ใครก็ตามเปิดหน้าเว็บหลังอัปเดตโค้ด ระบบจะเห็นว่ายังไม่มีไฟล์ฐานข้อมูล สร้างโครงตาราง แล้วดูดทั้งไฟล์บนดิสก์และ JSON เดิมเข้ามาให้ในทรานแซกชันเดียว ไฟล์ JSON เดิมยังอยู่ครบในฐานะไฟล์สำรอง
-- โครงตารางทั้งหมดของระบบ มีแค่สองตาราง
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item TEXT NOT NULL, -- รายการเอกสารที่ไฟล์นี้สังกัด
fname TEXT NOT NULL, -- ชื่อไฟล์บนดิสก์ (ที่เราตั้งเอง)
mime TEXT,
size INTEGER,
created TEXT
);
CREATE INDEX IF NOT EXISTS ix_files_item ON files(item);
CREATE UNIQUE INDEX IF NOT EXISTS ux_files ON files(item, fname); -- กันบันทึกซ้ำตอนย้ายซ้ำ
CREATE TABLE IF NOT EXISTS items (
item TEXT PRIMARY KEY,
note TEXT, -- หมายเหตุที่บันทึกอัตโนมัติระหว่างพิมพ์
owner TEXT, -- ผู้รับผิดชอบรายการนี้
checked INTEGER DEFAULT 0, -- ติ๊กว่ามีเอกสารแล้ว แม้ยังไม่ได้อัปไฟล์
updated TEXT
);
<?php
function db() {
global $DBF;
static $pdo = null;
if ($pdo) return $pdo;
$new = !is_file($DBF); // ยังไม่มีไฟล์ = ครั้งแรก = ต้องย้ายของเก่า
$pdo = new PDO('sqlite:'.$DBF);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA busy_timeout=5000'); // สองคำขอชนกันให้รอ ไม่ใช่โยน error ใส่หน้าผู้ใช้
$pdo->exec('CREATE TABLE IF NOT EXISTS files(...)');
$pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS ux_files ON files(item,fname)');
$pdo->exec('CREATE TABLE IF NOT EXISTS items(...)');
if ($new) db_migrate($pdo);
return $pdo;
}
function db_migrate($pdo) { // ดูดไฟล์บนดิสก์ + JSON เดิมเข้าฐานข้อมูล
global $DOCS, $STATE, $OWN, $CHK;
$pdo->beginTransaction(); // ล้มกลางทางแล้วไม่เหลือของครึ่งๆ กลางๆ
$ins = $pdo->prepare('INSERT OR IGNORE INTO files(item,fname,mime,size,created) VALUES(?,?,?,?,?)');
foreach (glob($DOCS.'/*', GLOB_ONLYDIR) as $dir) {
$item = basename($dir);
foreach (scandir($dir) as $f) {
if ($f === '' || $f[0] === '.') continue;
$ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
if ($ext === '' || !is_file("$dir/$f")) continue;
$mime = ['jpg'=>'image/jpeg','png'=>'image/png','pdf'=>'application/pdf'][$ext] ?? 'application/octet-stream';
$ins->execute([$item, $f, $mime, filesize("$dir/$f"), date('Y-m-d H:i:s', filemtime("$dir/$f"))]);
}
}
$rd = function ($p) { return is_file($p) ? (json_decode(file_get_contents($p), true) ?: []) : []; };
$st = $rd($STATE); $ow = $rd($OWN); $ck = $rd($CHK);
$keys = array_unique(array_merge(array_keys($st), array_keys($ow), array_keys($ck)));
$up = $pdo->prepare('INSERT OR REPLACE INTO items(item,note,owner,checked,updated) VALUES(?,?,?,?,?)');
foreach ($keys as $k) {
$up->execute([$k, isset($st[$k]) ? (string)$st[$k] : null,
$ow[$k] ?? null, !empty($ck[$k]) ? 1 : 0, date('Y-m-d H:i:s')]);
}
$pdo->commit();
}
ผลข้างเคียงที่ต้องรู้ทันคือ ฟังก์ชันเดิมที่ใช้สแกนโฟลเดอร์และอ่าน JSON กลายเป็นโค้ดตายทันทีที่ย้ายเสร็จ ผมปล่อยไว้รอบหนึ่งเพื่อกันเหนียว แล้วค่อยลบทีหลัง แต่ต้องเขียนคอมเมนต์กำกับไว้ ไม่งั้นเดือนหน้ากลับมาอ่านแล้วนึกว่ายังใช้อยู่
ลบให้ครบทั้งสามไฟล์
ข้อสุดท้ายสั้นแต่พลาดง่ายมาก ในเมื่อการอัปหนึ่งครั้งสร้างสามไฟล์ การลบก็ต้องลบครบสามไฟล์ ไม่งั้นต้นฉบับของเอกสารที่ผู้ใช้ตั้งใจลบทิ้งจะยังนอนอยู่บนดิสก์ ซึ่งในระบบที่เก็บข้อมูลส่วนบุคคลถือว่าเป็นเรื่องใหญ่ ไม่ใช่แค่เปลืองที่
<?php
if (($_GET['action'] ?? '') === 'delstep') {
header('Content-Type: application/json; charset=utf-8');
if (!authed()) { echo json_encode(['ok'=>false,'err'=>'ยังไม่ได้เข้าสู่ระบบ']); exit; }
$k = preg_replace('/[^a-z0-9_]/i', '', (string)($_POST['key'] ?? ''));
if ($k !== '') {
// ลบครบทั้งสามไฟล์ ไม่งั้นต้นฉบับที่มีข้อมูลส่วนบุคคลค้างอยู่บนดิสก์
foreach ([$k.'.jpg', $k.'.orig.jpg', $k.'.crop.json'] as $fn) {
$f = $STEPDIR.'/'.$fn;
if (is_file($f)) @unlink($f);
}
}
echo json_encode(['ok'=>true]); exit;
}
ตอนออกแบบ ผมแนะนำให้เขียนรายชื่อนามสกุลทั้งชุดไว้เป็นค่าคงที่ที่เดียว แล้วให้ทั้งตัวบันทึก ตัวลบ และตัวสำรองข้อมูล อ่านจากค่าเดียวกัน วันที่เพิ่มไฟล์ที่สี่เข้ามาจะได้ไม่ลืมแก้ที่ใดที่หนึ่ง
สรุป
ทั้งระบบนี้เป็นไฟล์ PHP ไม่กี่ไฟล์กับฐานข้อมูล SQLite หนึ่งไฟล์ อยู่บนโฮสต์ร่วมธรรมดา ไม่มีเฟรมเวิร์ก ไม่มีคิว ไม่มีอะไรที่ต้องคอยดูแล แต่ของที่ผมคิดว่าคุ้มที่สุดในงานนี้ไม่ใช่เรื่องเทคนิคเลย มันคือการตัดสินใจว่าต้นฉบับต้องอยู่เสมอ
ส่วนตัวผมสรุปเป็นกฎสั้นๆ ว่า ทุกครั้งที่ระบบแปลงข้อมูลของผู้ใช้ให้เล็กลง สวยขึ้น หรือพอดีขึ้น ต้องเก็บของก่อนแปลงไว้ด้วยเสมอ ไม่ว่าจะเป็นรูป ไฟล์เสียง ข้อความที่ตัดให้สั้น หรือข้อมูลที่ปัดเศษ เพราะการแปลงกลับมักทำไม่ได้ และตอนที่รู้ว่าแปลงผิด ต้นทางมักหายไปแล้ว ใครจะเก็บที่ความละเอียดเท่าไรก็ตามกำลังพื้นที่ที่มีครับ
ปล. ยังมีอีกเรื่องที่ผมยังไม่ได้ทำในรอบนี้คือการล้างข้อมูลตำแหน่งและข้อมูลกล้องที่ติดมากับรูปจากมือถือ ตอนนี้การวาดลงพื้นที่วาดในเบราว์เซอร์มันตัดทิ้งให้โดยบังเอิญอยู่แล้ว แต่ผมยังไม่ได้ตรวจครบทุกเส้นทางการอัป เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย