บทเรียน 141 · Rails · Day 4 Part 1

Lab Vibe 4 — เนื้อหาเป็นของจริง: rich text + รูปปก

~30 นาทีอ่าน + ตรวจตามในเครื่องตัวเองอีก ~20 นาที (ในนั้นรอลายเซ็นหมดอายุ 5 นาที) · ควรผ่าน บท 137–140 (Action Text · MinIO · signed URL · variant · ตรวจไฟล์) มาก่อน

บท 137–140 คุณสร้างของบล็อก D ครบแล้วทีละชิ้น · บทนี้ไม่มีโค้ดแอปใหม่ แต่สวมบท "คนตรวจรับงาน" เหมือนบท 135: เอาใบตรวจรับ 7 ข้อของสไลด์หน้า 14 มาไล่ทีละข้อด้วยของจริง (เบราว์เซอร์ · MinIO console · log · curl)
อ่านจบแล้วคุณจะ ตรวจงาน rich text + อัปโหลดไฟล์ที่ใครก็ตามส่งมา (รวมถึง AI) ได้เอง · รู้ว่าเกณฑ์ข้อไหนมีกับดักซ่อนอยู่ · มีไฟล์ prompts/04-content.md กับกฎข้อ 13–15 ไว้สั่งงานครั้งหน้า · และตอบโจทย์ recap ของสไลด์หน้า 13 ได้ด้วยผลรันจริง

🔁 ทวนก่อนเริ่ม — ทั้งบทใช้คำพวกนี้ ถ้าข้อไหนไม่คุ้นให้กลับไปบทนั้นก่อน

📍 โค้ดเริ่มจาก: ตอนจบบท 140 ที่ commit แล้ว (git status --short ว่าง) — หน้า /posts/new มีช่องเนื้อหาแบบ Trix และช่องรูปปก · หน้า /posts โชว์รูปย่อ · บทนี้เพิ่มแค่สองไฟล์ใน prompts/ · จบบทโค้ดของคุณคือจุดเช็คพอยต์ D (ชื่อที่บทเรียนนี้ใช้เรียกจุดพักท้ายบล็อก D = จบเรื่อง Rich text + ไฟล์ · แบบเดียวกับจุดเช็คพอยต์ C ของบท 135)

🖥 เปิดค้างไว้: docker compose up -d (Postgres + MinIO — ต้องเห็น MinIO ที่ localhost:9001) · bin/dev หนึ่งหน้าต่าง (เว็บที่ localhost:3000) · อีกหน้าต่างไว้พิมพ์คำสั่ง · Chrome

🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 18 · aws-sdk-s3 1.238.0 · libvips 8.18.7 · MinIO (image pgsty/silo ตัวเดียวกับ compose ของอาจารย์) · Chrome (สั่งด้วยสคริปต์ ไม่ได้กดเอง) — สไลด์กับ repo ของอาจารย์เขียน Ruby 4.0.5–4.0.6 · Rails 8.1.3 เรื่องในบทนี้ไม่ต่างกัน

ผลรันทุกก้อนรันจริงแล้วก็อปมา · ค่าที่เปลี่ยนทุกครั้ง เช่น เวลา ms · เลข id · เลข commit · วันเวลา · ชื่อ object ใน MinIO (blob key) · ลายเซ็นใน URL ของคุณจะต่าง · log ตัดรหัสสีและ comment ท้าย SQL ออกแล้ว (ในหน้าต่าง bin/dev ทุกบรรทัดของ log จะมี web.1 | นำหน้า)

Lab นี้คือวันตรวจรับ ไม่ใช่วันสร้าง

สไลด์หน้า 14 ให้ Lab นี้เป็น "เขียน prompt ให้ AI ทำทั้งบล็อก แล้วตรวจ" · เว็บนี้เลือกให้คุณทำเองเป็นก่อน — ทุกบรรทัดของเป้าหมายและข้อกำหนดในหน้านั้นคุณทำไปแล้วในบท 137–140 · ที่เหลือคือส่วนที่สำคัญที่สุดของการทำงานกับ AI: ตรวจรับให้เป็น

ข้อกำหนดในสไลด์หน้า 14ทำไปแล้วในบทไฟล์หลักที่ได้
ใช้ Action Text สำหรับเนื้อหา (has_rich_text :body)137models/post.rb · posts/_form.html.erb (rich_textarea)
ย้ายข้อมูลเดิมจาก description มา body แบบ expand-contract ไม่ลบคอลัมน์เก่า137 (ทวน 109)lib/tasks/posts.rake · before_save ใน post.rb ที่ยังเขียน description คู่กัน
เพิ่มคอลัมน์ plain_text สำหรับค้นหาภายหลัง137migration AddPlainTextToPosts
รูปปกใช้ Active Storage → service minio138config/storage.yml · config/environments/development.rb · credentials ของ development
เปิด direct_upload: true138–139posts/_form.html.erb · application.js (ActiveStorage.start())
validate ชนิดไฟล์ (png/jpeg/webp) และขนาด ≤ 5MB140models/post.rb (cover_must_be_small_image ใช้ Marcel อ่านเนื้อไฟล์)
index ใช้ variant resize_to_limit [400,300]140posts/_post.html.erb
controller ต้องมี with_attached_cover และ with_rich_text_body137 + 140posts_controller.rb (index) · config/application.rb (track_variants = false)

Lab นี้มี 5 ขั้น — เหมือนบท 135 ทุกขั้น เปลี่ยนแค่ของที่ตรวจ:

ขั้น 1 เขียนใบสั่งงาน prompts/04-content.md (ทุกบรรทัดมาจากบท 137–140) │ ขั้น 2 ตรวจรับ 7 ข้อ เบราว์เซอร์ + Network + MinIO console + log + curl │ ขั้น 3 ดูที่ AI พลาดประจำ 3 ข้อ และโจทย์ recap หน้า 13 (raw ในอีเมล · body ใน JSON) │ ขั้น 4 เติมกฎ 13–15 prompts/00-context.md (12 ข้อ → 15 ข้อ) │ ขั้น 5 เก็บเข้าโปรเจกต์ git commit │ (ทางเลือก) ให้ AI ทำบล็อก D ใหม่ทั้งหมด แล้วตรวจด้วย checklist ชุดเดียวกัน
🍽 เทียบให้เห็นภาพ — วันตรวจรับร้านที่เพิ่งต่อสัญญากับโกดัง

ช่างมาติดตั้งของใหม่ 4 รอบ: กระดานให้ลูกค้าเขียนข้อความบนหน้าเค้ก (rich text) · สัญญากับโกดังรับฝากของนอกร้าน · ระบบใบอนุญาตให้ลูกค้าเอาของไปฝากเอง · คนเปิดกล่องตรวจของก่อนรับฝาก · วันนี้เจ้าของร้านถือใบตรวจรับ 7 ข้อ เดินลองทีละข้อ: เขียนหน้าเค้กดูจริง · เดินไปโกดังดูว่าของเข้าห้องจริง · เอาบัตรผ่านไปลองใช้หลังหมดเวลา · ยื่นกล่องที่ฉลากโกหกให้ดูว่าโดนจับไหม

ที่ไม่เหมือนกัน: ตรวจรับร้านจริงทำครั้งเดียวจบ · ของเราตรวจซ้ำได้ฟรีทุกครั้งที่โค้ดเปลี่ยน · และบางข้อ "ดูด้วยตาว่าผ่าน" ไม่พอ — เช่น ข้อ 3 ถ้าก๊อป URL ผิดตัว จะผ่านแบบหลอก ๆ (ดูขั้น 2)

🗣 ศัพท์ของขั้นนี้
acceptance criteria (เกณฑ์ตรวจรับ)
รายการข้อที่ต้องจริงทุกข้อ งานถึงจะนับว่า "เสร็จ" (ทวนบท 135) · พิมพ์บอก AI ได้ว่า "For each acceptance criterion, tell me the exact command or browser step I can run to verify it myself."
evidence (หลักฐาน)
สิ่งที่คนอื่นเปิดดูแล้วเห็นเหมือนเรา: บรรทัด log · ผล curl · รายการใน Network tab · ชื่อไฟล์ใน MinIO console — ไม่ใช่ประโยค "ผมลองแล้วใช้ได้" · ถาม senior ได้ว่า "ตอนส่ง PR ควรแนบหลักฐานแบบไหน ถึงจะไม่ต้องให้พี่มาลองเองครับ"

ขั้น 1 — ใบสั่งงาน prompts/04-content.md: ทุกบรรทัดมาจากบทไหน

ไฟล์นี้คือใบสั่งงานที่จะส่งให้ AI ทำบล็อก D ทั้งหมดในครั้งเดียว · สี่ส่วนเหมือนไฟล์ 01–03: เป้าหมาย · ข้อกำหนด · ห้าม · เกณฑ์ตรวจรับ

