EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Web / ให้ลูกค้าเลือกรูปเองผ่านหน้าเว็บ โดยไม่ต้องมีระบบล็อกอิน
Web

ให้ลูกค้าเลือกรูปเองผ่านหน้าเว็บ โดยไม่ต้องมีระบบล็อกอิน

โดย Ton-Or ·23 ต.ค. 2026·อ่าน 8 นาที
ให้ลูกค้าเลือกรูปเองผ่านหน้าเว็บ โดยไม่ต้องมีระบบล็อกอิน

ฟอร์มสาธารณะที่ไม่มีการยืนยันตัวตน ไม่ได้แปลว่าปล่อยให้เขียนอะไรก็ได้ มันแปลว่าเราย้ายงานตรวจทั้งหมดไปไว้ที่ปลายทางฝั่งเซิร์ฟเวอร์แทน

พอดีผมรับทำเว็บใหม่ให้ที่พักแห่งหนึ่ง มีรูปที่ถ่ายไว้อยู่สองร้อยกว่ารูป และต้องให้เจ้าของเป็นคนเลือกเองว่าหน้าไหนใช้รูปไหน เพราะเขารู้ดีกว่าผมว่ารูปไหนคือห้องที่ยังเปิดขายอยู่ และรูปไหนคือมุมที่ปรับปรุงไปแล้ว

วิธีเดิมที่ผมเคยทำคือส่งลิงก์โฟลเดอร์ให้ แล้วให้ตอบกลับมาว่าเอารูปเลขอะไรบ้าง ผลที่ได้คือข้อความยาวที่อ่านแล้วไม่แน่ใจว่าเลขไหนคือหน้าไหน แล้วต้องถามกลับอีกสองสามรอบ รอบนี้ผมเลยทำหน้าเว็บให้กดเลือกเองไปเลย

คำถามแรกที่ต้องตอบคือจะใส่ระบบล็อกอินไหม และคำตอบของผมคือไม่ใส่

ชื่อที่พัก ชื่อไฟล์จริง ที่อยู่เว็บ และเส้นทางทั้งหมดในบทความนี้ผมเปลี่ยนเป็นตัวอย่างแล้ว ส่วนโครงสร้างโค้ด ตัวเลขเพดาน และเหตุผลของแต่ละข้อ เป็นของจริงจากงานนั้น

ระบบล็อกอินที่ถูกต้อง แต่ทำให้งานไม่จบ

ทางที่มีล็อกอิน

ถูกตำรา แต่งานค้างที่หน้าแรก

  • ต้องมีที่เก็บผู้ใช้ รหัสผ่าน และการกู้รหัสผ่าน
  • ลูกค้าต้องจำรหัส หรือไม่ก็ลืมแล้วทักมาถามทุกครั้ง
  • งานที่ใช้จริงแค่สามวัน แต่มีของให้ดูแลตลอดไป
  • ถ้าทำรหัสผ่านหลุด ผมรับผิดชอบข้อมูลที่ไม่จำเป็นต้องมีตั้งแต่แรก
ทางที่ไม่มีล็อกอิน

เข้าถึงง่าย แต่ต้องคุมที่ปลายทาง

  • ส่งลิงก์เดียว เปิดในมือถือแล้วกดได้เลย
  • ไม่มีข้อมูลบัญชีให้รั่วเพราะไม่มีบัญชีตั้งแต่แรก
  • ใครได้ลิงก์ไปก็เขียนทับได้ ต้องยอมรับข้อนี้
  • งานตรวจย้ายไปอยู่ที่ปลายทางฝั่งเซิร์ฟเวอร์ทั้งหมด

ส่วนตัวผมชั่งน้ำหนักจากมูลค่าของข้อมูล สิ่งที่หน้านี้เก็บคือรายการรหัสรูปที่ถูกเลือก ซึ่งไม่มีข้อมูลส่วนบุคคล ไม่มีเงิน และเสียหายหนักสุดคือรายการเลือกโดนล้าง ซึ่งกู้กลับได้จากไฟล์สำรอง เทียบกับต้นทุนที่ลูกค้าอายุหกสิบกว่าต้องมานั่งสมัครสมาชิกแล้ว ผมเลือกไม่ใส่

