🔁 ทวนก่อนเริ่ม — ทั้งบทใช้คำพวกนี้ ถ้าข้อไหนไม่คุ้นให้กลับไปบทนั้นก่อน
- Action Text ·
has_rich_text :body= ช่องเนื้อหาที่จัดตัวหนา/ลิงก์ได้ เก็บ HTML ไว้ในลิ้นชักกลาง "ข้อความยาว" (ตารางaction_text_rich_texts) · ล้าง HTML อันตราย (sanitize) ตอนจัดจาน ไม่ใช่ตอนรับออเดอร์ (บท 136–137) - Active Storage + MinIO = ไฟล์ไปอยู่โกดังรับฝากของนอกร้าน · ในตู้แฟ้มเก็บแค่ใบรับฝาก (blob) กับป้ายว่าเป็นของบทความไหน (attachment) · bucket
wongnok-devเป็น private (บท 138) - signed URL · direct upload = บัตรผ่านมีลายเซ็นที่หมดอายุใน 5 นาที · ลูกค้าเอาของไปฝากโกดังเองด้วยใบอนุญาตที่ร้านออกให้ (บท 139)
- variant · ตรวจไฟล์จากเนื้อจริง · ไฟล์กำพร้า = รูปย่อ 400×300 · ไม่เชื่อฉลากที่ลูกค้าเขียนบนกล่อง เปิดดูของข้างใน · ของฝากที่ไม่มีใครมารับ (บท 140)
- prompts/ = แฟ้มใบสั่งงานให้ AI ·
00-context.md= กฎถาวรของร้าน ตอนนี้มี 12 ข้อ (บท 121 · บท 135)
📍 โค้ดเริ่มจาก: ตอนจบบท 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) | 137 | models/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 สำหรับค้นหาภายหลัง | 137 | migration AddPlainTextToPosts |
| รูปปกใช้ Active Storage → service minio | 138 | config/storage.yml · config/environments/development.rb · credentials ของ development |
เปิด direct_upload: true | 138–139 | posts/_form.html.erb · application.js (ActiveStorage.start()) |
| validate ชนิดไฟล์ (png/jpeg/webp) และขนาด ≤ 5MB | 140 | models/post.rb (cover_must_be_small_image ใช้ Marcel อ่านเนื้อไฟล์) |
index ใช้ variant resize_to_limit [400,300] | 140 | posts/_post.html.erb |
controller ต้องมี with_attached_cover และ with_rich_text_body | 137 + 140 | posts_controller.rb (index) · config/application.rb (track_variants = false) |
Lab นี้มี 5 ขั้น — เหมือนบท 135 ทุกขั้น เปลี่ยนแค่ของที่ตรวจ:
ช่างมาติดตั้งของใหม่ 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_upload | 138–139 | ไม่บอก AI จะใช้ :local (ไฟล์ลงดิสก์ของเครื่อง server) และให้ไฟล์วิ่งผ่าน Rails ทั้งก้อน |
| validate ชนิดไฟล์ + ขนาด | 140 | AI มักเช็คแค่นามสกุลหรือ content_type — ข้อห้ามข้อ 3 ปิดทางนี้ไว้อีกชั้น |
| variant 400×300 | 140 | ไม่บอก AI จะโชว์รูปต้นฉบับเต็มไฟล์ในหน้ารวม (รูปละหลายร้อย KB × 10 ใบ) |
with_attached_cover · with_rich_text_body | 137 · 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 ด้วยของจริง
เตรียมโต๊ะตรวจ
- terminal แท็บที่ 1:
bin/dev· แท็บที่ 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) - เตรียมรูปจริงไว้หนึ่งรูป (jpg/png ไม่เกิน 5 MB) · ของผมเป็น
cover.jpg1600×1000 ขนาด 36,582 bytes -
เปิด Chrome ที่
http://localhost:3000/posts/newแล้วเปิด DevTools (Mac Cmd+Option+I · Windows F12) → แท็บ Network · ติ๊ก Preserve log (ไม่ให้รายการหายตอนเปลี่ยนหน้า) · Network tab คือสมุดจดว่าเบราว์เซอร์คุยกับใครบ้าง ทุกคำขอ ทุกปลายทาง - เปิดอีกแท็บที่
http://localhost:9001(MinIO console) → Usernameminioadmin· Passwordminioadmin→ 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 |
| 4 | Network tab: ไฟล์ขึ้นตรงไป :9000 ไม่ผ่าน :3000 | ดูรายการใน Network ตอนกด "บันทึก" ในข้อ 1 | PUT 200 http://localhost:9000/wongnok-dev/fe20nsq… · ฟอร์มที่ส่งไป :3000 มีแค่รหัสไฟล์ (รายการเต็มข้างล่าง) |
| 5 | log หน้า 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
-s= เงียบ ไม่โชว์แถบความคืบหน้า ·-I= ขอแค่หัว · ใส่ URL ใน"…"เพราะมีตัว=และ-ปนอยู่location:= "ไปเอาที่นี่" · เป็น URL ของ:9000(MinIO) ·X-Amz-Date=…T032228Z= เซ็นตอน 03:22:28 เวลาโลก (= 10:22:28 เวลาไทย) ·X-Amz-Expires=300= ใช้ได้ 300 วินาที = 5 นาที ·X-Amz-Signature= ลายเซ็นX-Amz-Credential=minioadmin…= ชื่อกุญแจ (ไม่ใช่รหัสลับ) โผล่ใน URL ได้ · รหัสลับไม่เคยออกจาก servercache-control: max-age=300, private= บอกเบราว์เซอร์ว่า "จำคำตอบนี้ไว้ได้ 5 นาที" เท่าอายุลายเซ็นพอดี
คุณมี 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
URL :3000 = ใบบอกทางที่ร้านแจก "อยากได้ของชิ้นนี้ ถามเคาน์เตอร์ร้าน" — ใครถือมาเมื่อไหร่ เคาน์เตอร์ก็เขียนบัตรผ่านใบใหม่ให้ทุกครั้ง ·
URL :9000 = บัตรผ่านเข้าโกดังที่มีลายเซ็นหมดอายุใน 5 นาที — ยามโกดังดูแค่ลายเซ็นกับเวลา ไม่สนว่าคุณเป็นใคร (หน้าต่าง incognito ไม่มี cookie ก็เข้าได้) · เลยเวลาแล้วยามไม่ให้เข้า
ที่ไม่เหมือนกัน: เคาน์เตอร์ร้านจริงมักถามว่า "คุณเป็นใคร" ก่อนออกบัตร · ของเราตอนนี้ไม่ถามเลย ใครมีใบบอกทางก็ได้บัตรใหม่ไปเรื่อย ๆ — เพราะแอปยังไม่มีระบบ login (มาใน Part 2) · ข้อ 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) ในเบราว์เซอร์:
- หน้า
/posts/31คลิกขวาที่รูปปก → Open image in new tab · แถบที่อยู่ของแท็บใหม่จะเป็นlocalhost:9000/wongnok-dev/…แล้ว (เบราว์เซอร์ตาม 302 ให้เอง) - ก๊อป URL จากแถบที่อยู่นั้น → เปิดหน้าต่าง incognito (Mac Cmd+Shift+N · Windows Ctrl+Shift+N) → วาง → เห็นรูป
- จดเวลาไว้ · รอให้เลย 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):
POST …/direct_uploads— เบราว์เซอร์ขอใบอนุญาตจากร้าน · ส่งไปแค่ชื่อไฟล์ ชนิด ขนาด และ checksum (ตัวเลขสรุปเนื้อไฟล์ ไว้เช็คว่าไฟล์ไม่เสียระหว่างทาง) — ไม่มีตัวไฟล์ (ใน log ของbin/dev:{"filename" => "cover.jpg", "content_type" => "image/jpeg", "byte_size" => 36582, "checksum" => "aUg779RDG3S8ZQ8+Dczm1g=="})OPTIONS 204— เบราว์เซอร์ถามโกดังก่อนว่า "รับของที่ส่งมาจากเว็บ localhost:3000 ไหม" (ด่านถามสิทธิ์ข้ามเว็บ เรียกว่า CORS · บท 139) · 204 = รับPUT 200 … :9000— ตัวไฟล์จริงวิ่งตรงเข้าโกดังบรรทัดนี้ · นี่คือบรรทัดที่เกณฑ์ข้อ 4 ต้องการเห็นPOST 302 …/posts— ส่งฟอร์มเข้าร้าน มีหัวข้อ + HTML ของเนื้อหา + รหัสไฟล์ (signed id) ไม่มีตัวไฟล์ · บันทึกได้ → 302 ไปหน้าบทความ- สองบรรทัดสุดท้าย = เปิดรูปปก: ถามร้าน (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 10 | pagy นับทั้งหมดเพื่อทำเลขหน้า แล้วหยิบมา 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 Load | with_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)
"content_type" => "image/png"— เบราว์เซอร์เดาจากนามสกุล แล้วเขียนฉลากว่าเป็นรูป · ถ้าเชื่อฉลากนี้ ไฟล์ปลอมผ่าน (ตาราง "AI พลาดประจำ")422— model อ่าน 4 KB แรกของไฟล์ด้วย Marcel (เปิดกล่องดูของข้างใน · บท 140) ไม่เจอลายนิ้วมือของรูป จึงไม่รับ
แต่ก่อน 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
for i in $(seq 1 30); do … ; done= ทำซ้ำโดยให้iเป็น 1, 2, … 30 ·$i= ค่าของรอบนั้นgrep -c 'class="trix-content"'= นับว่าหน้านั้นมีกล่องเนื้อหาของ Action Text กี่กล่อง (หน้าปกติ = 1)| sort | uniq -c= รวมผล 30 บรรทัดให้เหลือ "ผลแบบนี้ มีกี่หน้า"
ผลจริง:
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")))
cover: { io: …, filename: …, content_type: … }= ยื่นไฟล์ให้ Active Storage พร้อมฉลากที่เราเขียนเอง — แบบเดียวกับที่เบราว์เซอร์เขียนฉลากimage/pngให้ตอนอัป ·io:= ตัวไฟล์ที่เปิดไว้p= พิมพ์ค่าออกมาดู ·post.cover.content_type= ฉลาก ·filename.extension= นามสกุลpost.valid?= สั่ง validation ทุกตัวทำงานโดยยังไม่บันทึก ได้true/false·errors.full_messages= ข้อความ error ที่จะขึ้นในกล่องแดง- บรรทัดสุดท้าย:
File.binread= อ่านเนื้อไฟล์ดิบทั้งก้อน ·StringIO.new(…)= ห่อเนื้อนั้นให้หน้าตาเหมือนไฟล์ · ส่งให้Marcel::MimeType.forดูแค่เนื้อ ไม่ส่งชื่อไฟล์หรือฉลากไปด้วย
"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>))
ApplicationMailer= แม่แบบของ "คนส่งจดหมาย" ที่มากับrails new(ละเอียดในบล็อก E) ·mail(…)= เขียนซองจดหมาย (ส่งถึงใคร หัวเรื่องอะไร)do |format| format.html { … } end= "อีเมลฉบับ HTML หน้าตาเป็นแบบนี้" ·render inline:= ใช้ ERB ในเครื่องหมายคำพูดเป็นหน้าตาอีเมล — ตรงกับบรรทัดในสไลด์<%= raw @post.body.to_s %>postในบรรทัดสุดท้าย = บทความหนึ่งตัวที่ผมหยิบมาจาก DB ·%(…)= วิธีเขียน string ของ Ruby ที่ข้างในมี"ได้สบาย ๆ ·alert(1)= payload ทดลองที่ไม่ทำอันตราย แค่เด้งกล่องข้อความ- จากนั้นสร้างอีเมลจาก mailer นี้ (ยังไม่ส่งจริง) แล้วเปิดดู HTML ข้างใน — ผลอยู่ในเฉลยข้างล่าง
ใน 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 อย่างที่ควรรู้:
- ได้ HTML ที่ล้างแล้วเหมือนในอีเมล ห่อด้วยกล่อง
trix-content— แอปมือถือต้องเอา HTML ไปวาดเอง · และใน development ยังติด comment บอกที่อยู่ไฟล์ในเครื่อง server มาด้วย ซึ่งไม่ควรไปถึงลูกค้า - ถ้าแอปมือถือแค่โชว์ข้อความ ใช้
post.body.to_plain_textแทน — ผลจริงได้"สูตรเด็ด ตัวหนา"(ทิ้งทั้งแท็กและเนื้อใน script) · API ของเราตอนนี้ (_post.json.jbuilder) ส่งdescriptionซึ่งเป็นข้อความล้วนอยู่แล้ว (บท 137)
body.to_s = คนจัดจานเอาข้อความหน้าเค้กมาเทียบรายการตัวตกแต่งที่ร้านอนุญาต ทิ้งของที่ไม่อยู่ในรายการแล้ว · raw = บอกพนักงานเสิร์ฟว่า "ไม่ต้องใส่ซองใสอีก ยกไปเลย" ·
วันนี้ปลอดภัยเพราะของผ่านการตรวจมาแล้ว — แต่ป้าย "ยกไปเลย" ติดอยู่ที่ช่องเสิร์ฟ ไม่ได้ติดที่ของ วันไหนมีคนเอาของที่ยังไม่ตรวจมาวางช่องนี้ มันก็ถูกยกไปเลยเหมือนกัน
ที่ไม่เหมือนกัน: พนักงานจริงยังมีตาเห็นว่า "จานนี้แปลก ๆ" · raw ไม่ดูอะไรเลย ไม่เตือน ไม่บ่น ·
และอีเมลไม่ได้อยู่ในเบราว์เซอร์ของเรา — โปรแกรมอ่านอีเมลแต่ละเจ้าจัดการ HTML ไม่เหมือนกัน เราจึงพึ่ง "ปลายทางจะกันให้" ไม่ได้
- 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 |
ข้อห้ามสองข้อที่ไม่ย้ายมาเป็นกฎถาวร:
- "ลบคอลัมน์ description ในรอบนี้" — เรื่องเฉพาะงานนี้ · หลักการทั่วไปมีอยู่แล้วในกฎข้อ 7 (expand-contract)
- "raw หรือ html_safe" — ควรเป็นกฎถาวรด้วยซ้ำ แต่อย่าเพิ่งเติมเองตอนนี้ · บทถัด ๆ ไปเติมกฎต่อจากเลข 15 · ถ้าไฟล์ของคุณมีกฎเกินมา เลขจะไม่ตรงกับบทเรียน เทียบยาก · ระหว่างนี้ข้อห้ามนี้อยู่ในใบสั่ง
04-content.mdแล้ว
ขั้น 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 ตอบอะไร (ของแต่ละคนต่างกันทุกครั้ง)
git log --oneline→ หาเลข commitprompts: ใบสั่งงาน Hotwire + กฎ 9–12(จบ Day 3 ก่อนเริ่มบล็อก D)git switch -c ai-vibe4 <เลข commit นั้น>→git checkout @{-1} -- prompts/(ก็อปโฟลเดอร์prompts/ที่มีใบสั่ง 04 และกฎ 13–15 ตามมาด้วย · ทวนบท 135)- ปิด
bin/dev→bin/rails db:reset→ เปิดbin/devใหม่ — ตู้ต้องกลับไปเป็นแบบที่ยังไม่มีตาราง Action Text / Active Storage - สั่ง AI ประมาณ "ทำตาม @prompts/04-content.md ทีละข้อกำหนด · เสร็จแต่ละข้อให้บอกว่าแก้ไฟล์ไหน และบอกวิธีตรวจเกณฑ์ที่เกี่ยวข้อง"
- ตรวจเองครบ 7 ข้อ + ภารกิจท้ายบท + ตาราง "AI พลาดประจำ" · จ้องเป็นพิเศษ: AI เขียน
minioadminลงstorage.ymlตรง ๆ ไหม (ห้ามข้อ 4) · ใช้bundle addแล้วได้ gem เวอร์ชันอื่นไหม (บท 138) · ลบwith_rich_text_bodyตาม Bullet ไหม (กฎ 13) - กลับบ้าน: 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 รันทีละบรรทัด:
- ห้าม raw/html_safe:
(grep -rnw -e raw -e html_safe app/views app/helpers app/controllers app/models-w= ต้องเป็นคำเต็ม กันไปเจอคำที่มี raw ซ่อนอยู่ข้างใน เช่น "draw" หรือ "drawer" ถ้าวันหลังมีในโค้ด ·-e= หาหลายคำในครั้งเดียว) - ห้าม bucket public — เปิดไฟล์ในโกดังโดยไม่มีลายเซ็น (ใช้ blob key ของคุณจากเกณฑ์ข้อ 2 แทน
<key>):curl -s -w '\nHTTP %{http_code}\n' http://localhost:9000/wongnok-dev/<key> - ห้ามเชื่อ content_type:
grep -rn "Marcel\|content_type" app/models - ห้ามเขียน key ลง storage.yml:
grep -n "access_key\|secret" config/storage.yml - ห้ามลบ 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
ถ้าเจออาการนี้ แปลว่า…
- ข้อ 2 ได้เนื้อไฟล์รูปออกมาเป็นตัวอักษรมั่ว ๆ และ
HTTP 200→ bucket ถูกเปิด public (ใน console ไปที่ bucket → แท็บ Anonymous / Access มีกฎให้ใครก็อ่านได้) · ใครเดา key ได้ก็โหลดได้โดยไม่ต้องผ่านร้าน → ลบกฎนั้นทิ้ง - ข้อ 2 ได้แค่
HTTP 000ไม่มี XML เลย → ต่อ MinIO ไม่ติด (ไม่ได้รัน) ·-sซ่อนข้อความ error ไว้ ลบ-sออกจะเห็นcurl: (7) Failed to connect to localhost port 9000 …·docker compose up -dแล้วลองใหม่ - ⚠️ ข้อ 2 พิมพ์ key ผิดก็ได้
AccessDeniedเหมือนกัน (ผลจริง: ลองกับ key มั่ว ๆ ได้ XML แบบเดียวกัน) — โกดังไม่บอกคนไม่มีบัตรว่ามีของชิ้นนั้นหรือไม่ · ข้อนี้จึงพิสูจน์แค่ว่า "ไม่มีลายเซ็นเข้าไม่ได้" ต้องใช้ key จริงจากbin/rails runner 'p Post.last.cover.key'ถึงจะมีความหมาย - ข้อ 4 เจอบรรทัด
access_key_id: minioadminตรง ๆ → กุญแจโกดังหลุดเข้า git ทุกคนที่ clone ได้ไป · ย้ายไป credentials (บท 138) และในงานจริงต้องเปลี่ยนกุญแจใหม่ด้วย เพราะของเก่าอยู่ในประวัติ git แล้ว
ผลจริงบนโค้ดจุดเช็คพอยต์ 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 |
| 13 | case 1 raw @post.body.to_s (ให้คิดว่าอันตรายแค่ไหน) | Rails 8.1.4: to_s ล้างให้แล้ว script ไม่หลุด · แต่ raw ไร้ประโยชน์และเปิดทางพลาด | แค่รู้ไว้ |
| 13 | case 3 (redirect / proxy) · case 4 (ลูกค้าขอให้ลบรูป) | อยู่ในบท 139 (redirect vs proxy) และบท 140 (purge · ไฟล์ใน MinIO + รูปย่อ) | แค่รู้ไว้ |
| 14 | ป้าย "15 นาที" | เวลาสำหรับให้ AI ทำ · บทนี้ตรวจของจริงทีละข้อ ใช้ ~20 นาทีในเครื่อง (ข้อ 3 ต้องรอ 5 นาทีอยู่แล้ว) | แค่รู้ไว้ |
อ้างอิง
- Rails Guides — Active Storage: Redirect Mode — อ่านย่อหน้าแรก: URL ของ Rails คงที่ แล้ว redirect ไปหา URL ของ service ที่หมดอายุใน 5 นาที (ที่มาของกับดักข้อ 3)
- Rails Guides — Active Storage: Purging Unattached Uploads — ไฟล์ที่อัปแล้วไม่ถูกผูก (เช่น ฟอร์มไม่ผ่าน) ค้างอยู่ใน storage · ตัวอย่างงานเก็บกวาดที่จะเข้าใจเต็ม ๆ ในบล็อก E
- Rails Guides — Action Text: Avoiding N+1 Queries —
สั้นมาก · ที่มาของ
with_rich_text_bodyในเกณฑ์ข้อ 5 - Rails Guides — Action Text: Rendering Rich Text Content —
อ่านว่า Action Text sanitize เนื้อหาตอนแสดงผล (เหตุผลที่
body.to_sใน case 1 ไม่มี script) - OWASP File Upload Cheat Sheet — File Signature Validation — อ่านหัวข้อนี้กับ "Content-Type Validation" ข้างบนมัน: ทำไมเชื่อ Content-Type ไม่ได้ และต้องดู magic bytes (ที่มาของกฎ 14)
- rails/marcel (GitHub) — README ส่วน Usage: ส่งแค่เนื้อไฟล์ = ดูจากเนื้ออย่างเดียว · ส่ง
name:/declared_type:ด้วย = ยอมถอยไปเชื่อชื่อไฟล์ - repo ของอาจารย์ — wongnok-blog-all-in-one —
เลือก branch
day3-d-doneถ้าอยากเทียบกับเฉลยของอาจารย์ · จำไว้ว่าไฟล์ต่างจากบทเรียนนี้