ตอนนี้ ls prompts ควรเห็น 4 ไฟล์ (00-context.md · 01-views.md · 02-styling.md · 03-hotwire.md) · สร้างไฟล์ prompts/04-content.md แล้วพิมพ์ตามนี้ (ทั้งไฟล์ — ก็อปจากสไลด์หน้า 14 แล้วเพิ่มบรรทัดในวงเล็บหนึ่งบรรทัด ใต้เรื่องย้ายข้อมูล):

## เป้าหมาย

เปลี่ยน description เป็น rich text แก้ไขได้
เพิ่มรูปปกให้โพสต์ เก็บใน MinIO และโชว์รูปย่อในหน้า index

## ข้อกำหนด

- ใช้ Action Text สำหรับเนื้อหา (has_rich_text :body)
- ย้ายข้อมูลเดิมจาก description มา body
  ใช้ท่า expand-contract ไม่ลบคอลัมน์เก่าในรอบนี้
  (ย้ายด้วย rake task ที่รันซ้ำได้ ไม่ใช่ใน migration · escape ข้อความเดิมก่อนเป็น HTML)
- รูปปกใช้ Active Storage -> service minio
- เปิด direct_upload: true
- validate ชนิดไฟล์ (png/jpeg/webp) และขนาด <= 5MB
- index ใช้ variant resize_to_limit [400,300]
- controller ต้องมี with_attached_cover
  และ with_rich_text_body
- เพิ่มคอลัมน์ plain_text สำหรับค้นหาภายหลัง

## ห้าม

- raw หรือ html_safe กับ body ที่ไหนก็ตาม
- ตั้ง bucket เป็น public
- เชื่อ content_type ที่ client ส่งมาอย่างเดียว
- เขียน access key ลง storage.yml ตรง ๆ
- ลบคอลัมน์ description ในรอบนี้

## เกณฑ์ตรวจรับ

- [ ] พิมพ์ตัวหนา ใส่ลิงก์ ในเนื้อหาได้
- [ ] อัปรูปแล้วเปิด MinIO console เห็นไฟล์จริง
- [ ] ก๊อป URL รูปไปเปิด incognito -> ใช้ได้ แล้วรอหมดอายุ
- [ ] Network tab: ไฟล์ขึ้นตรงไป :9000 ไม่ผ่าน :3000
- [ ] log หน้า index ไม่เกิน 6 query
- [ ] อัปไฟล์ .txt ที่เปลี่ยนนามสกุลเป็น .png -> ต้องถูกปฏิเสธ
- [ ] ข้อมูลเดิม 30 โพสต์ยังอ่านได้ครบ

ทวนสัญลักษณ์: ## = หัวข้อ · - [ ] = ช่องติ๊กที่ยังไม่ติ๊ก · -> = ลูกศรธรรมดาที่สไลด์ใช้ ไม่ใช่คำสั่งอะไร · ไฟล์นี้ยาว 36 บรรทัดพอดี (นับด้วย wc -l prompts/04-content.md) · ทุกบรรทัดของข้อกำหนดกับข้อห้ามมาจากเรื่องที่คุณเจอมาแล้ว:

บรรทัดในใบสั่งเรียนในบททำไมต้องเขียนบอก AI (ถ้าไม่เขียน AI มักทำแบบไหน)
expand-contract · ไม่ลบคอลัมน์เก่า · บรรทัดในวงเล็บ137 (ทวน 109)AI ชอบย้ายข้อมูลใน migration แล้วลบคอลัมน์ทิ้งในไฟล์เดียว "ให้จบ ๆ" · บรรทัดในวงเล็บเราเพิ่มเอง (กล่องเตือนข้างล่าง)
service minio · direct_upload138–139ไม่บอก AI จะใช้ :local (ไฟล์ลงดิสก์ของเครื่อง server) และให้ไฟล์วิ่งผ่าน Rails ทั้งก้อน
validate ชนิดไฟล์ + ขนาด140AI มักเช็คแค่นามสกุลหรือ content_type — ข้อห้ามข้อ 3 ปิดทางนี้ไว้อีกชั้น
variant 400×300140ไม่บอก AI จะโชว์รูปต้นฉบับเต็มไฟล์ในหน้ารวม (รูปละหลายร้อย KB × 10 ใบ)
with_attached_cover · with_rich_text_body137 · 140ลืมแล้วไม่มีอะไรพังให้เห็น หน้ายังโชว์ครบ แค่ query บวมเงียบ ๆ (ตาราง "AI พลาดประจำ")
ห้าม 5 ข้อ136–140ห้ามข้อ 1 = กันรู XSS (136) · ข้อ 2 + 4 = กุญแจโกดัง (138–139) · ข้อ 3 = ฉลากโกหก (140) · ข้อ 5 = expand-contract (137) · ตรวจทุกข้อในภารกิจท้ายบท

⚠️ บรรทัดที่เพิ่มจากสไลด์: (ย้ายด้วย rake task ที่รันซ้ำได้ ไม่ใช่ใน migration · escape ข้อความเดิมก่อนเป็น HTML) — สไลด์ไม่ได้บอกว่าย้ายข้อมูลที่ไหน · เราใช้กฎของบท 109 (backfill อยู่ในงานแยก ไม่อยู่ใน migration) และ escape ข้อความเดิม กันคำว่า <script> ใน description เก่ากลายเป็นแท็กจริงตอนย้าย (บท 137)

ขั้น 2 — ตรวจรับ 7 ข้อของสไลด์หน้า 14 ด้วยของจริง

เตรียมโต๊ะตรวจ

  1. terminal แท็บที่ 1: bin/dev · แท็บที่ 2 ไว้พิมพ์คำสั่ง
  2. ทำ "ไฟล์ปลอม" สำหรับข้อ 6 — ข้อความธรรมดาที่ตั้งชื่อลงท้าย .png (เก็บไว้ใน tmp/ ของโปรเจกต์ git จะไม่เห็น):
    echo "hello this is just text, not an image" > tmp/fake.png
    ls -l tmp/fake.png
    > = เขียนผลของ echo ลงไฟล์ · ไฟล์ที่ได้ขนาด 38 bytes (37 ตัวอักษร + ขึ้นบรรทัดใหม่ 1)
  3. เตรียมรูปจริงไว้หนึ่งรูป (jpg/png ไม่เกิน 5 MB) · ของผมเป็น cover.jpg 1600×1000 ขนาด 36,582 bytes
  4. เปิด Chrome ที่ http://localhost:3000/posts/new แล้วเปิด DevTools (Mac Cmd+Option+I · Windows F12) → แท็บ Network · ติ๊ก Preserve log (ไม่ให้รายการหายตอนเปลี่ยนหน้า) · Network tab คือสมุดจดว่าเบราว์เซอร์คุยกับใครบ้าง ทุกคำขอ ทุกปลายทาง
  5. เปิดอีกแท็บที่ http://localhost:9001 (MinIO console) → Username minioadmin · Password minioadmin → Login (ค่าทดลองจาก compose.yaml · บท 138)

ตารางตรวจรับ (ผลจริงที่ผมได้ บนโค้ดสถานะเดียวกับของคุณ · DB ใหม่จาก seeds)