เกณฑ์ตัดสินใจที่ผมใช้ และอยากให้ใช้ตาม

ถามสามข้อก่อนตัดว่าไม่ต้องมีการยืนยันตัวตน หนึ่ง ข้อมูลที่เก็บมีข้อมูลส่วนบุคคลของใครไหม สอง ถ้าคนแปลกหน้าเขียนทับทั้งหมด เสียหายแค่ไหนและกู้กลับได้ไหม สาม ข้อมูลที่อ่านออกไปได้ มีอะไรที่ไม่อยากให้คนนอกเห็นไหม ถ้าคำตอบคือไม่มี กู้ได้ และไม่มี แบบนี้ทำได้ ถ้ามีข้อใดข้อหนึ่งไม่ผ่าน ต้องมีการยืนยันตัวตน ไม่มีทางลัด

สามคำถามก่อนตัดว่าไม่ต้องมีการยืนยันตัวตน1 ข้อมูลที่เก็บมีข้อมูลส่วนบุคคล2 ถูกเขียนทับหมดกู้กลับได้ไหม3 คนนอกอ่านได้มีของที่ต้องปิดไหมไม่มีกู้ได้ไม่มีทำได้โดยไม่ต้องยืนยันตัวตนข้อใดข้อหนึ่งไม่ผ่าน ต้องมีการยืนยันตัวตน ไม่มีทางลัดถ้าตอบตรงข้าม
ผ่านทั้งสามข้อเลยตัดล็อกอินออกได้ ถ้าตกข้อใดต้องมีการยืนยันตัวตน

เส้นทางที่เดายาก ไม่ใช่ความปลอดภัย แต่ก็ไม่ใช่ของไร้ค่า

หน้านี้ผมวางไว้ใต้เส้นทางที่เดาไม่ถูก คือเป็นโฟลเดอร์ที่มีชื่อสุ่มยาวๆ ไม่มีลิงก์จากที่ไหนชี้เข้ามา

ผมอยากพูดให้ชัดว่าการซ่อนเส้นทางไม่ใช่ความปลอดภัย ใครที่ได้ลิงก์ไปคือเข้าได้ตลอดกาล และลิงก์มีทางหลุดได้หลายทาง ทั้งจากประวัติเบราว์เซอร์ จากการส่งต่อในแชท หรือจากส่วนขยายเบราว์เซอร์ที่ลูกค้าลงไว้ สิ่งที่มันช่วยได้จริงคือลดโอกาสที่ตัวเก็บข้อมูลอัตโนมัติจะเดินมาเจอโดยบังเอิญ เท่านั้น

ชั้นที่สองคือการกันไม่ให้ถูกจัดทำดัชนี ซึ่งต้องทำทั้งที่หน้าเว็บและที่ส่วนหัวของคำตอบจากเซิร์ฟเวอร์

<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="robots" content="noindex, nofollow">
<title>เลือกรูปสำหรับเว็บไซต์</title>
<?php
// save.php — ปลายทางเดียวของทั้งหน้า
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('X-Robots-Tag: noindex, nofollow');   // ตัวคำตอบเองก็ต้องบอกไม่ให้เก็บดัชนี

ส่วนหัวที่สั่งไม่ให้เดานามสกุลไฟล์เอง ผมใส่ไว้เพราะปลายทางนี้คืนข้อความที่มาจากผู้ใช้ ถ้าเบราว์เซอร์เดาว่าเป็น HTML แล้วเรนเดอร์ให้ จะกลายเป็นช่องให้ฝังสคริปต์ได้ทันที

ปลายทางเดียว ตรวจด้วยบัญชีขาว ไม่ใช่บัญชีดำ

