A Single-Page PHP and SQLite Document Vault That Keeps Originals
Cropping an image to fit a frame and keeping only the result means permanently destroying data without realizing it. By the time you discover a bad crop, the original photo is already gone from the photographer's phone.
I built a document storage system for a set of permit applications to government agencies. The job involved a list of more than thirty documents to prepare. People on site photographed the documents with their phones and uploaded them to the website one at a time. The system stored them, and the photos were then placed into documents ready for printing.
At first, I took the most straightforward approach: accept an image, crop it to fit the display frame, and keep the result as a single file. It worked for a while, until a photo of one large document had a few millimeters cropped off its edges. There was no way to fix it, because the only original was on the photographer's phone, and they had already deleted it. That day's lesson became the architecture of the whole system.
All business names, personal names, keys, passwords, and web addresses in this article are examples I made up. The structure, numbers, and code are from the actual project.
One upload means three files
The one rule I follow throughout the system is never let the crop result become the only remaining copy of the data. Each upload therefore always writes three files. The first is the image for display and printing. The second is the original, resized but with nothing cropped out. The third holds the zoom and position settings used for that crop.
<?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;
}
Notice that the two image files have different size limits. The display file is limited to 8 megabytes, while the original can be up to 16 megabytes, since it naturally has a higher resolution.
The browser image editor defaults to keeping the whole image
All resizing and cropping happens in the browser. The server never has to process an image, which helps a lot on a host with limited resources.
The part I redesigned after running into the problem was making "Keep the whole image, without cropping edges" the default checkbox setting. Someone on site who simply uploads and saves without touching anything gets the full document every time. Cropping to the frame's aspect ratio happens only when they deliberately pinch to zoom or pan the image.
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);
}
Another necessary detail: if the user has "Keep the whole image" checked and then happens to pan or zoom, uncheck it automatically. Otherwise, they will wonder why their adjustments have no effect.
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)';
}
Adjust the crop from the original without uploading it again
This is the part that makes the extra work worthwhile. When the user clicks the image adjustment button on a box that already contains an image, the system does not ask them to find the photo on their phone again. It loads the stored original and restores the exact previous zoom and position settings. They just continue adjusting from where they left off.
/** แนบสถานะของกล่องรูปไปกับ 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;
}
- The user clicks the image adjustment button on a box that already contains an image. The code reads the previous crop settings from an attribute and sets a flag indicating that this round uses the original.
- The image editor opens by loading the original file through a script, rather than the cropped file, so the image stays just as sharp.
- The previous zoom and position are restored, scaled by the ratio between the current screen size and the size when the settings were last saved.
- The user continues adjusting and clicks save. The system overwrites only the display file and crop settings file, leaving the original untouched.
- The result is that the image can be adjusted any number of times without cumulative quality loss, because every round always starts from the same original.
/* ส่งขึ้นเซิร์ฟเวอร์ พร้อมต้นฉบับ เฉพาะครั้งแรกที่แนบรูปนั้น */
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);
}
Storing personal data: block access at the web server and serve through a script
Documents in a system like this inevitably contain personal data, including copies of ID cards, household registration documents, and certificates. If those files sit in a folder the web server can serve directly, anyone who guesses a filename can open it immediately without logging in.
The first layer is a configuration file in the storage folder that denies every request made directly to a file. The second is a single PHP script that serves as the only entry point for images and always checks login status before sending the first byte. Do not rely on either layer alone. If the web server version changes or the site moves to another host and the configuration file is no longer read, the second layer still provides protection. If the code has a bug, the first layer still provides protection.
<?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;
}
One small detail people often miss is Cache-Control. If it is accidentally left at its default or set to public, intermediaries can cache copies of documents containing personal data, undermining the effort spent building those two layers.
When testing the system, I used mock documents made by printing blank tables and photographing them myself. I did not use anyone's actual ID card or household registration copies, even blurred ones, because test files have a habit of ending up somewhere unintended: in screenshots, reports, temporary folders I forget to delete, and backups.
When accepting files, do not trust what the user sends
Another entry point into the system is the checklist page, which accepts multiple files at once, including PDFs. Here, I resize images on the server using PHP's image library, because the files come from a wider range of sources.
<?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;
}
I never use the user's original filename when writing a file to disk. I generate a new name from the time plus a short random value, then enforce the extension based on the actual detected type. This handles unusual characters in filenames, duplicate names, and files sneaking in double extensions all at once.
Move from JSON to SQLite and import existing data automatically
At the start, I stored state in three JSON files: notes, responsible people, and checked status. File lists came from scanning the folders for each item. This worked well while there was little data, but once the photos piled up, opening a single page required scanning more than thirty folders every time.
Easy to start, painful as it grows
- Opening one page scans more than thirty folders
- Two concurrent writes silently overwrite each other
- Finding "which items have no files" requires checking everything manually
- File sizes and types require disk access every time
One query, nothing extra to install
- One query retrieves the entire page's list
- One statement updates an existing record or inserts a new one
- Indexes keep queries consistently fast
- Images stay on disk; the database stores only metadata
What I consider more important than the choice of database is that migration must happen automatically, rather than requiring someone to run it. I therefore tied migration to the initial database creation. The first time anyone opens the website after the code update, the system detects that there is no database file, creates the tables, and imports both the files on disk and the old JSON data in a single transaction. The original JSON files remain intact as backups.
-- โครงตารางทั้งหมดของระบบ มีแค่สองตาราง
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();
}
One side effect to watch for is that the old functions for scanning folders and reading JSON become dead code as soon as migration finishes. I left them in for one release as a precaution, then removed them later. But they need comments marking their status, or I might come back next month and think they are still in use.
Delete all three files
The last point is short but very easy to miss. Since one upload creates three files, deletion must remove all three. Otherwise, the original of a document the user intended to delete will still be sitting on disk. In a system storing personal data, that is a serious issue, not just wasted space.
<?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;
}
During design, I recommend defining the full list of extensions as a constant in one place, then having the save, delete, and backup code all read that same value. When a fourth file is added, that helps avoid forgetting to update one of those places.
Summary
This entire system is a few PHP files and one SQLite database file on ordinary shared hosting. There is no framework, no queue, and nothing that needs ongoing attention. But what I consider the most worthwhile part of this project is not technical at all. It is the decision to always keep the original.
My own rule is simple: whenever a system transforms user data to make it smaller, better-looking, or a better fit, it must always keep the version from before the transformation. That applies to images, audio files, shortened text, or rounded data, because reversing the transformation is often impossible, and by the time a mistake is discovered, the source is often gone. The resolution you keep is up to the storage space you have.
P.S. One thing I have not addressed in this round is stripping location and camera metadata attached to photos from phones. Drawing the image onto a browser canvas currently removes it as a side effect, but I have not checked every upload path yet. I will come back and address that later.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first