#เกณฑ์ทำยังไงควรเห็น (ผลจริง)
1พิมพ์ตัวหนา ใส่ลิงก์ ในเนื้อหาได้ หน้า /posts/new ช่องเนื้อหาพิมพ์ ข้าวผัดปูเนื้อแน่น อ่านสูตรต่อที่ลิงก์นี้ · ลากเลือก "เนื้อแน่น" กดปุ่ม B · ลากเลือก "ลิงก์นี้" กดปุ่มลิงก์ (รูปโซ่) พิมพ์ https://rubyonrails.org กด Link · ใส่หัวข้อ เลือกหมวด เลือกรูปปก (ไฟล์จริง) · กด "บันทึก" ไปหน้า /posts/31 · "เนื้อแน่น" เป็นตัวหนา · "ลิงก์นี้" เป็นลิงก์สีหลักมีขีดเส้นใต้ · HTML ในหน้า (คลิกขวา → Inspect): <div>ข้าวผัดปู<strong>เนื้อแน่น</strong> อ่านสูตรต่อที่<a href="https://rubyonrails.org">ลิงก์นี้</a></div>
2อัปรูปแล้วเปิด MinIO console เห็นไฟล์จริง แท็บ MinIO console → เมนูซ้าย Buckets → wongnok-dev · ในเทอร์มินัลรัน bin/rails runner 'p Post.last.cover.key' runner ได้ "fe20nsqraac0pyfnxqmith9brend" · ใน console มีแถว fe20nsqraac0pyfnxqmith9brend · Today, 10:20 · 35.7 KiB (36,582 bytes = 35.7 KiB) · ชื่อไฟล์ในโกดังคือเลขใบรับฝาก ไม่ใช่ cover.jpg (บท 138)
3ก๊อป URL รูปไปเปิด incognito → ใช้ได้ แล้วรอหมดอายุ ⚠️ ก๊อปให้ถูกตัว — ดูหัวข้อถัดไป URL ของ :9000 เปิดในหน้าต่างใหม่ที่ไม่มี cookie ได้รูป 1600×1000 · เลย 5 นาทีแล้วได้ Request has expired
4Network tab: ไฟล์ขึ้นตรงไป :9000 ไม่ผ่าน :3000 ดูรายการใน Network ตอนกด "บันทึก" ในข้อ 1 PUT 200 http://localhost:9000/wongnok-dev/fe20nsq… · ฟอร์มที่ส่งไป :3000 มีแค่รหัสไฟล์ (รายการเต็มข้างล่าง)
5log หน้า index ไม่เกิน 6 query เปิด /posts แล้วดูแท็บ bin/dev หาบรรทัด Completed 200 OK ของ GET "/posts" (6 queries, 0 cached) · ไม่มีกล่องเตือนของ Bullet
6อัป .txt ที่เปลี่ยนนามสกุลเป็น .png → ต้องถูกปฏิเสธ /posts/new กรอกครบ แต่รูปปกเลือก tmp/fake.png · กดบันทึก อยู่หน้าเดิม กล่องแดง บันทึกไม่ได้ มี 1 จุดที่ต้องแก้: / Cover รองรับเฉพาะ PNG, JPEG, WebP · Network: POST 422 http://localhost:3000/posts
7ข้อมูลเดิม 30 โพสต์ยังอ่านได้ครบ คำสั่งวนเปิด 30 หน้า (ข้างล่าง) 30 1 = ทั้ง 30 หน้ามีกล่องเนื้อหา 1 กล่อง · หน้า index 1–3 มีการ์ด 10/10/10 และหน้า 4 มี 1 (บทความใหม่ทำให้รวมเป็น 31)

ข้อ 3 — URL รูปมีสองตัว ก๊อปผิดตัวแล้ว "ผ่านแบบหลอก ๆ"

ทวนบท 139: รูปในหน้าเว็บไม่ได้ชี้ไปที่ MinIO ตรง ๆ · src ของ <img> คือ URL ของ Rails · พอเบราว์เซอร์ขอ Rails ตอบ 302 (ไปที่อื่น) พร้อม URL ของ MinIO ที่เพิ่งเซ็นสด ๆ · ดูด้วย curl -I (ขอแค่หัวคำตอบ ไม่เอาเนื้อ) กับ src ของรูปในหน้า /posts/31 (ผลจริง · ตัดหัวที่ไม่เกี่ยวออก):

$ curl -sI "http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MSwicHVyIjoiYmxvYl9pZCJ9fQ==--9cb571efe743a82b14364270d1427f3ec543ab59/cover.jpg"
HTTP/1.1 302 Found
date: Tue, 10 Nov 2026 03:22:28 GMT
location: http://localhost:9000/wongnok-dev/fe20nsqraac0pyfnxqmith9brend?response-content-disposition=inline%3B%20filename%3D%22cover.jpg%22%3B%20filename%2A%3DUTF-8%27%27cover.jpg&response-content-type=image%2Fjpeg&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20261110%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20261110T032228Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=75c7da6c4cc91c9980975faf6f8811b7f21dcd3e372cd2225be97594a9adf788
cache-control: max-age=300, private

คุณมี URL สองตัวจากรูปเดียวกัน: (ก) http://localhost:3000/rails/active_storage/blobs/redirect/…/cover.jpg ที่ก๊อปจาก src · (ข) http://localhost:9000/wongnok-dev/fe20nsq…?…X-Amz-Expires=300… ที่ได้จาก location:

ก๊อปทั้งสองไปเปิดในหน้าต่าง incognito หลังผ่านไปเกิน 5 นาที — ตัวไหนยังได้รูป?

(ก) ตัว :3000 ยังได้รูป · (ข) ตัว :9000 ไม่ได้แล้ว — ผลจริงของผม:

  • (ข) เซ็นตอน 10:22:28 · เปิด 10:22:38 ได้รูป 1600×1000 · เปิดอีกที 10:28:01 ได้ Request has expired (403)
  • (ก) เปิด 10:28:22 (หลัง (ข) หมดอายุแล้ว) ได้ 200 image/jpeg ขนาด 36,582 bytes · เพราะ Rails ตอบ 302 พร้อม URL ใบใหม่ที่มี X-Amz-Date=20261110T032822Z (เซ็นใหม่ตอนนั้นเลย)

แปลว่า ถ้าก๊อป src ไปทดสอบ คุณจะรอ 5 นาทีแล้วเห็นรูปยังขึ้น แล้วสรุปผิดว่า "ลายเซ็นไม่หมดอายุ" · เกณฑ์ข้อ 3 ต้องทดสอบด้วยตัว :9000

🎫 เทียบให้เห็นภาพ — ใบบอกทางของร้าน vs บัตรผ่านเข้าโกดัง

URL :3000 = ใบบอกทางที่ร้านแจก "อยากได้ของชิ้นนี้ ถามเคาน์เตอร์ร้าน" — ใครถือมาเมื่อไหร่ เคาน์เตอร์ก็เขียนบัตรผ่านใบใหม่ให้ทุกครั้ง · URL :9000 = บัตรผ่านเข้าโกดังที่มีลายเซ็นหมดอายุใน 5 นาที — ยามโกดังดูแค่ลายเซ็นกับเวลา ไม่สนว่าคุณเป็นใคร (หน้าต่าง incognito ไม่มี cookie ก็เข้าได้) · เลยเวลาแล้วยามไม่ให้เข้า

ที่ไม่เหมือนกัน: เคาน์เตอร์ร้านจริงมักถามว่า "คุณเป็นใคร" ก่อนออกบัตร · ของเราตอนนี้ไม่ถามเลย ใครมีใบบอกทางก็ได้บัตรใหม่ไปเรื่อย ๆ — เพราะแอปยังไม่มีระบบ login (มาใน Part 2) · ข้อ 3 จึงพิสูจน์ได้แค่ว่า "บัตรผ่านหมดอายุจริง" ไม่ได้พิสูจน์ว่า "รูปเป็นความลับ"

🗣 ศัพท์ของข้อ 3
redirect URL (ของ Active Storage)
URL ถาวรของ Rails ที่ตอบ 302 ไปหา URL ที่เซ็นใหม่ทุกครั้ง · ไม่หมดอายุ · ถาม senior ได้ว่า "รูปที่ต้องเป็นความลับ ควรให้ redirect URL ของ Active Storage ตรวจสิทธิ์ก่อนไหมครับ"
presigned URL · expiry
URL ที่มีลายเซ็นและเวลาหมดอายุติดมาในตัว (X-Amz-Expires) · ค่าเริ่มต้นของ Rails 5 นาที (ActiveStorage.service_urls_expire_in) · พิมพ์บอก AI ได้ว่า "When I test URL expiry, which URL should I copy — the Rails redirect URL or the presigned S3 URL?"

วิธีก๊อปตัวที่ถูก (ตัว :9000) ในเบราว์เซอร์:

  1. หน้า /posts/31 คลิกขวาที่รูปปก → Open image in new tab · แถบที่อยู่ของแท็บใหม่จะเป็น localhost:9000/wongnok-dev/… แล้ว (เบราว์เซอร์ตาม 302 ให้เอง)
  2. ก๊อป URL จากแถบที่อยู่นั้น → เปิดหน้าต่าง incognito (Mac Cmd+Shift+N · Windows Ctrl+Shift+N) → วาง → เห็นรูป
  3. จดเวลาไว้ · รอให้เลย 5 นาที → กด refresh ในหน้าต่าง incognito → เห็นข้อความ XML ว่าหมดอายุ

ผลจริงของผม (เปิดใน Chrome โปรไฟล์ใหม่ที่ไม่มี cookie เลย — ผลเท่ากับ incognito เพราะ URL ที่เซ็นแล้วไม่ใช้ cookie):

# 10:22:38 (10 วินาทีหลังเซ็น) — Chrome โปรไฟล์ใหม่ · cookie 0 ตัว
ได้รูป 1600×1000

# 10:28:01 (5 นาที 33 วินาทีหลังเซ็น) — URL เดิมทุกตัวอักษร · curl ได้ HTTP 403
<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>AccessDenied</Code><Message>Request has expired</Message><Key>fe20nsqraac0pyfnxqmith9brend</Key><BucketName>wongnok-dev</BucketName><Resource>/wongnok-dev/fe20nsqraac0pyfnxqmith9brend</Resource><RequestId>18E6915195788FD3</RequestId><HostId>dd9025ba…</HostId></Error>