หลักที่ผมยึดคือ ทุกค่าที่เข้ามาต้องผ่านตัวกรองที่บอกว่าอะไรผ่านได้ ไม่ใช่ตัวกรองที่บอกว่าอะไรผ่านไม่ได้ เพราะรายการของที่ผ่านไม่ได้นั้นเขียนให้ครบไม่ได้ แต่รายการของที่ผ่านได้เขียนครบได้เสมอ

ด่านของหนึ่งการบันทึกรับ JSON ดิบเพดาน 200 KB->ต้องเป็น arrayไม่ใช่ก็ทิ้ง->กรองชื่อหมวดเหลือ a-z 0-9 ขีด->กรองรหัสรูปต้องตรงแบบเป๊ะ->สำรองของเดิม.bak->เขียนทับล็อกไฟล์
<?php
$F = __DIR__ . '/selections.json';

if (($_SERVER['REQUEST_METHOD'] ?? '') === 'GET') {
    // ขาอ่าน: คืนรายการที่บันทึกไว้ ไม่มีก็คืนโครงว่าง
    if (isset($_GET['get'])) {
        if (is_file($F)) { readfile($F); } else { echo '{"sel":{}}'; }
    } else {
        echo '{"ok":true}';
    }
    exit;
}
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    http_response_code(405);
    echo '{"ok":false,"error":"method"}';
    exit;
}

$raw = file_get_contents('php://input');
// เพดานแรกสุด ก่อนแตะข้อมูลใดๆ — 200 KB พอสำหรับรายการเลือกทั้งเว็บหลายเท่า
if ($raw === false || strlen($raw) > 200000) { echo '{"ok":false,"error":"too-large"}'; exit; }

$d = json_decode($raw, true);
if (!is_array($d)) { echo '{"ok":false,"error":"bad-json"}'; exit; }

หัวใจอยู่ที่ลูปกรองข้างล่างนี้ ผมไม่แตะค่าที่ส่งมาเลยแม้แต่ตัวเดียว แต่สร้างชุดข้อมูลใหม่ขึ้นมาจากค่าที่ผ่านเกณฑ์เท่านั้น ของที่ไม่เข้าเกณฑ์หายไปเงียบๆ ไม่ต้องแจ้งกลับ

<?php
// สร้างของใหม่จากค่าที่ผ่านเกณฑ์ ไม่ใช่แก้ของที่ส่งเข้ามา
$clean = [];
foreach ($d as $k => $v) {
    if (!is_string($k) || !is_array($v)) continue;

    $k = preg_replace('/[^a-z0-9\-]/', '', $k);     // ชื่อหมวดเหลือแค่ตัวพิมพ์เล็ก เลข ขีด
    if ($k === '' || strlen($k) > 40) continue;     // ชื่อยาวเกินคือไม่ใช่ของเรา

    $ids = [];
    foreach ($v as $id) {
        // รหัสรูปต้องตรงแบบเป๊ะ: g ตามด้วยกลุ่ม 1-3 ขีดล่าง แล้วเลขสองหลัก
        if (is_string($id) && preg_match('/^g[123]_[0-9]{2}$/', $id)
            && !in_array($id, $ids, true)) {
            $ids[] = $id;
        }
        if (count($ids) > 60) break;                // หนึ่งหมวดเลือกได้ไม่เกิน 60 รูป
    }
    $clean[$k] = $ids;
    if (count($clean) > 40) break;                  // ทั้งเว็บมี 9 หมวด เผื่อไว้ 40 พอ
}
รหัสรูปที่ผ่านเข้ามา ต้องไม่มีทางกลายเป็นชื่อไฟล์หรือเส้นทางได้

รูปแบบที่ผมบังคับคือตัวอักษรเดียว ตามด้วยเลขกลุ่ม ขีดล่าง และเลขสองหลัก ไม่มีจุด ไม่มีทับ ไม่มีอักขระอื่นเลย แปลว่าค่าที่ผ่านด่านนี้ไปประกอบเป็นเส้นทางไต่โฟลเดอร์ไม่ได้ ไม่ว่าจะเอาไปต่อกับอะไร นี่คือเหตุผลที่ผมใช้นิพจน์ปกติที่มีหัวท้ายรัดไว้ ไม่ใช่แค่ตัดอักขระที่ไม่ชอบทิ้ง เพราะการตัดทิ้งยังเหลือความเป็นไปได้ที่คิดไม่ถึง แต่การบังคับรูปแบบทั้งสายอักขระไม่เหลือ

200 KBเพดานเนื้อคำขอ
60รูปสูงสุดต่อหมวด
40หมวดสูงสุด
40ตัวอักษรของชื่อหมวด
1 ไฟล์ปลายทางที่เขียนได้

เพดานพวกนี้ผมไม่ได้ตั้งจากทฤษฎี แต่ตั้งจากของจริงคูณสี่ถึงห้าเท่า งานนี้มีเก้าหมวดจริงๆ และหมวดที่รูปเยอะสุดมีสิบสี่รูป การตั้งเพดานที่หกสิบและสี่สิบเลยกว้างพอที่ผู้ใช้จะไม่มีทางชน แต่แคบพอที่คนยิงมั่วจะเขียนขยะลงไฟล์ได้ไม่กี่กิโลไบต์

สำรองก่อนเขียนทุกครั้ง และล็อกไฟล์ตอนเขียน

หน้านี้บันทึกอัตโนมัติทุกครั้งที่กด แปลว่ามีการเขียนทับไฟล์บ่อยมาก ถ้าวันไหนมีคนกดปุ่มล้างทั้งหมดโดยไม่ได้ตั้งใจ แล้วเราไม่มีของเดิม คืองานสามวันหายไปในคลิกเดียว

<?php
$out = json_encode(['saved' => date('c'), 'sel' => $clean],
                   JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);

if (is_file($F)) @copy($F, $F . '.bak');        // สำรองของเดิมก่อนทุกครั้ง
$ok = @file_put_contents($F, $out, LOCK_EX);    // ล็อกกันสองคำขอเขียนทับกัน

echo $ok !== false ? '{"ok":true}' : '{"ok":false,"error":"write"}';

การล็อกไฟล์ตอนเขียนไม่ใช่เรื่องเกินจำเป็น เพราะหน้านี้บันทึกอัตโนมัติ และลูกค้าเปิดพร้อมกันสองเครื่องได้ ทั้งมือถือและคอมพิวเตอร์ ถ้าสองคำขอมาถึงพร้อมกันโดยไม่ล็อก ผลที่ได้คือไฟล์ที่มีเนื้อหาครึ่งของแต่ละชุดปนกัน ซึ่งอ่านกลับไม่ได้เลย

ไฟล์สำรองเก็บแค่รุ่นเดียวก็พอสำหรับงานแบบนี้

ผมเคยคิดจะทำเป็นเก็บทุกรุ่นพร้อมเวลากำกับ แต่พอคิดถึงการใช้งานจริงแล้วไม่คุ้ม เพราะเหตุที่จะต้องกู้มีเหตุเดียวคือกดล้างผิด ซึ่งรู้ตัวภายในไม่กี่วินาที ไฟล์สำรองรุ่นก่อนหน้ารุ่นเดียวพอ และไม่มีโฟลเดอร์ที่โตขึ้นเรื่อยๆ ให้ต้องมาตามลบ ถ้างานไหนแก้กันหลายคนหลายวัน ค่อยทำเป็นหลายรุ่น

หลังเขียนโค้ดเสร็จ ผมทดสอบด้วยการยิงขยะเข้าไปเอง ก่อนส่งลิงก์ให้ลูกค้า

# ยิงข้อมูลมั่ว ต้องได้ ok แต่ไฟล์ผลลัพธ์ต้องไม่มีขยะนั้นอยู่เลย
curl -s -X POST -d '{"../../etc":["../../etc/passwd"],"x":[1,2,3]}' \
  https://example.com/p/abc123/save.php

# ยิงก้อนใหญ่เกินเพดาน ต้องได้ too-large
head -c 300000 /dev/urandom | base64 | curl -s -X POST --data-binary @- \
  https://example.com/p/abc123/save.php