XML = ข้อความที่มีแท็กแบบ HTML แต่ใช้ส่งข้อมูล ไม่ได้ใช้ทำหน้าเว็บ — MinIO ตอบ error เป็นแบบนี้ · ใน Chrome จะขึ้นบรรทัด This XML file does not appear to have any style information associated with it. นำหน้า แล้วโชว์ XML เดียวกันแบบเป็นต้นไม้ — ไม่ต้องตกใจ แปลว่า "ไฟล์ XML ที่ไม่มีหน้าตา"

AccessDenied + Request has expired = "ลายเซ็นหมดอายุแล้ว" · ต่างจากไม่มีลายเซ็นเลย ที่ได้ Access Denied. เฉย ๆ (เดี๋ยวลองในภารกิจ) — อ่านคำใน <Message> ให้ออกว่าโดนปฏิเสธเพราะอะไร

ข้อ 4 — Network tab: ตัวไฟล์ไม่ผ่านร้านเลย

รายการใน Network ตอนกด "บันทึก" ในข้อ 1 (ผลจริงจาก Chrome · เรียงตามเวลา · ย่อ URL ยาว ๆ ด้วย … · ตัดไฟล์ CSS/JS ไอคอน และบรรทัด POST …/mini-profiler-resources/results ออก — อันหลังเป็นของตัวจับเวลา rack-mini-profiler (บท 120) ไม่เกี่ยวกับการอัปไฟล์):

POST     200  http://localhost:3000/rails/active_storage/direct_uploads
OPTIONS  204  http://localhost:9000/wongnok-dev/fe20nsqraac0pyfnxqmith9brend?X-Amz-Algorithm=…&X-Amz-Expires=300&X-Amz-SignedHeaders=content-length%3Bcontent-md5%3Bcontent-type%3Bhost&…
PUT      200  http://localhost:9000/wongnok-dev/fe20nsqraac0pyfnxqmith9brend?X-Amz-Algorithm=…&X-Amz-Expires=300&…
POST     302  http://localhost:3000/posts
GET      200  http://localhost:3000/posts/31
GET      302  http://localhost:3000/rails/active_storage/blobs/redirect/…/cover.jpg
GET      200  http://localhost:9000/wongnok-dev/fe20nsqraac0pyfnxqmith9brend?response-content-disposition=…&X-Amz-Expires=300&…

อ่านทีละบรรทัด (ภาพร้านอาหารของบท 139):

  1. POST …/direct_uploads — เบราว์เซอร์ขอใบอนุญาตจากร้าน · ส่งไปแค่ชื่อไฟล์ ชนิด ขนาด และ checksum (ตัวเลขสรุปเนื้อไฟล์ ไว้เช็คว่าไฟล์ไม่เสียระหว่างทาง) — ไม่มีตัวไฟล์ (ใน log ของ bin/dev: {"filename" => "cover.jpg", "content_type" => "image/jpeg", "byte_size" => 36582, "checksum" => "aUg779RDG3S8ZQ8+Dczm1g=="})
  2. OPTIONS 204 — เบราว์เซอร์ถามโกดังก่อนว่า "รับของที่ส่งมาจากเว็บ localhost:3000 ไหม" (ด่านถามสิทธิ์ข้ามเว็บ เรียกว่า CORS · บท 139) · 204 = รับ
  3. PUT 200 … :9000 — ตัวไฟล์จริงวิ่งตรงเข้าโกดังบรรทัดนี้ · นี่คือบรรทัดที่เกณฑ์ข้อ 4 ต้องการเห็น
  4. POST 302 …/posts — ส่งฟอร์มเข้าร้าน มีหัวข้อ + HTML ของเนื้อหา + รหัสไฟล์ (signed id) ไม่มีตัวไฟล์ · บันทึกได้ → 302 ไปหน้าบทความ
  5. สองบรรทัดสุดท้าย = เปิดรูปปก: ถามร้าน (302) → ไปเอาที่โกดังด้วยบัตรผ่านใบใหม่ (200)

⚠️ ถ้าใน Network ไม่มีบรรทัด PUT … :9000 แต่บันทึกได้และรูปขึ้น = ไฟล์วิ่งผ่าน Rails ทั้งก้อน — ช่อง file_field ไม่มี direct_upload: true หรือ application.js ไม่มี ActiveStorage.start() (บท 138) · ดูให้ชัดว่าตัวไหนหายด้วย grep -rn "direct_upload" app/views และ grep -n "ActiveStorage" app/javascript/application.js

ข้อ 5 — log หน้า index: นับ 6 query ให้เห็นกับตา

เปิด /posts แล้วดูแท็บ bin/dev (ผลจริง · หน้าแรกมีบทความใหม่ที่มีรูป 1 ชิ้น + บทความจาก seeds 9 ชิ้น · ตัดบรรทัด ↳ และ Rendered ออก):