# อ่านกลับมาดูว่าเหลืออะไรในไฟล์บ้าง
curl -s 'https://example.com/p/abc123/save.php?get=1'

ผลที่ควรได้จากคำสั่งแรกคือคำตอบว่าสำเร็จ แต่เมื่ออ่านไฟล์กลับมาแล้วต้องไม่มีคีย์นั้นและไม่มีค่านั้นอยู่ ผมยืนยันว่าให้ตอบว่าสำเร็จดีกว่าตอบว่าผิดพลาด เพราะการตอบว่าอะไรผิดตรงไหน คือการสอนคนที่กำลังลองว่าให้แก้ตรงไหนต่อ

{
  "saved": "2026-11-14T10:22:41+07:00",
  "sel": {
    "home": ["g1_03", "g1_07", "g1_12"],
    "rooms-fan": ["g2_01", "g2_04"],
    "rooms-air": ["g3_02", "g3_05", "g3_09"]
  }
}

ฝั่งหน้าเว็บ ออกแบบให้คนที่ไม่ถนัดคอมพิวเตอร์กดได้

เรื่องนี้กินเวลาผมมากกว่าฝั่งเซิร์ฟเวอร์หลายเท่า เพราะคนใช้คือเจ้าของที่พักที่ใช้มือถืออย่างเดียว และไม่เคยใช้เว็บที่ต้องกดเลือกอะไรแบบนี้มาก่อน

  1. ปุ่มเลือกหน้าเป็นกล่องใหญ่สามคอลัมน์บนมือถือ สูงไม่ต่ำกว่า 64 จุดภาพ กดพลาดยาก และมีตัวเลขสีแดงมุมขวาบน บอกว่าหน้านั้นเลือกไว้กี่รูปแล้ว
  2. ไม่ใช้แถบเลื่อนแนวนอนเลยสักที่ เพราะคนที่ไม่ชินจะไม่รู้ว่ามีของซ่อนอยู่ทางขวา ทุกอย่างขึ้นบรรทัดใหม่แทน
  3. รูปที่ยังไม่เลือกแสดงจางลงเล็กน้อย รูปที่เลือกแล้วขึ้นกรอบสีทองพร้อมเลขลำดับ ให้ต่างกันชัดโดยไม่ต้องอ่านตัวหนังสือ
  4. บันทึกอัตโนมัติทุกครั้งที่กด แล้วขึ้นข้อความบอกที่แถบล่างว่าบันทึกแล้วเมื่อไร ไม่มีปุ่มบันทึกให้ลืมกด
  5. มีปุ่มดูคลิปสอนอยู่หัวหน้าจอ เป็นคลิปสั้นที่ผมอัดจอตัวเองกดช้าๆ ทีละขั้น ไม่มีเสียงพูด

ข้อสุดท้ายเป็นข้อที่ได้ผลเกินคาด ลูกค้าเปิดดูรอบเดียวแล้วทำได้เลย ไม่ต้องโทรถามผมสักครั้ง คลิปยาวไม่ถึงหนึ่งนาที อัดด้วยเครื่องมือจับภาพจอธรรมดา แล้วบีบให้เล็กลงก่อนวางไว้ข้างหน้าเว็บ

# บีบคลิปสอนให้เล็กพอสำหรับเน็ตมือถือ และตัดเสียงทิ้ง
ffmpeg -i raw_help.mp4 -an -vf "scale=720:-2" -c:v libx264 -crf 30 -preset slow help.mp4

# ย่อรูปเป็นภาพตัวอย่างสำหรับหน้าเลือก ด้านกว้าง 400 จุดภาพพอ
for f in photos/*.jpg; do
  convert "$f" -resize 400x400^ -gravity center -extent 400x300 -quality 78 "thumb/$(basename "$f")"
done

บันทึกอัตโนมัติที่หน่วงไว้ และข้อความสถานะที่เป็นภาษาคน

ตัวบันทึกอัตโนมัติต้องหน่วงไว้ก่อน ไม่งั้นคนที่กดรัวสิบรูปติดกันจะยิงคำขอสิบครั้งซ้อน ซึ่งนอกจากเปลืองแล้ว ยังมีโอกาสที่คำขอมาถึงสลับลำดับกันจนผลลัพธ์ไม่ตรงกับที่หน้าจอแสดง

let saveTimer = null;
let dirty = false;

function toggle(id, section) {
  const set = selections[section] || (selections[section] = []);
  const i = set.indexOf(id);
  if (i >= 0) set.splice(i, 1); else set.push(id);
  render();
  dirty = true;
  clearTimeout(saveTimer);
  saveTimer = setTimeout(save, 800);   // รอให้หยุดกด 0.8 วิ ค่อยส่งครั้งเดียว
}

function save() {
  fetch('save.php', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(selections)
  })
  .then(r => r.json())
  .then(d => { dirty = !d.ok; status(d.ok ? 'บันทึกแล้ว ' + nowText() : 'บันทึกไม่สำเร็จ'); })
  .catch(() => status('เน็ตหลุด ระบบจะลองใหม่เมื่อกดครั้งต่อไป'));
}

// กันปิดแท็บตอนที่ยังบันทึกไม่เสร็จ
window.addEventListener('beforeunload', e => { if (dirty) e.preventDefault(); });

ข้อความสถานะที่แถบล่างผมเขียนเป็นภาษาคนทั้งหมด ไม่มีรหัสข้อผิดพลาด ไม่มีคำว่าเซิร์ฟเวอร์ เพราะคนอ่านไม่ได้อยากรู้ว่าอะไรพัง เขาอยากรู้แค่ว่าที่กดไปเมื่อกี้เก็บแล้วหรือยัง

สรุป

ระบบทั้งก้อนคือไฟล์ HTML หนึ่งไฟล์ ไฟล์ PHP หนึ่งไฟล์สี่สิบบรรทัด และไฟล์ JSON หนึ่งไฟล์ อยู่บนโฮสต์ร่วมธรรมดา ไม่มีฐานข้อมูล ไม่มีระบบสมาชิก และหลังงานจบผมลบโฟลเดอร์ทิ้งได้ทั้งอันโดยไม่เหลืออะไรให้ดูแลต่อ

ส่วนตัวผมเก็บได้สองข้อ ข้อแรกการไม่มีระบบยืนยันตัวตนต้องเป็นการตัดสินใจ ไม่ใช่การละเลย ตอบสามคำถามเรื่องข้อมูลส่วนบุคคล ความเสียหาย และการกู้กลับให้ได้ก่อน ถ้าตอบไม่ได้ครบแปลว่าต้องมีล็อกอิน ข้อสองเมื่อไม่มีด่านที่ประตู ด่านทั้งหมดต้องไปอยู่ที่ปลายทาง และต้องเป็นบัญชีขาวที่บังคับรูปแบบทั้งสายอักขระ ไม่ใช่การตัดอักขระที่ไม่ชอบทิ้ง

ใครจะใส่รหัสผ่านง่ายๆ ตัวเดียวคั่นไว้หน้าแรกก็ตามสะดวกครับ มันไม่ได้เพิ่มความปลอดภัยมากไปกว่าเส้นทางที่เดายากเท่าไร แต่ถ้าลูกค้ารับได้และทำให้สบายใจขึ้นก็ไม่เสียหาย

ปล. ของที่ผมอยากเติมแต่ยังไม่ได้ทำคือช่องให้พิมพ์คำบรรยายใต้รูปที่เลือกแต่ละรูป ตอนนี้คำบรรยายยังส่งกันทางแชทอยู่ ซึ่งกลับไปเป็นปัญหาเดิมคือไม่รู้ว่าข้อความไหนคู่กับรูปไหน ถ้าเติมช่องนี้ต้องเพิ่มด่านกรองข้อความอิสระอีกชั้น ซึ่งยากกว่าการกรองรหัสรูปพอสมควร เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

อยากให้เราทำให้ไหม?

ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า

คุยเรื่องงานกับเรา

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

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