Started GET "/posts" for ::1 at 2026-11-10 10:20:23 +0700
Processing by PostsController#index as HTML
  Post Count (1.1ms)  SELECT COUNT(*) FROM "posts"
  Post Load (0.9ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT 10 OFFSET 0
  Category Load (7.9ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (1, 5, 4, 3, 2)
  ActionText::RichText Load (1.3ms)  SELECT "action_text_rich_texts".* FROM "action_text_rich_texts" WHERE "action_text_rich_texts"."record_type" = 'Post' AND "action_text_rich_texts"."name" = 'body' AND "action_text_rich_texts"."record_id" IN (31, 30, 29, 28, 27, 26, 25, 24, 23, 22)
  ActiveStorage::Attachment Load (0.8ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_type" = 'Post' AND "active_storage_attachments"."name" = 'cover' AND "active_storage_attachments"."record_id" IN (31, 30, 29, 28, 27, 26, 25, 24, 23, 22)
  ActiveStorage::Blob Load (0.7ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 1
Completed 200 OK in 27ms (Views: 20.1ms | ActiveRecord: 3.5ms (6 queries, 0 cached) | GC: 7.9ms)
queryมาจากไหน
1 Post Count · 2 Post Load … LIMIT 10pagy นับทั้งหมดเพื่อทำเลขหน้า แล้วหยิบมา 10 (บท 126)
3 Category Load … IN (…)includes(:category) (บท 114)
4 ActionText::RichText Load … IN (31, 30, …)with_rich_text_body — เนื้อหาของทั้ง 10 บทความในใบเบิกเดียว (บท 137)
5 ActiveStorage::Attachment Load · 6 ActiveStorage::Blob Loadwith_attached_cover — ป้ายของทั้งหน้า แล้วใบรับฝากของรูปที่มีจริง (บท 140) · หน้า 2 ที่ไม่มีบทความไหนมีรูปเลย ผมได้ (5 queries, 0 cached) (Rails ข้าม Blob เพราะไม่มี id ให้หา)

บรรทัดสุดท้าย Completed … = สรุปทั้ง request · Views / ActiveRecord / GC = เวลาที่ใช้แต่ละส่วน (ไม่ต้องสนตอนนี้) · ตัวที่เกณฑ์ข้อ 5 ดูคือ (6 queries, 0 cached) = ถามตู้แฟ้มไป 6 ครั้ง

สังเกต: ทั้ง 6 บรรทัดไม่มีตัวไหนเกิดซ้ำต่อบทความ (ไม่มี 10 บรรทัดหน้าตาเหมือนกัน) — นั่นคือหน้าตาของ "ไม่มี N+1"

ที่ได้ 6 ไม่ใช่ 8 ขึ้นไป เพราะบรรทัด config.active_storage.track_variants = false ใน config/application.rb (บท 140) — ปิดการจดประวัติรูปย่อลงตาราง Rails เลยไม่ต้องเบิกตารางนั้นเพิ่ม · บรรทัดนี้ไม่อยู่ในสไลด์ ถ้า AI ทำตามสไลด์อย่างเดียวจะไม่ผ่านข้อนี้

ข้อ 6 — ถูกปฏิเสธแล้ว แต่ไฟล์ปลอมยังนอนอยู่ในโกดัง

ใน log ตอนส่งไฟล์ปลอม (ผลจริง · ตัดมา) ดูสองจุด:

Started POST "/rails/active_storage/direct_uploads" for ::1 at 2026-11-10 10:20:23 +0700
  Parameters: {"blob" => {"filename" => "fake.png", "content_type" => "image/png", "byte_size" => 38, "checksum" => "i6qWQX4nSMSe4tJLtV293Q=="}, …}
…
Started POST "/posts" for ::1 at 2026-11-10 10:20:23 +0700
Completed 422 Unprocessable Content in 14ms (Views: 2.2ms | ActiveRecord: 3.4ms (3 queries, 0 cached) | GC: 0.4ms)

แต่ก่อน 422 นั้น Network มี PUT 200 … :9000 ไปแล้ว — direct upload ส่งไฟล์เข้าโกดังก่อนส่งฟอร์ม · ใน MinIO console ผมเห็นแถว 48d6zqf2kp2sluwcfi24iiiocdo2 · Today, 10:20 · 38.0 B · และใน console ของ Rails (ผลจริง):

$ bin/rails console
Loading development environment (Rails 8.1.4)
wongnok-blog-all-in-one(dev):001> ActiveStorage::Blob.unattached.pluck(:filename, :content_type, :byte_size)
  ActiveStorage::Blob Pluck (0.5ms)  SELECT "active_storage_blobs"."filename", "active_storage_blobs"."content_type", "active_storage_blobs"."byte_size" FROM "active_storage_blobs" LEFT OUTER JOIN "active_storage_attachments" ON "active_storage_attachments"."blob_id" = "active_storage_blobs"."id" WHERE "active_storage_attachments"."id" IS NULL
=> [["fake.png", "image/png", 38]]

บรรทัดที่ขึ้นต้นด้วย ActiveStorage::Blob Pluck = SQL ที่ Rails ส่งไปถามตู้แฟ้ม · => = คำตอบ: ใบรับฝาก 1 ใบ ชื่อ fake.png ขนาด 38 bytes

unattached = ใบรับฝากที่ไม่มีป้ายผูกกับบทความไหน = ของฝากที่ไม่มีใครมารับ (ไฟล์กำพร้า · บท 140) · ตรวจรับข้อ 6 ผ่าน (ปฏิเสธจริง) แต่ขยะในโกดังเป็นงานค้าง — ต้องมีพนักงานหลังร้านมาเก็บกวาดเป็นรอบ ๆ ซึ่งคือเรื่องของบล็อก E (บท 142 เป็นต้นไป) · พิมพ์ exit ออกจาก console

ข้อ 7 — 30 บทความเดิมยังอ่านได้ครบ

ให้คอมพิวเตอร์เปิดทีละหน้าแทนเรา (รันในแท็บที่ 2 · ทั้งหมดบรรทัดเดียว):

for i in $(seq 1 30); do curl -s localhost:3000/posts/$i | grep -c 'class="trix-content"'; done | sort | uniq -c

ผลจริง:

     30 1

อ่านว่า "ผล 1 มี 30 หน้า" = ครบ · ถ้ามีบรรทัด 0 โผล่ เช่น 2 0 แปลว่ามี 2 หน้าที่ไม่มีกล่องเนื้อหา — บทความพวกนั้นยังไม่มี body (ลืมรัน bin/rails posts:backfill_body ของบท 137) หรือหน้านั้นพังเป็น error (เปิดในเบราว์เซอร์ดู) · ถ้า DB ของคุณเคยลบหรือเพิ่มบทความ เลข id อาจไม่ใช่ 1–30 พอดี ให้ดูที่ bin/rails runner 'p Post.where(plain_text: nil).count' ต้องได้ 0 แทน

ขั้น 3 — ที่ AI พลาดประจำ 3 ข้อ — อาการ · ตรวจยังไง · แก้ยังไง

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

AI ทำแบบนี้อาการที่เห็นจริงตรวจยังไงแก้ยังไง
ลืม with_attached_cover (ลบออกจาก index บรรทัดเดียว) หน้าแรกโชว์ครบ ดูไม่ออก · log: (15 queries, 0 cached) จากเดิม 6 (มี ActiveStorage::Attachment Load ทีละบทความ 10 บรรทัด) · Bullet ขึ้น USE eager loading detected / Post => [:cover_attachment] เกณฑ์ข้อ 5 · grep -rn "with_attached\|with_rich_text" app/controllers ใส่ with_attached_cover กลับ · อย่าทำตามบรรทัด Add to your query: .includes([:cover_attachment]) ของ Bullet ตรง ๆ — ได้ป้ายแต่ใบรับฝาก (blob) ยังเบิกทีละใบ · with_attached_cover เบิกทั้งสองอย่าง
validate ไฟล์ด้วยนามสกุล หรือ content_type (โค้ดในสไลด์หน้า 12 ก็เป็นแบบนี้) ไฟล์ปลอม fake.png บันทึกผ่าน · ทั้ง content_type และนามสกุลบอกว่าเป็น png (ผลจริงข้างล่าง) เกณฑ์ข้อ 6 · grep -rn "Marcel\|content_type" app/models ต้องเจอ Marcel::MimeType.for(StringIO.new(…)) อ่านเนื้อไฟล์ช่วงต้นด้วย Marcel โดยไม่ส่งชื่อไฟล์หรือชนิดที่ client บอก (บท 140)
เสนอให้ลบคอลัมน์ description ในรอบเดียวกับที่ย้ายข้อมูล ถ้าลบคอลัมน์ทั้งที่โค้ดยังเขียน description อยู่: แก้แค่หัวข้อบทความก็พัง NoMethodError: undefined method 'description=' for an instance of Post (ผลจริง · ทดลองใน transaction = ทำแล้วยกเลิกทั้งก้อน ข้อมูลจริงไม่เปลี่ยน) · และถ้าต้อง rollback โค้ดกลับไปเวอร์ชันก่อน โค้ดเก่าก็หาคอลัมน์ไม่เจอ เกณฑ์ข้อ 7 · grep -n '"description"' db/schema.rb ต้องยังเจอ t.text "description", null: false · grep -rn "remove_column" db/migrate ต้องว่าง ตอบ AI ว่า "ไม่ลบรอบนี้" — เป็นเฟส expand (บท 109 · 137) · รอบ contract ค่อยเลิกเขียน → ประกาศเลิกใช้ → ลบ

ผลจริงของแถวที่สอง — ถามฉลากกับถามเนื้อไฟล์ ได้คำตอบไม่ตรงกัน · ส่วนนี้แค่อ่านดูผลก็พอ ไม่ต้องพิมพ์ตาม · ผมเขียนไฟล์ tmp/fake_test.rb แล้วรันด้วย bin/rails runner tmp/fake_test.rb (สร้างบทความในหน่วยความจำ ยังไม่บันทึก จึงไม่มีไฟล์ขึ้นโกดัง):

# tmp/fake_test.rb
post = Post.new(title: "ทดสอบไฟล์ปลอม", body: "<div>x</div>", category: Category.first,
                cover: { io: File.open("tmp/fake.png"), filename: "fake.png", content_type: "image/png" })
p post.cover.content_type
p post.cover.filename.extension
p post.valid?
p post.errors.full_messages
p Marcel::MimeType.for(StringIO.new(File.binread("tmp/fake.png")))
"image/png"
"png"
false
["Cover รองรับเฉพาะ PNG, JPEG, WebP"]
"application/octet-stream"

บรรทัด 1–2: ฉลาก (content_type) กับนามสกุลบอกว่าเป็น png ทั้งคู่ → AI ที่ตรวจแบบนี้จะปล่อยผ่าน · บรรทัด 3–4: validation ของเรา (บท 140) ไม่ยอม · บรรทัด 5: Marcel อ่านเนื้อแล้วบอก application/octet-stream = "ข้อมูลอะไรไม่รู้ ไม่ใช่รูปแน่" · (ถ้าลองทำตามแล้ว ลบทิ้งด้วย rm tmp/fake_test.rb)

⚠️ Bullet ยังมีเตือนผิดอีกหนึ่งแบบที่ AI ชอบทำตาม: AVOID eager loading … Post => ["rich_text_body"] — บอกให้ลบ with_rich_text_body ทั้งที่ใช้จริง · บท 137 ปิดเสียงไว้แล้วด้วย Bullet.add_safelist ใน config/environments/development.rb · ถ้า AI ลบ safelist หรือลบ with_rich_text_body ตามคำเตือน → N+1 ของเนื้อหากลับมา (บท 137 วัดได้ 13 query)

โจทย์ recap หน้า 13 case 1 — raw ในอีเมล · body.to_s ใน JSON

สไลด์หน้า 13 ให้โค้ดสองบรรทัดแล้วถามว่าปลอดภัยไหม — บรรทัดแรกในอีเมลสรุปรายสัปดาห์ · บรรทัดที่สองใน API ที่ส่งให้แอปมือถือ · ก่อนตอบ ต้องรู้ก่อนว่า @post.body.to_s ให้อะไรออกมา · ผมใส่ HTML อันตรายลงในเนื้อหาบทความ (แบบที่คนร้ายส่งตรงเข้าฟอร์มโดยไม่ผ่าน Trix · บท 136) แล้วลองจริงใน mailer · ส่วนนี้อ่านดูผลอย่างเดียว ไม่ต้องพิมพ์ตาม (อีเมลเป็นเรื่องของบล็อก E) · และลองแบบนี้ได้เฉพาะกับแอปในเครื่องตัวเอง ห้ามส่งของแบบนี้เข้าเว็บคนอื่น

# ทดลองใน bin/rails runner · ทำใน transaction แล้วย้อนกลับ ข้อมูลจริงไม่เปลี่ยน
class WeeklyTestMailer < ApplicationMailer
  def digest(post)
    @post = post
    mail(to: "reader@example.com", subject: "สรุปรายสัปดาห์") do |format|
      format.html { render inline: "<%= raw @post.body.to_s %>" }
    end
  end
end

post.update!(body: %(<div>สูตรเด็ด <strong>ตัวหนา</strong><script>alert(1)</script><img src="x" onerror="alert(2)"></div>))

ใน HTML ของอีเมลที่ได้ <script> กับ onerror จะยังอยู่ไหม — ในเมื่อมี raw ครอบไว้?

คิดว่าได้อะไร?

ผลจริง (ตัด comment BEGIN/END ที่ development ใส่ไว้บอกชื่อไฟล์ออก):

--- ที่เก็บในตาราง (post.body.body.to_html)
<div>สูตรเด็ด <strong>ตัวหนา</strong><script>alert(1)</script><img src="x" onerror="alert(2)"></div>
--- HTML ในอีเมล
<div class="trix-content">
  <div>สูตรเด็ด <strong>ตัวหนา</strong>alert(1)<img src="x"></div>
</div>

ถูกล้างแล้ว ทั้งที่มี raw — แท็ก <script> หายเหลือแค่ตัวหนังสือ alert(1) · onerror หายจาก <img> · เพราะ body.to_s ผ่าน sanitize ของ Action Text ก่อนแล้ว (ตรวจตอนจัดจาน · บท 136) · raw แค่บอกว่า "ไม่ต้อง escape อีก" กับของที่ล้างมาแล้ว

แล้วทำไมยังห้าม? บรรทัดแรกของผลคือของดิบในตาราง — ยังมี script ครบ · ถ้าวันหนึ่งมีคน (หรือ AI) เปลี่ยน to_s เป็นของดิบ เช่น raw @post.body.body.to_html raw จะปล่อย script เข้าอีเมลทันที · raw ที่ไม่มีประโยชน์ = ซองที่ถูกแกะรอไว้ · เขียน <%= @post.body %> ได้ผลเท่ากันและไม่เปิดช่องนี้

ส่วนบรรทัดที่สอง render json: { body: @post.body.to_s } (API = ช่องที่ส่งข้อมูลเป็น JSON ให้แอปมือถือ) — ผมรันจริงกับบทความที่ใส่ HTML อันตรายแบบเดียวกัน (รันใน runner เป็น { body: post.body.to_s }.to_json ซึ่งคือสิ่งที่ render json: ทำ) · ได้ 2 อย่างที่ควรรู้:

✉️ เทียบให้เห็นภาพ — ซองใสที่ถูกตรวจมาแล้ว

body.to_s = คนจัดจานเอาข้อความหน้าเค้กมาเทียบรายการตัวตกแต่งที่ร้านอนุญาต ทิ้งของที่ไม่อยู่ในรายการแล้ว · raw = บอกพนักงานเสิร์ฟว่า "ไม่ต้องใส่ซองใสอีก ยกไปเลย" · วันนี้ปลอดภัยเพราะของผ่านการตรวจมาแล้ว — แต่ป้าย "ยกไปเลย" ติดอยู่ที่ช่องเสิร์ฟ ไม่ได้ติดที่ของ วันไหนมีคนเอาของที่ยังไม่ตรวจมาวางช่องนี้ มันก็ถูกยกไปเลยเหมือนกัน

ที่ไม่เหมือนกัน: พนักงานจริงยังมีตาเห็นว่า "จานนี้แปลก ๆ" · raw ไม่ดูอะไรเลย ไม่เตือน ไม่บ่น · และอีเมลไม่ได้อยู่ในเบราว์เซอร์ของเรา — โปรแกรมอ่านอีเมลแต่ละเจ้าจัดการ HTML ไม่เหมือนกัน เราจึงพึ่ง "ปลายทางจะกันให้" ไม่ได้

🗣 ศัพท์ของ case 1
defense in depth
กันหลายชั้น ชั้นหนึ่งพลาดยังมีอีกชั้น · raw คือการถอดชั้น escape ทิ้งโดยไม่ได้อะไรกลับมา · ถาม senior ได้ว่า "ถ้า sanitize ของ Action Text ทำงานแล้ว เรายังต้องห้าม raw ทั้งโปรเจกต์ไหมครับ"
output context (HTML · email · JSON)
ของชิ้นเดียวกันต้องเตรียมต่างกันตามปลายทาง · พิมพ์บอก AI ได้ว่า "This API is consumed by a mobile app that displays plain text. Return body.to_plain_text, never raw HTML from Action Text."

ขั้น 4 — เติมกฎ 13–15 ลง 00-context.md: คู่มือโตจาก 12 ข้อเป็น 15 ข้อ

ใบสั่ง 04-content.md ใช้ครั้งเดียวต่อบล็อก · ส่วน 00-context.md คือกฎถาวรของร้านที่ AI อ่านทุกครั้ง (โหลดผ่าน CLAUDE.md · บท 121) · บทเรียนที่ต้องใช้ซ้ำในงานอื่นจึงย้ายมาอยู่ที่นี่ · เปิด prompts/00-context.md แล้วแทรกก้อนนี้ไว้ระหว่างกฎข้อ 12 กับหัวข้อ ## รูปแบบการตอบ · ให้มีบรรทัดว่าง 1 บรรทัดทั้งก่อนและหลังก้อนนี้ (แบบเดียวกับหัวข้ออื่นในไฟล์ — ลืมบรรทัดว่างหลังกฎข้อ 15 แล้วไฟล์จะไม่ตรงกับบทเรียน):

## กฎเพิ่มจากบล็อค Rich text และไฟล์

13. attachment และ rich text ที่โชว์ในหน้ารวม ต้องมี with_attached_* / with_rich_text_* ใน controller
    Bullet เตือน AVOID eager loading "rich_text_*" = เตือนผิด ห้ามลบ ให้ add_safelist ใน development.rb
14. ตรวจชนิดไฟล์จากเนื้อไฟล์จริง (magic bytes) ไม่เชื่อ content_type หรือนามสกุลที่ client ส่งมา
15. bucket เป็น private เสมอ ให้ดูไฟล์ผ่าน signed URL และ access key อยู่ใน credentials เท่านั้น
กฎมาจากทำไมเป็นกฎถาวร ไม่ใช่แค่ในใบสั่ง
13พลาดประจำข้อ 1 + กับดัก Bullet (บท 137 · 140)ทุกหน้ารวมในอนาคตที่มีรูปหรือ rich text (หน้าหมวด · หน้าค้นหา) เจอเรื่องเดียวกัน · บรรทัดที่สองกัน AI "ทำตาม Bullet" แล้วพัง
14พลาดประจำข้อ 2 + ห้ามข้อ 3 (บท 140)ใช้กับทุกช่องอัปโหลดในอนาคต (รูปโปรไฟล์ · ไฟล์แนบ) ไม่ใช่แค่รูปปก · magic bytes = ลายนิ้วมือช่วงต้นไฟล์ที่บอกชนิดจริง
15ห้ามข้อ 2 + 4 (บท 138–139)รวมสองข้อห้ามเรื่องกุญแจโกดังไว้ที่เดียว · bucket ใหม่ทุกอันในอนาคตต้อง private

ข้อห้ามสองข้อที่ไม่ย้ายมาเป็นกฎถาวร:

ขั้น 5 — เก็บเข้าโปรเจกต์

ดูก่อนว่าจะเก็บอะไร (ผลจริง — ต้องมีแค่สองบรรทัดนี้):

$ git status --short
 M prompts/00-context.md
?? prompts/04-content.md

M = แก้ไฟล์เดิม · ?? = ไฟล์ใหม่ · tmp/fake.png ไม่โผล่เพราะ tmp/ อยู่ใน .gitignore ตั้งแต่ rails new · ถ้ามีไฟล์ใต้ app/ หรือ config/ โผล่มาด้วย แปลว่ายังมีของทดลองค้าง → git diff ดูก่อน แล้ว git restore <ไฟล์>

$ git add .
$ git commit -m "prompts: ใบสั่งงาน rich text + รูปปก และกฎ 13–15"
[my-day3 828d702] prompts: ใบสั่งงาน rich text + รูปปก และกฎ 13–15
 2 files changed, 43 insertions(+)
 create mode 100644 prompts/04-content.md

43 insertions = 7 บรรทัดใน 00-context (หัวข้อ · บรรทัดว่าง · กฎ 4 บรรทัด · บรรทัดว่าง) + 36 บรรทัดของไฟล์ใหม่ · ในวงเล็บเหลี่ยมคือชื่อ branch ที่คุณอยู่กับเลข commit — ของคุณจะต่าง

✅ จบ Lab: โค้ดของคุณตอนนี้คือจุดเช็คพอยต์ D — บทความมีเนื้อหาแบบ rich text และรูปปกที่เก็บในโกดังนอกร้าน · สไลด์หน้า 2 บอกว่าตามไม่ทันก็ checkout branch สำเร็จรูปของอาจารย์ได้ (ของบล็อก D คือ day3-d-done) — ใช้ได้ แต่ไฟล์ข้างในต่างจากบทเรียนนี้ (ใช้ระบบแปลภาษา i18n · ชื่อ bucket · เลขกฎใน 00-context ไม่ตรงกัน) · แนะนำให้ไล่บท 137–140 มากกว่า

ทางเลือก: ลอง vibe จริง — ให้ AI ทำบล็อก D ใหม่ทั้งหมด แล้วตรวจด้วย checklist ชุดนี้

ทำหลัง commit ข้างบน · ไม่บังคับ · ขั้นตอนเหมือนบท 135 หัวข้อ "ทางเลือก" ทุกข้อ ต่างแค่จุดเริ่มกับชื่อใบสั่ง · บทนี้ไม่โชว์ว่า AI ตอบอะไร (ของแต่ละคนต่างกันทุกครั้ง)

  1. git log --oneline → หาเลข commit prompts: ใบสั่งงาน Hotwire + กฎ 9–12 (จบ Day 3 ก่อนเริ่มบล็อก D)
  2. git switch -c ai-vibe4 <เลข commit นั้น> → git checkout @{-1} -- prompts/ (ก็อปโฟลเดอร์ prompts/ ที่มีใบสั่ง 04 และกฎ 13–15 ตามมาด้วย · ทวนบท 135)
  3. ปิด bin/dev → bin/rails db:reset → เปิด bin/dev ใหม่ — ตู้ต้องกลับไปเป็นแบบที่ยังไม่มีตาราง Action Text / Active Storage
  4. สั่ง AI ประมาณ "ทำตาม @prompts/04-content.md ทีละข้อกำหนด · เสร็จแต่ละข้อให้บอกว่าแก้ไฟล์ไหน และบอกวิธีตรวจเกณฑ์ที่เกี่ยวข้อง"
  5. ตรวจเองครบ 7 ข้อ + ภารกิจท้ายบท + ตาราง "AI พลาดประจำ" · จ้องเป็นพิเศษ: AI เขียน minioadmin ลง storage.yml ตรง ๆ ไหม (ห้ามข้อ 4) · ใช้ bundle add แล้วได้ gem เวอร์ชันอื่นไหม (บท 138) · ลบ with_rich_text_body ตาม Bullet ไหม (กฎ 13)
  6. กลับบ้าน: commit → git switch my-day3 → ปิด bin/dev → bin/rails db:reset → เปิด bin/dev ใหม่

สรุปบล็อก D — ได้อะไรไปบ้าง

บทภาพร้านอาหารประโยคที่ควรอธิบายให้เพื่อนฟังได้
136รายการตัวตกแต่งที่ร้านอนุญาต · ตรวจตอนจัดจานallowlist ปลอดภัยกว่า blocklist · sanitize ตอนแสดง กฎใหม่ใช้กับข้อมูลเก่าได้ทันที
137ลิ้นชักกลาง "ข้อความยาว"เนื้อหาไม่อยู่ในตาราง posts แล้ว → หน้ารวมต้อง with_rich_text_body · ย้ายข้อมูลแบบ expand ไม่ลบของเก่า
138โกดังรับฝากของนอกร้าน · ใบรับฝาก + ป้ายไฟล์ไม่อยู่ในเครื่อง server · กุญแจโกดังอยู่ใน credentials
139บัตรผ่านหมดอายุ 5 นาที · ลูกค้าไปฝากเองbucket private + signed URL · ไฟล์ไม่ผ่าน Rails (PUT ตรงไป :9000)
140หั่นชิ้นพอดีคำ · เปิดกล่องดูของข้างใน · ของฝากไม่มีคนรับรูปย่อสร้างครั้งแรกแล้วเก็บไว้ · ตรวจชนิดจากเนื้อไฟล์ · ไฟล์ที่ถูกปฏิเสธยังค้างในโกดัง

งานค้างที่บล็อก D ทิ้งไว้ให้บล็อก E: ไฟล์กำพร้าต้องมีคนเก็บกวาด · และตอนอัปรูปในขั้น 2 log ของ bin/dev มีบรรทัด [ActiveJob] Enqueued ActiveStorage::AnalyzeJob … to Async(default) — Rails มี "ใบงานหลังร้าน" อยู่แล้วโดยที่เราไม่ได้สั่ง · บท 142 เริ่มเรื่องนี้

ภารกิจ: ตรวจข้อห้าม 5 ข้อด้วยคำสั่ง

🎯 ภารกิจ Lab Vibe 4 (~10 นาที)

เกณฑ์ตรวจรับ 7 ข้อบอกว่า "ทำได้" · ส่วนข้อห้าม 5 ข้อบอกว่า "ไม่ได้ทำของอันตรายแถมมา" — ตรวจด้วยตาไม่ได้ ต้องใช้คำสั่ง · bin/dev เปิดค้างไว้ · แท็บที่ 2 รันทีละบรรทัด:

  1. ห้าม raw/html_safe:
    grep -rnw -e raw -e html_safe app/views app/helpers app/controllers app/models
    (-w = ต้องเป็นคำเต็ม กันไปเจอคำที่มี raw ซ่อนอยู่ข้างใน เช่น "draw" หรือ "drawer" ถ้าวันหลังมีในโค้ด · -e = หาหลายคำในครั้งเดียว)
  2. ห้าม bucket public — เปิดไฟล์ในโกดังโดยไม่มีลายเซ็น (ใช้ blob key ของคุณจากเกณฑ์ข้อ 2 แทน <key>):
    curl -s -w '\nHTTP %{http_code}\n' http://localhost:9000/wongnok-dev/<key>
  3. ห้ามเชื่อ content_type:
    grep -rn "Marcel\|content_type" app/models
  4. ห้ามเขียน key ลง storage.yml:
    grep -n "access_key\|secret" config/storage.yml
  5. ห้ามลบ description:
    grep -n '"description"' db/schema.rb

ควรเห็น: ข้อ 1 เจอแค่บรรทัด comment <%# … ห้ามใส่ raw/html_safe %> · ข้อ 2 ได้ XML AccessDenied + HTTP 403 · ข้อ 3 เจอ Marcel::MimeType.for(StringIO.new(cover_head)) · ข้อ 4 ทุกบรรทัดที่ไม่ใช่ comment เป็น Rails.application.credentials.dig(…) · ข้อ 5 เจอ t.text "description", null: false

ถ้าเจออาการนี้ แปลว่า…

ผลจริงบนโค้ดจุดเช็คพอยต์ D (key ของคุณจะต่าง · RequestId กับ HostId เปลี่ยนทุกครั้ง):

$ grep -rnw -e raw -e html_safe app/views app/helpers app/controllers app/models
app/views/posts/_post.html.erb:20:      <%# Action Text ล้าง HTML อันตราย (sanitize) ให้เองตอนแสดง — ห้ามใส่ raw/html_safe %>
$ curl -s -w '\nHTTP %{http_code}\n' http://localhost:9000/wongnok-dev/fe20nsqraac0pyfnxqmith9brend
<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>AccessDenied</Code><Message>Access Denied.</Message><Key>fe20nsqraac0pyfnxqmith9brend</Key><BucketName>wongnok-dev</BucketName><Resource>/wongnok-dev/fe20nsqraac0pyfnxqmith9brend</Resource><RequestId>18E691191E004D90</RequestId><HostId>dd9025ba…</HostId></Error>
HTTP 403
$ grep -rn "Marcel\|content_type" app/models
app/models/post.rb:29:      # ชนิดไฟล์: ดูจากเนื้อไฟล์จริง (magic bytes ต้นไฟล์) ไม่ใช่ content_type หรือนามสกุลที่เบราว์เซอร์บอกมา
app/models/post.rb:30:      real_type = Marcel::MimeType.for(StringIO.new(cover_head))
$ grep -n "access_key\|secret" config/storage.yml
18:  access_key_id: <%= Rails.application.credentials.dig(:minio, :access_key_id) %>
19:  secret_access_key: <%= Rails.application.credentials.dig(:minio, :secret_access_key) %>
21:# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
24:#   access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
25:#   secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
$ grep -n '"description"' db/schema.rb
74:    t.text "description", null: false
  • ข้อ 1: บรรทัดเดียวที่เจอเป็น <%# = comment ของ ERB ไม่ถูกรัน (บท 122) · ผ่าน
  • ข้อ 2: Access Denied. (ไม่ใช่ Request has expired) = "ไม่มีบัตรผ่านเลย" · โกดังไม่ให้เข้า · ผ่าน — เทียบกับข้อ 3 ของตรวจรับ: บัตรผ่านหมดอายุได้ข้อความอีกแบบ
  • ข้อ 3: Marcel อ่านจาก cover_head (4 KB แรกของไฟล์) · คำว่า content_type ที่เจอมีแค่ใน comment · ผ่าน
  • ข้อ 4: บรรทัด 18–19 ดึงจาก credentials · บรรทัด 21–25 ขึ้นต้นด้วย # = ตัวอย่างที่ rails new ใส่ไว้ให้ ไม่ทำงาน · ผ่าน
  • ข้อ 5: คอลัมน์ยังอยู่และยัง NOT NULL (โค้ดเขียนค่าลงทุกครั้งที่ save · บท 137) · ผ่าน

Quiz

ข้อ 1 · (สไลด์หน้า 13 case 2) controller เขียน @posts = Post.includes(:category).limit(20) · ใน _post.html.erb มี image_tag post.cover.variant(…) และ truncate(post.body.to_plain_text, length: 100) · ต้องเติมอะไรใน controller?

ตัวเลือกที่ 2 ถูก · รูปปกอยู่ในตาราง attachments + blobs และเนื้อหาอยู่ในลิ้นชักกลาง action_text_rich_texts — ไม่มีอันไหนอยู่ในตาราง posts · ต้องสั่งเบิกทั้งสองอย่าง (เกณฑ์ข้อ 5: 6 query)

ตัวเลือกที่ 1 ผิด — includes(:category) เบิกแค่หมวด · ตัวเลือกที่ 3 ได้แค่ครึ่งเดียว — ผลจริงตอนลืม with_attached_cover Bullet แนะนำ .includes([:cover_attachment]) ซึ่งได้ป้ายแต่ใบรับฝาก (blob) ยังเบิกทีละใบ และยังไม่แก้ rich text · ตัวเลือกที่ 4 ลดจำนวนแต่ยังเป็น N+1 อยู่ (10 บทความก็ยังเบิกทีละบทความ)

จุดที่ต้องจำ: ของที่โชว์ในหน้ารวมแต่ไม่อยู่ในตารางหลัก = ต้องมี with_attached_* / with_rich_text_* (กฎ 13)

ข้อ 2 · เพื่อนตรวจเกณฑ์ข้อ 3 โดยก๊อป src ของรูปปก (localhost:3000/rails/active_storage/blobs/redirect/…) ไปเปิดใน incognito หลัง 10 นาที — ยังเห็นรูป · เขาสรุปว่า "signed URL ไม่หมดอายุ ระบบพัง" · คุณตอบว่าอะไร?

ตัวเลือกที่ 4 ถูก · URL :3000 ตอบ 302 พร้อม URL ของ MinIO ที่เซ็นใหม่ (X-Amz-Date ใหม่) ทุกครั้งที่ถูกขอ · ตัวที่หมดอายุใน 5 นาทีคือตัว :9000 (ผลจริงในขั้น 2: ได้ Request has expired)

ตัวเลือกที่ 1 ไม่แก้อะไร — อายุสั้นแค่ไหน URL ของ Rails ก็ออกใบใหม่ได้ · ตัวเลือกที่ 2 ผิด — ภารกิจข้อ 2 พิสูจน์แล้วว่าไม่มีลายเซ็นได้ 403 · ตัวเลือกที่ 3 ผิด — URL ที่เซ็นแล้วไม่ใช้ cookie เลย (ผมเปิดจาก Chrome โปรไฟล์ใหม่ที่มี cookie 0 ตัว ก็ได้รูป)

จุดที่ต้องจำ: ก่อนสรุปว่าระบบพัง เช็คก่อนว่าตรวจถูกตัว — curl -I ดู location: แล้วทดสอบ URL ปลายทางนั้น

ข้อ 3 · (สไลด์หน้า 13 case 1) ในอีเมลสรุปรายสัปดาห์มี <%= raw @post.body.to_s %> · บทความหนึ่งมี <script> ที่คนร้ายส่งตรงเข้าฟอร์ม · ผลจริงคืออะไร และควรทำยังไง?

ตัวเลือกที่ 1 ถูก · ผลจริงในหัวข้อ recap: HTML ในอีเมลเหลือ alert(1) เป็นตัวหนังสือ · onerror หาย · เพราะ sanitize ทำงานใน to_s ก่อนถึง raw · แต่ raw ไม่ได้ช่วยอะไรและพร้อมปล่อยของดิบถ้ามีคนเปลี่ยนเป็น body.body.to_html

ตัวเลือกที่ 2 คิดว่า raw ปิด sanitize — ไม่จริง มันแค่ไม่ escape ซ้ำ · ตัวเลือกที่ 3 ผิด — อีเมลถูกสร้างได้ปกติ · ตัวเลือกที่ 4 ผิดสองจุด — แท็กถูกถอดทิ้ง (sanitize) ไม่ใช่แปลงเป็นตัวหนังสือ (escape) และยังต้องแก้เพราะผิดข้อห้ามข้อ 1

จุดที่ต้องจำ: ปลอดภัยวันนี้เพราะบังเอิญ ≠ ปลอดภัยโดยการออกแบบ · ไม่มีเหตุผลต้องใช้ raw กับ body เลย

ข้อ 4 · ตรวจเกณฑ์ข้อ 6 ผ่าน (ไฟล์ปลอมถูกปฏิเสธ 422) · แต่เปิด MinIO console ยังเห็นไฟล์ขนาด 38.0 B อยู่ใน bucket · หมายความว่าอะไร?

ตัวเลือกที่ 3 ถูก · Network: PUT 200 … :9000 เกิดก่อน POST 422 /posts · ใน console ของ Rails ActiveStorage::Blob.unattached ได้ [["fake.png", "image/png", 38]] = ใบรับฝากที่ไม่มีป้ายผูกกับบทความไหน

ตัวเลือกที่ 1 ผิด — ถ้าผูกแล้วจะไม่อยู่ใน unattached และหน้าจะ redirect ไม่ใช่ 422 · ตัวเลือกที่ 2 ผิด — MinIO ไม่รู้เรื่อง validation ของ Rails เลย ไม่มีใครลบให้ · ตัวเลือกที่ 4 ผิด — Network มี PUT ครั้งเดียวต่อการกดบันทึก

จุดที่ต้องจำ: ตรวจหลังอัป = มีของค้างเสมอ · ระบบที่รับไฟล์ต้องมีพนักงานหลังร้านเก็บกวาด (บล็อก E)

ถ้าเปิดสไลด์ดูประกอบ

หน้าสไลด์เขียนของจริงต้องสนใจไหม
14"ก๊อป URL รูปไปเปิด incognito → ใช้ได้ แล้วรอหมดอายุ"URL ใน src (:3000) ไม่หมดอายุ · ต้องก๊อปตัว :9000 หลัง redirectสนใจ — ขั้น 2 ข้อ 3
14"log หน้า index ไม่เกิน 6 query" + ข้อกำหนด with_attached_coverได้ 6 เพราะ track_variants = false (บท 140) ซึ่งไม่อยู่ในสไลด์ · ถ้าไม่มีบรรทัดนี้ได้ 8–10สนใจ — ขั้น 2 ข้อ 5
14"ย้ายข้อมูลเดิมจาก description มา body" (ไม่บอกย้ายที่ไหน)ใบสั่งของเราเพิ่มบรรทัด: rake task ที่รันซ้ำได้ + escape ข้อความเดิมสนใจ — ขั้น 1
13case 1 raw @post.body.to_s (ให้คิดว่าอันตรายแค่ไหน)Rails 8.1.4: to_s ล้างให้แล้ว script ไม่หลุด · แต่ raw ไร้ประโยชน์และเปิดทางพลาดแค่รู้ไว้
13case 3 (redirect / proxy) · case 4 (ลูกค้าขอให้ลบรูป)อยู่ในบท 139 (redirect vs proxy) และบท 140 (purge · ไฟล์ใน MinIO + รูปย่อ)แค่รู้ไว้
14ป้าย "15 นาที"เวลาสำหรับให้ AI ทำ · บทนี้ตรวจของจริงทีละข้อ ใช้ ~20 นาทีในเครื่อง (ข้อ 3 ต้องรอ 5 นาทีอยู่แล้ว)แค่รู้ไว้

อ้างอิง