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

ย่อรูป ตรวจไฟล์จากเนื้อจริง และไฟล์กำพร้า

~25 นาที · ควรผ่าน บทเรียน 138 (รูปปก · MinIO) · บทเรียน 139 (direct upload) และ บทเรียน 125 (N+1 ใน view · bullet) มาก่อน

บท 138 ให้บทความมีรูปปกได้แล้ว แต่ยังมีสามเรื่องที่ทุกโปรเจกต์ที่รับไฟล์ต้องทำ (สไลด์หน้า 12): รูปปกขนาดเต็มใหญ่เกินไปสำหรับการ์ดเล็ก ๆ · ใครจะส่งไฟล์อะไรมาก็ได้แค่ตั้งชื่อลงท้าย .png · และไฟล์ที่อัปขึ้นไปแล้วแต่ไม่ได้ใช้ ค้างอยู่ในโกดังตลอดไป
อ่านจบบทนี้ คุณจะทำรูปย่อด้วย variant และดูได้ว่ามันถูกสร้างตอนไหน · แก้ N+1 ที่รูปปกพามาด้วย with_attached_cover · พิสูจน์ด้วยไฟล์ปลอมของตัวเองว่าโค้ดตรวจไฟล์ในสไลด์ไม่พอ แล้วเปลี่ยนเป็นตรวจจากเนื้อไฟล์ · และนับ/เก็บกวาดไฟล์กำพร้าได้

🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้

🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 18 · libvips 8.18.7 · image_processing 1.14.0 · marcel 1.2.1 · MinIO (image pgsty/silo) · Chrome (สั่งด้วยสคริปต์) — สไลด์และ repo ของอาจารย์เขียน Ruby 4.0.5–4.0.6 · Rails 8.1.3 เรื่องในบทนี้ไม่ต่างกัน
📍 โค้ดเริ่มจาก: ตอนจบบท 138 (มีรูปปก + direct upload) และคืนการทดลองของบท 139 แล้ว (git status --short ว่าง) · บทนี้เก็บโค้ดไว้จริง — จบบทจะแก้ 5 ไฟล์: _post.html.erb · posts_controller.rb · config/application.rb · post.rb · _form.html.erb
🖥 เปิดค้างไว้: docker compose up -d (Postgres + MinIO) · bin/dev หนึ่งหน้าต่าง (เว็บที่ localhost:3000 + ตัว build CSS) · อีกหน้าต่างไว้พิมพ์คำสั่ง/เปิด console · ผลรันทุกกล่องมาจากเครื่องจริง · เวลา ms · วันเวลา · id · key ของไฟล์ (ตัวอักษรสุ่มยาว ๆ) · ลายเซ็นใน URL · ขนาดไฟล์รูปของคุณ จะไม่ตรงกับในบท

เตรียมของ: บทความที่มีรูปปก 2–3 ชิ้น

ทั้งบทเราดูหน้ารวม /posts · ถ้ายังไม่มีบทความไหนมีรูปปก ให้เปิดหน้าแก้ไขบทความที่อยู่บนสุด 2–3 ชิ้น แล้วเลือกรูป (png · jpg · webp อะไรก็ได้ในเครื่อง) กดบันทึก แบบที่ทำในบท 138 · ของผมแนบไว้ 3 ชิ้น: บทความ 30 (cover1.jpg ขนาด 1600×1000 หนัก 618,620 byte) · 29 · 28

ตอนนี้หน้ารวมยังไม่โชว์รูปเลย — บท 138 ใส่รูปปกไว้แค่ในหน้าบทความเดี่ยว (/posts/30) เป็นรูปเต็มขนาด · บทนี้จะเอารูปมาแปะบนการ์ดในหน้ารวม

รูปต้นฉบับใหญ่เกินไปสำหรับการ์ด — variant

รูปที่ผู้ใช้อัปมักใหญ่มาก (รูปจากมือถือกว้างหลายพันพิกเซล หนักหลาย MB) · ถ้าการ์ดในหน้ารวมใช้ไฟล์ต้นฉบับ ผู้ใช้ต้องโหลดรูปใหญ่ 10 รูปเพื่อดูภาพเล็ก ๆ 10 ภาพ · ทางแก้คือทำรูปย่อ — ไฟล์ใหม่ที่เล็กลง ทำจากรูปเดิม แล้วให้การ์ดใช้ไฟล์นั้น

Active Storage ทำรูปย่อให้เองด้วยคำสั่ง variant (แปลว่า "แบบหนึ่งของของชิ้นเดิม") · เราไม่ต้องเก็บไฟล์ย่อเอง ไม่ต้องเพิ่มคอลัมน์ · สั่งแค่ใน view ว่า "ขอแบบย่อไม่เกิน 400×300" — ครั้งแรกที่มีคนขอ Rails ย่อแล้วเก็บไว้ใน MinIO · ครั้งต่อไปหยิบอันเดิม

🔪 เทียบให้เห็นภาพ — หั่นชิ้นพอดีคำจากวัตถุดิบเดิม

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

ที่ไม่เหมือนกัน: ร้านจริงหั่นเตรียมไว้ตั้งแต่ก่อนเปิดร้าน · Rails ไม่หั่นล่วงหน้า รอให้มีคนขอครั้งแรกก่อน — ลูกค้าคนแรกเลยรอนานกว่าคนอื่น (ส่วน Rails รู้ได้ยังไงว่าชิ้นไหนหั่นแล้ว เดี๋ยวเห็นใน log ในหัวข้อ "ครั้งแรกหั่น ครั้งต่อไปหยิบ")

เทียบของที่รู้: ถ้าเคยใช้ <Image> ของ Next.js — มันทำแบบเดียวกัน คือย่อรูปตอนมีคนขอครั้งแรกแล้วเก็บไว้ · และอีกเดี๋ยวจะเห็นว่า "สูตรการย่อ" ถูกฝังอยู่ใน URL ของรูป แบบเดียวกับเลขรุ่นบนชื่อไฟล์ CSS (fingerprinting บท 127)

เพิ่มรูปย่อในการ์ด

เปิด app/views/posts/_post.html.erb · ไฟล์นี้มีสองกิ่ง: if full = หน้าบทความเดี่ยว · else = การ์ดในหน้ารวม · เพิ่ม 3 บรรทัด (comment · image_tag · บรรทัดว่าง) ใต้ <% else %> พอดี (บรรทัดที่ขึ้นต้น + คือของใหม่ ไม่ต้องพิมพ์ตัว +):

<%# app/views/posts/_post.html.erb (ช่วงกิ่ง else) %>
    <% else %>
+      <%# รูปย่อไม่เกิน 400x300: สร้างครั้งแรกที่มีคนขอ แล้วเก็บไว้ใน MinIO ใช้ซ้ำ %>
+      <%= image_tag post.cover.variant(resize_to_limit: [ 400, 300 ]), alt: "", class: "rounded-box self-start" if post.cover.attached? %>
+
      <h2 class="card-title">
        <%= link_to post.title, post, class: "link link-hover" %>
      </h2>

เซฟแล้วรีเฟรช http://localhost:3000/posts → การ์ด 3 ใบบนสุดมีรูปขนาด 400×250 อยู่เหนือหัวข้อ · ดูว่าแท็กรูปหน้าตาเป็นยังไง (ผลจริง ตัดมาใบแรกใบเดียว):

$ curl -s localhost:3000/posts | grep '<img'
      <img alt="" class="rounded-box self-start" src="http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MSwicHVyIjoiYmxvYl9pZCJ9fQ==--43aa658d7525d1cd4db04eae95f0f904e19f481e/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGciLCJyZXNpemVfdG9fbGltaXQiOls0MDAsMzAwXX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--7a3118b7de5256f591925063f8e4b05374960466/cover1.jpg" />
…

URL ยาวน่ากลัว แต่แบ่งได้เป็นท่อน ๆ คั่นด้วย / · ท่อนตัวอักษรยาว ๆ เป็นข้อความที่ถูกแปลงรหัส (base64 = วิธีเขียนข้อมูลด้วยตัวอักษรที่ใส่ใน URL ได้) — แกะออกมาดูได้ (ผลจริง):

$ echo 'eyJfcmFpbHMiOnsiZGF0YSI6MSwicHVyIjoiYmxvYl9pZCJ9fQ==' | base64 -d
{"_rails":{"data":1,"pur":"blob_id"}}
$ echo 'eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGciLCJyZXNpemVfdG9fbGltaXQiOls0MDAsMzAwXX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==' | base64 -d
{"_rails":{"data":{"format":"jpg","resize_to_limit":[400,300]},"pur":"variation"}}

สูตรอยู่ใน URL → เปลี่ยนสูตร = URL ใหม่ = รูปย่อชิ้นใหม่ · ไม่มีใครต้องตามไปลบรูปย่อแบบเก่า — แนวคิดเดียวกับ fingerprinting และจะกลับมาอีกครั้งในเรื่อง cache (บล็อก F)

🗣 ศัพท์ที่เพิ่งเรียนไป
variant · resize_to_limit
variant = ไฟล์แบบแปลงแล้ว (ย่อ · ครอป · เปลี่ยนชนิด) ที่ Active Storage ทำจากไฟล์ต้นฉบับ · resize_to_limit = ย่อให้อยู่ในกรอบ รักษาสัดส่วน ไม่ขยายรูปเล็ก · พิมพ์บอก AI ได้ว่า "show a thumbnail with cover.variant(resize_to_limit: [400, 300]) instead of the original file"
lazy processing (on-demand)
ทำงานตอนมีคนขอครั้งแรก ไม่ทำล่วงหน้า · ข้อดี: ไม่ทำรูปย่อที่ไม่มีใครดู · ข้อเสีย: คนแรกรอ · ถาม senior ได้ว่า "รูปย่อของเราสร้างตอนอัป หรือตอนมีคนเปิดครั้งแรก — ถ้าคนแรกรอนานควรย้ายไป background job ไหม"

คนหั่นรูปคือ libvips — ต้องติดตั้งในเครื่อง

Rails ไม่ได้ย่อรูปเอง · มันส่งงานต่อให้ gem image_processing ซึ่งเรียกโปรแกรมย่อรูปที่ชื่อ libvips อีกที · gem มีในโปรเจกต์แล้วตั้งแต่ rails new (ผลจริง):

$ grep -n -B1 image_processing Gemfile
42-# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
43:gem "image_processing", "~> 1.2"
$ vips --version
vips-8.18.7

libvips ไม่ใช่ gem แต่เป็นโปรแกรมของระบบปฏิบัติการ ต้องติดตั้งแยก: macOS brew install vips · Ubuntu/Debian sudo apt install libvips42 (ชื่อตามคู่มือของ ruby-vips) · ส่วนบนเซิร์ฟเวอร์จริง Dockerfile (ไฟล์สูตรสร้างกล่องของแอปตอนเอาขึ้นเซิร์ฟเวอร์ — rails new สร้างให้) มีบรรทัด apt-get install … libvips … อยู่แล้ว

⚠️ ถ้ารูปย่อในหน้ารวมไม่ขึ้นและ log มีคำว่า vips ให้เช็ค vips --version ก่อนอย่างอื่น · (ผมลองอาการ "ไม่มี libvips" จริงไม่ได้ เพราะเครื่องที่ใช้ตรวจมีอยู่แล้ว จึงไม่มีข้อความ error ให้ดู)

รูปปกพา N+1 กลับมา — with_attached_cover

รีเฟรชหน้ารวมแล้วเปิด log/development.log (บท 113) ดูใบสั่ง GET "/posts" · ผลจริง (ตัด comment /*…*/ ท้าย SQL · ตัดบรรทัด Rendered … และ ↳ ส่วนใหญ่ · ย่อแถวที่ซ้ำและท่อนกลาง SQL เป็น … · กล่อง bullet ตัดเหลือ 3 บรรทัด):

Started GET "/posts" for ::1 at 2026-11-10 10:15:07 +0700
Processing by PostsController#index as */*
  Post Count (0.7ms)  SELECT COUNT(*) FROM "posts"
  Post Load (0.8ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT 10 OFFSET 0
  Category Load (0.7ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (5, 4, 3, 2, 1)
  ActionText::RichText Load (0.9ms)  SELECT "action_text_rich_texts".* FROM "action_text_rich_texts" WHERE … "record_id" IN (30, 29, 28, 27, 26, 25, 24, 23, 22, 21)
  ActiveStorage::Attachment Load (0.8ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = 30 AND "active_storage_attachments"."record_type" = 'Post' AND "active_storage_attachments"."name" = 'cover' LIMIT 1
  ↳ app/views/posts/_post.html.erb:25
  ActiveStorage::Blob Load (0.8ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 1 LIMIT 1
  ↳ app/views/posts/_post.html.erb:25
  ActiveStorage::Attachment Load (0.7ms)  … "record_id" = 29 …
  ActiveStorage::Blob Load (0.7ms)  … "id" = 2 …
  ActiveStorage::Attachment Load (0.7ms)  … "record_id" = 28 …
  ActiveStorage::Blob Load (1.3ms)  … "id" = 3 …
  ActiveStorage::Attachment Load (1.7ms)  … "record_id" = 27 …
  …  (record_id = 26, 25, 24, 23, 22, 21 อีกตัวละบรรทัด)
Completed 200 OK in 154ms (Views: 112.2ms | ActiveRecord: 16.3ms (17 queries, 0 cached) | GC: 28.9ms)

USE eager loading detected
  Post => [:cover_attachment]
  Add to your query: .includes([:cover_attachment])

bullet แนะนำ .includes([:cover_attachment]) · แต่นั่นหยิบมาแค่ป้าย ยังต้องไปหยิบใบรับฝากอีกทีละใบ · Active Storage มีคำสั่งสำเร็จรูปที่หยิบให้ครบทั้งสองอย่าง ชื่อ with_attached_ + ชื่อไฟล์แนบ — แบบเดียวกับ with_rich_text_body ของบท 137

เปิด app/controllers/posts_controller.rb แก้ 2 บรรทัดใน def index (comment + บรรทัด @pagy) — - คือของเดิม + คือของใหม่:

  def index
-    # with_rich_text_body = ดึง body ของทั้งหน้ามาในคำสั่งเดียว (ไม่งั้น N+1 ทีละบทความ)
-    @pagy, @posts = pagy(Post.includes(:category).with_rich_text_body.recent, limit: 10)
+    # with_rich_text_body / with_attached_cover = ดึง body และรูปปกของทั้งหน้ามาทีเดียว (ไม่งั้น N+1 ทีละบทความ)
+    @pagy, @posts = pagy(Post.includes(:category).with_rich_text_body.with_attached_cover.recent, limit: 10)
  end

ของใหม่มีคำเดียว: .with_attached_cover ต่อท้าย with_rich_text_body = "หยิบป้ายรูปปกและใบรับฝากของทั้ง 10 บทความมาพร้อมกัน" · รีเฟรชแล้วดู log อีกรอบ (ผลจริง · ผมเปิดรูปย่อในเบราว์เซอร์ไปแล้วรอบหนึ่ง · ตัดมาเฉพาะส่วนรูปปก ต่อจาก 4 บรรทัดเดิม):

  ActiveStorage::Attachment Load (0.7ms)  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 (30, 29, 28, 27, 26, 25, 24, 23, 22, 21)
  ActiveStorage::Blob Load (0.7ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" IN (3, 2, 1)
  ActiveStorage::VariantRecord Load (0.7ms)  SELECT "active_storage_variant_records".* FROM "active_storage_variant_records" WHERE "active_storage_variant_records"."blob_id" IN (1, 2, 3)
  ActiveStorage::Attachment Load (0.7ms)  SELECT … WHERE "active_storage_attachments"."record_type" = 'ActiveStorage::Blob' AND "active_storage_attachments"."name" = 'preview_image' AND … IN (1, 2, 3)
  ActiveStorage::Attachment Load (0.7ms)  SELECT … WHERE "active_storage_attachments"."record_type" = 'ActiveStorage::VariantRecord' AND "active_storage_attachments"."name" = 'image' AND … IN (1, 2, 3)
  ActiveStorage::Blob Load (0.8ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" IN (4, 5, 6)
Completed 200 OK in 44ms (Views: 27.0ms | ActiveRecord: 9.3ms (10 queries, 0 cached) | GC: 4.6ms)

AVOID eager loading detected
  ActiveStorage::Blob => [:variant_records, :preview_image_attachment]
  Remove from your query: .includes([:variant_records, :preview_image_attachment])
AVOID eager loading detected
  ActiveStorage::VariantRecord => [:image_attachment]
  Remove from your query: .includes([:image_attachment])

ของเกินมาจากไหน — track_variants

ค่าเริ่มต้นของ Rails จดทุกรูปย่อที่ทำแล้วลงตาราง active_storage_variant_records (เห็นตอนรูปย่อถูกสร้างครั้งแรก มีบรรทัด INSERT INTO "active_storage_variant_records" ใน log) · และเพราะมีตารางนี้ with_attached_cover จึงหยิบข้อมูลรูปย่อมาด้วยเผื่อไว้ · หน้ารวมของเราไม่ได้ใช้ (มันแค่สร้าง URL ซึ่งไม่ต้องรู้ว่าย่อแล้วหรือยัง) จึงเป็นของเกิน

ปิดการจดได้ในบรรทัดเดียว · เปิด config/application.rb เพิ่ม 5 บรรทัด (บรรทัดว่าง · comment 3 · ค่าตั้ง 1) ใต้ config.time_zone:

    config.time_zone = "Asia/Bangkok"
+
+    # ไม่จดรูปย่อ (variant) ลงตาราง active_storage_variant_records
+    # ถ้าจด (ค่าเริ่มต้น) with_attached_cover จะดึงตารางนั้นมาด้วยอีก 2-4 query ทั้งที่หน้า index ไม่ได้ใช้
+    # แลกกับ: ตอนเปิดรูปย่อ Rails ถาม MinIO ว่ามีไฟล์รูปย่อหรือยัง แทนการถาม DB
+    config.active_storage.track_variants = false
    # config.eager_load_paths << Rails.root.join("extras")

⚠️ ไฟล์ใน config/ ถูกอ่านครั้งเดียวตอนเปิดเซิร์ฟเวอร์ · ต้องหยุด bin/dev (Ctrl+C) แล้วเปิดใหม่ ไม่งั้นตัวเลขไม่เปลี่ยน

รีสตาร์ทแล้วรีเฟรชหน้ารวม (ผลจริง · ตัดเหลือส่วนที่ไปตู้):

  Post Count (1.1ms)  SELECT COUNT(*) FROM "posts"
  Post Load (1.8ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT 10 OFFSET 0
  Category Load (1.3ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (5, 4, 3, 2, 1)
  ActionText::RichText Load (1.3ms)  SELECT "action_text_rich_texts".* FROM … "record_id" IN (30, 29, 28, 27, 26, 25, 24, 23, 22, 21)
  ActiveStorage::Attachment Load (0.8ms)  SELECT "active_storage_attachments".* FROM … "record_id" IN (30, 29, 28, 27, 26, 25, 24, 23, 22, 21)
  ActiveStorage::Blob Load (1.0ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" IN (3, 2, 1)
Completed 200 OK in 39ms (Views: 30.0ms | ActiveRecord: 4.0ms (6 queries, 0 cached) | GC: 20.3ms)

17 → 10 → 6 query และไม่มีกล่อง bullet เลย · 6 คือ "ของเดิม 4 + ป้ายรูปปก 1 + ใบรับฝาก 1" — เพิ่มบทความหรือรูปอีกเท่าไรก็ยัง 6

🧺 เทียบให้เห็นภาพ — ใบเบิกที่ต้องเขียนให้พอดี

ไม่มี with_attached_cover = คนจัดจานเดินไปตู้ถามทีละแฟ้มว่า "แฟ้มนี้มีบัตรรับฝากรูปไหม" (บท 114 อีกแล้ว) · ใส่แล้ว = หัวหน้าเชฟเขียนใบเบิกทีเดียว "เอาบัตรรับฝากของทั้ง 10 แฟ้มมาด้วย" · ค่าเริ่มต้นของ Rails ใบเบิกนั้นแถมสมุดจดว่าชิ้นไหนหั่นแล้วมาด้วยทุกครั้ง ทั้งที่จานในหน้ารวมไม่ได้เปิดดู — ปิด track_variants = เลิกใช้สมุดเล่มนั้น

ที่ไม่เหมือนกัน: ในร้านจริง เลิกจดสมุดแล้วก็แค่ไม่มีบันทึก · ใน Rails ของที่แลกมาคือทุกครั้งที่มีคนขอรูปย่อ Rails ต้องไปถามโกดัง (MinIO) ว่ามีชิ้นนั้นหรือยัง แทนการเปิดสมุด · ถามโกดังที่อยู่ข้างร้าน (เครื่องเดียวกัน) เร็วมาก แต่ถ้าโกดังอยู่ไกล (S3 ต่างประเทศ) อาจกลับมาคุ้มที่จะจดสมุด

🗣 ศัพท์ที่เพิ่งเรียนไป
with_attached_cover (with_attached_*)
scope ที่ has_one_attached :cover สร้างให้ = includes ป้าย + ใบรับฝากของไฟล์แนบทีเดียว · ชื่อเปลี่ยนตามชื่อไฟล์แนบ (with_attached_avatar) · พิมพ์บอก AI ได้ว่า "the index shows cover thumbnails; add with_attached_cover to the query and check the log stays at a constant number of queries"
track_variants · AVOID eager loading
track_variants = จดรูปย่อลงตาราง active_storage_variant_records (ค่าเริ่มต้น true) · AVOID eager loading = bullet บอกว่าหยิบมาล่วงหน้าแต่ไม่ได้ใช้ · ถาม senior ได้ว่า "เราควรเปิด track_variants ไหม storage ของเราอยู่ไกลแค่ไหน"

ครั้งแรกหั่น ครั้งต่อไปหยิบ — ดูใน log

หลังรีสตาร์ท รูปย่อถูกสร้างใหม่อีกรอบหนึ่ง — ปิดสมุดแล้ว Rails ตั้งชื่อชิ้นย่อในโกดังแบบใหม่ เลยหาชิ้นเดิมไม่เจอ (เกิดครั้งเดียว) · เราเลยได้เห็นจังหวะ "ครั้งแรก" พอดี · ใน log หาใบสั่งที่ขึ้นต้น Started GET "/rails/active_storage/representations/redirect/…/cover1.jpg" (ผลจริง · URL ยาวผมย่อเป็น …):

Processing by ActiveStorage::Representations::RedirectController#show as JPEG
  ActiveStorage::Blob Load (0.9ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 1 LIMIT 1
  S3 Storage (11.5ms) Checked if file exists at key: variants/6ims6h1zecgfmejk558tkfdd3vpy/6b68a4a4ccbcccc1886efb4cef49776906bf890a781e7937db17ff54b07368ca (no)
  S3 Storage (6.9ms) Downloaded file from key: 6ims6h1zecgfmejk558tkfdd3vpy
  S3 Storage (3.8ms) Uploaded file to key: variants/6ims6h1zecgfmejk558tkfdd3vpy/6b68a4a4ccbcccc1886efb4cef49776906bf890a781e7937db17ff54b07368ca
  S3 Storage (1.0ms) Generated URL for file at key: variants/6ims6h1zecgfmejk558tkfdd3vpy/6b68… (http://localhost:9000/wongnok-dev/variants/6ims…/6b68…?…&X-Amz-Expires=300&…)
Redirected to http://localhost:9000/wongnok-dev/variants/6ims6h1zecgfmejk558tkfdd3vpy/6b68…?…
Completed 302 Found in 46ms (ActiveRecord: 0.6ms (1 query, 0 cached) | GC: 9.4ms)

รีเฟรชหน้ารวมอีกรอบ · เบราว์เซอร์ขอรูปย่อใบเดิมอีกครั้ง · ใน log ของใบสั่งรูปนั้นจะยังเห็นบรรทัด Downloaded กับ Uploaded ไหม? และน่าจะเร็วหรือช้ากว่าครั้งแรก?

ผลจริง (ใบสั่งรูปเดิม ครั้งที่สอง):

Processing by ActiveStorage::Representations::RedirectController#show as JPEG
  ActiveStorage::Blob Load (1.8ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 1 LIMIT 1
  S3 Storage (2.7ms) Checked if file exists at key: variants/6ims6h1zecgfmejk558tkfdd3vpy/6b68a4a4ccbcccc1886efb4cef49776906bf890a781e7937db17ff54b07368ca (yes)
  S3 Storage (0.8ms) Generated URL for file at key: variants/6ims6h1zecgfmejk558tkfdd3vpy/6b68… (http://localhost:9000/wongnok-dev/variants/6ims…/6b68…?…)
Redirected to http://localhost:9000/wongnok-dev/variants/6ims6h1zecgfmejk558tkfdd3vpy/6b68…?…
Completed 302 Found in 8ms (ActiveRecord: 1.3ms (1 query, 0 cached) | GC: 0.0ms)

ไม่มี Downloaded/Uploaded แล้ว · (yes) = มีชิ้นหั่นแล้ว → ออกบัตรผ่านแล้วส่งไปเลย · ของผม 46ms → 8ms (ของคุณจะต่าง แต่ครั้งที่สองควรเร็วกว่าชัดเจน) · ยังเห็น Checked if file exists ทุกครั้ง — นี่คือ "ของที่แลกมา" ของ track_variants = false

ชิ้นย่อที่เก็บไว้เล็กกว่าต้นฉบับมาก: ของผมต้นฉบับ cover1.jpg 1600×1000 หนัก 618,620 byte → ชิ้นย่อ 400×250 หนัก 17,583 byte (ดึงไฟล์ออกมาวัดจริง) · และต้นฉบับยังอยู่ครบ หน้าบทความเดี่ยวยังใช้รูปเต็ม

สไลด์บอกว่า "สร้างครั้งแรกจะช้า → ถ้าไม่อยากให้ผู้ใช้คนแรกรอ ให้สร้างล่วงหน้าใน background job" — background job (งานที่ทำเบื้องหลังโดยไม่ให้ผู้ใช้ยืนรอ) คือเรื่องของบล็อก E บท 142 · ตอนนี้ปล่อยให้คนแรกรอไปก่อน

content_type เชื่อไม่ได้ — ลองด้วยไฟล์ปลอมของเราเอง

เรื่องที่สอง: ตอนนี้ช่องรูปปกรับไฟล์อะไรก็ได้ · สไลด์หน้า 12 ให้โค้ดตรวจมา: ชนิดต้องเป็น PNG/JPEG/WebP และขนาดไม่เกิน 5 MB · ก่อนเชื่อโค้ดในสไลด์ เราลองโจมตีแอปของตัวเองดูก่อน

⚠️ การทดลองทั้งหมดในบทนี้ทำกับแอปในเครื่องของคุณเท่านั้น · ไฟล์ปลอมของเราเป็นแค่ข้อความธรรมดา ไม่มีอันตราย · ห้ามลองอัปไฟล์ปลอมกับเว็บของคนอื่น แม้จะเป็นไฟล์ไม่มีพิษก็ตาม

สร้าง "รูปปลอม" — ไฟล์ข้อความธรรมดา แต่ตั้งชื่อลงท้าย .png · เก็บไว้ในโฟลเดอร์ tmp/ ของโปรเจกต์ (git ไม่สนโฟลเดอร์นี้):

$ printf 'hello this is just text, not an image\n' > tmp/fake.png

printf '…' > ไฟล์ = เขียนข้อความลงไฟล์ · \n = ขึ้นบรรทัดใหม่ · ได้ไฟล์ 38 byte ที่ชื่อบอกว่าเป็นรูป แต่ข้างในเป็นตัวหนังสือ

เปิด app/models/post.rb แล้วพิมพ์โค้ดตามสไลด์หน้า 12 ก่อน (เป็นการทดลอง — อีกเดี๋ยวเราเปลี่ยนทิ้ง) · เพิ่ม 1 บรรทัดใต้ validates :body และ method ใหม่ใต้ private:

  validates :body, presence: true
+  validate :cover_is_valid
  …
  private
    def copy_body_to_plain_text
      …
    end
+
+    def cover_is_valid
+      return unless cover.attached?
+
+      unless cover.content_type.in?(%w[ image/png image/jpeg image/webp ])
+        errors.add(:cover, "รองรับเฉพาะ PNG, JPEG, WebP")
+      end
+
+      if cover.byte_size > 5.megabytes
+        errors.add(:cover, "ไฟล์ต้องไม่เกิน 5 MB")
+      end
+    end

เปิดหน้าแก้ไขบทความที่ยังไม่มีรูปปก (ของผมคือ /posts/27/edit) · ช่องรูปปกเลือก tmp/fake.png · กดบันทึก · โค้ดของสไลด์ตรวจ content_type อยู่ — คิดว่าจะบันทึกได้ไหม?

บันทึกได้ — หน้าเด้งไปหน้าบทความ ขึ้นแถบเขียว "บันทึกการแก้ไขแล้ว" (ยืนยันใน Chrome) · ใน log (ผลจริง ตัดมาเฉพาะบรรทัดที่เกี่ยว):

Started POST "/rails/active_storage/direct_uploads" for ::1 at 2026-11-10 10:19:39 +0700
  Parameters: {"blob" => {"filename" => "fake.png", "content_type" => "image/png", "byte_size" => 38, "checksum" => "i6qWQX4nSMSe4tJLtV293Q=="}, …}
  ActiveStorage::Blob Create (2.7ms)  INSERT INTO "active_storage_blobs" ("key", "filename", "content_type", "metadata", "service_name", "byte_size", "checksum", "created_at") VALUES ('iqj0qwd65ukk2uns8664ezy8m2lf', 'fake.png', 'image/png', NULL, 'minio', 38, 'i6qWQX4nSMSe4tJLtV293Q==', '2026-11-10 03:19:39.342756') RETURNING "id"
…
Started PATCH "/posts/27" for ::1 at 2026-11-10 10:19:39 +0700
  ActiveStorage::Attachment Create (1.3ms)  INSERT INTO "active_storage_attachments" ("name", "record_type", "record_id", "blob_id", "created_at") VALUES ('cover', 'Post', 27, 7, '2026-11-10 03:19:39.393646') RETURNING "id"
Completed 303 See Other in 48ms (ActiveRecord: 8.0ms (11 queries, 0 cached) | GC: 7.7ms)
  • "content_type" => "image/png" ในบรรทัด Parameters — เบราว์เซอร์เป็นคนบอก และมันเดาจากนามสกุล .png ล้วน ๆ ไม่ได้เปิดดูข้างใน · (checksum ข้าง ๆ คือค่าไว้เช็คว่าไฟล์ไม่เพี้ยนระหว่างส่ง ไม่ได้บอกชนิดไฟล์)
  • INSERT INTO "active_storage_blobs" … 'image/png' — Rails จดลงใบรับฝากตามที่เบราว์เซอร์บอก
  • Attachment Create … 'cover', 'Post', 27 + 303 See Other — ผ่าน validation · ติดป้ายเข้าบทความ 27 แล้ว

แล้วผลเสียคืออะไร? เปิดหน้ารวม — การ์ดบทความ 27 ไม่มีรูป (ช่องว่าง) · ใบสั่งของรูปย่อใบนั้นใน log (ผลจริง ตัดเหลือ 3 บรรทัดแรกของ error):

Completed 500 Internal Server Error in 8ms (ActiveRecord: 0.6ms (1 query, 0 cached) | GC: 0.2ms)
Vips::Error (VipsForeignLoad: "/var/folders/jc/…/T/ActiveStorage-7-20261110-94944-npebqo.png" is not a known file format
):

/var/folders/…/ActiveStorage-7-….png คือสำเนาชั่วคราวที่ Rails โหลดมาจาก MinIO ให้ libvips หั่น · libvips พยายามหั่น "ปลา" ที่เป็นกระดาษ → is not a known file format (ไม่รู้จักไฟล์ชนิดนี้) → 500 (error ฝั่งเซิร์ฟเวอร์) · ไฟล์ข้อความของเราไม่มีพิษ แต่แปลว่าไฟล์อะไรก็ตามที่ไม่มีลายเซ็นให้ Rails จับได้ (อย่างข้อความล้วน) ผ่านด่านของสไลด์ได้หมด — และเว็บเราเก็บไว้ให้แล้วด้วยป้าย image/png

ลบรูปปลอมทิ้งด้วย purge

เปิด bin/rails console แล้วลบรูปปกปลอมของบทความ 27 (ใช้เลขบทความของคุณ) · ผลจริง (ตัด comment ท้าย SQL):

wongnok-blog-all-in-one(dev):001> post = Post.find(27); nil
  Post Load (0.5ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = 27 LIMIT 1
=> nil
wongnok-blog-all-in-one(dev):002> post.cover.filename
  ActiveStorage::Attachment Load (1.7ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE … "record_id" = 27 … "name" = 'cover' LIMIT 1
  ActiveStorage::Blob Load (0.5ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 7 LIMIT 1
=> #<ActiveStorage::Filename:0x000000012dbb0750 @filename="fake.png">
wongnok-blog-all-in-one(dev):003> post.cover.purge
  TRANSACTION (1.7ms)  BEGIN
  ActiveStorage::Attachment Destroy (3.4ms)  DELETE FROM "active_storage_attachments" WHERE "active_storage_attachments"."id" = 7
  Post Update (1.2ms)  UPDATE "posts" SET "updated_at" = '2026-11-10 03:21:22.332731' WHERE "posts"."id" = 27
  TRANSACTION (1.3ms)  COMMIT
  TRANSACTION (0.7ms)  BEGIN
  ActiveStorage::Attachment Exists? (1.7ms)  SELECT 1 AS one FROM "active_storage_attachments" WHERE "active_storage_attachments"."blob_id" = 7 LIMIT 1
  ActiveStorage::Attachment Load (0.8ms)  SELECT … "name" = 'preview_image' LIMIT 1
  ActiveStorage::Blob Destroy (1.7ms)  DELETE FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 7
  TRANSACTION (0.8ms)  COMMIT
  S3 Storage (16.1ms) Deleted file from key: iqj0qwd65ukk2uns8664ezy8m2lf
  S3 Storage (3.2ms) Deleted files by key prefix: variants/iqj0qwd65ukk2uns8664ezy8m2lf/
=> nil

สไลด์หน้า 13 มีโจทย์ "ลูกค้าโทรมาขอให้ลบรูปที่อัปผิด เราลบ attachment ใน DB แล้ว ต้องเช็คอะไรอีก" — คำตอบคือ 4 บรรทัดข้างบน: ลบแค่ป้ายไม่พอ ไฟล์ยังอยู่ในโกดัง และรูปย่อก็ยังอยู่ · purge ทำครบทั้ง 4 ให้

ตรวจจากเนื้อไฟล์ — magic bytes

ไฟล์รูปทุกชนิดมีลายเซ็นประจำตัวอยู่ไม่กี่ byte แรกของไฟล์ ที่โปรแกรมสร้างรูปเขียนไว้เสมอ · เรียกกันว่า magic bytes · ดูด้วยตาได้ (ใช้รูปจริงในเครื่องคุณสักรูป ก็อปมาเป็น tmp/real.png) · ผลจริง:

$ head -c 8 tmp/real.png | xxd
00000000: 8950 4e47 0d0a 1a0a                      .PNG....
$ head -c 8 tmp/fake.png | xxd
00000000: 6865 6c6c 6f20 7468                      hello th
$ file tmp/fake.png tmp/real.png
tmp/fake.png: ASCII text
tmp/real.png: PNG image data, 1600 x 1000, 8-bit grayscale, non-interlaced
📦 เทียบให้เห็นภาพ — ฉลากที่ลูกค้าเขียนเอง vs เปิดดูของข้างใน

ลูกค้าเอากล่องมาฝาก เขียนฉลากเองว่า "ปลา" (นามสกุล .png · content_type ที่เบราว์เซอร์ส่งมา) · โค้ดของสไลด์คือพนักงานที่อ่านฉลากแล้วเชื่อ · วิธีที่ถูกคือแง้มฝากล่องดู — ปลาทุกตัวมีหน้าตาของปลา (magic bytes) ปลอมฉลากได้ แต่ปลอมของข้างในยากกว่ามาก

ที่ไม่เหมือนกัน: เราแง้มดูแค่ปากกล่อง (ไม่กี่ KB แรก) ไม่ได้รื้อทั้งกล่อง · คนร้ายที่ตั้งใจจริงทำไฟล์ที่หัวเป็น PNG ถูกต้องแต่ท้ายไฟล์ซ่อนของอื่นได้ · การตรวจนี้กัน "ของผิดชนิด" ได้ ไม่ได้รับประกันว่าไฟล์ปลอดภัย 100% · OWASP (กลุ่มที่ทำคู่มือความปลอดภัยเว็บที่คนทั้งวงการใช้ — เจออีกใน Part 2) จึงแนะนำให้ทำหลายชั้น เช่นจำกัดขนาด เก็บไฟล์ไว้คนละเครื่องกับแอป และสแกนไวรัสถ้าทำได้

เทียบของที่รู้: บท 37 ของ Go เจอ http.DetectContentType มาแล้ว — ฟังก์ชันที่เดาชนิดจากเนื้อข้อมูลไม่กี่ร้อย byte แรก หลักเดียวกันเป๊ะ · ฝั่ง Ruby มี gem ชื่อ Marcel ทำงานนี้ และมากับ Rails อยู่แล้ว (Active Storage ใช้มันเอง) · ลองใน console (ผลจริง):

wongnok-blog-all-in-one(dev):001> Marcel::MimeType.for(File.open("tmp/fake.png"))
=> "application/octet-stream"
wongnok-blog-all-in-one(dev):002> Marcel::MimeType.for(File.open("tmp/fake.png"), name: "fake.png")
=> "image/png"
wongnok-blog-all-in-one(dev):003> Marcel::MimeType.for(File.open("tmp/real.png"))
=> "image/png"
wongnok-blog-all-in-one(dev):004> Marcel::MimeType.for(File.open("tmp/real.png"), name: "real.txt")
=> "image/png"

กฎที่ได้: เรียก Marcel ด้วยเนื้อไฟล์อย่างเดียว ห้ามบอกชื่อ ห้ามบอกชนิดที่เบราว์เซอร์ส่งมา

🗣 ศัพท์ที่เพิ่งเรียนไป
content_type · MIME type
ป้ายชนิดไฟล์แบบ image/png · ที่มาจากเบราว์เซอร์คือสิ่งที่ client อ้าง (เดาจากนามสกุล หรือพิมพ์เองด้วย curl ก็ได้) ไม่ใช่ความจริง · พิมพ์บอก AI ได้ว่า "never trust the client-provided content_type or file extension; detect the type from the file content"
magic bytes (file signature) · Marcel
ลายเซ็นไม่กี่ byte แรกของไฟล์ที่บอกชนิดจริง · Marcel = gem ที่ Rails ใช้อ่านลายเซ็นนั้น · application/octet-stream = ไม่รู้จัก · ถาม senior ได้ว่า "เราตรวจชนิดไฟล์จาก magic bytes หรือจาก content_type — แล้วใช้ gem อย่าง active_storage_validations ไหม"

โค้ดตรวจไฟล์ตัวจริง — ทีละบรรทัด

ลบโค้ดของสไลด์ออกทั้งหมด (บรรทัด validate :cover_is_valid และ method cover_is_valid) ให้ post.rb กลับเป็นแบบตอนต้นบท · แล้วเพิ่มของใหม่ 3 จุด · จุดที่ 1 บนสุดของคลาส (บรรทัดค่าคงที่ + บรรทัดว่าง):

class Post < ApplicationRecord
+  COVER_TYPES = %w[ image/png image/jpeg image/webp ]
+
  belongs_to :category

COVER_TYPES — ชื่อขึ้นต้นตัวใหญ่ = ค่าคงที่ (ตั้งครั้งเดียวไม่เปลี่ยน · บท 91) · รายการชนิดที่ยอมรับ เขียนไว้ที่เดียว ใช้ทั้งใน model และในฟอร์ม

จุดที่ 2 ใต้ validates :body, presence: true (comment 1 + validate 1):

  validates :body, presence: true
+  # ตรวจเฉพาะตอนแนบรูปใหม่ (attachment ยังไม่ได้บันทึก) — แก้แค่หัวข้อไม่ต้องตรวจรูปเดิมซ้ำ
+  validate :cover_must_be_small_image, if: -> { cover.attached? && cover.attachment.new_record? }

จุดที่ 3 ใต้ private ต่อจาก method copy_body_to_plain_text (บรรทัดว่างคั่น method เหมือนเดิม):

    def copy_body_to_plain_text
      self.plain_text = body.to_plain_text
      self.description = plain_text
    end
+
+    def cover_must_be_small_image
+      # ชนิดไฟล์: ดูจากเนื้อไฟล์จริง (magic bytes ต้นไฟล์) ไม่ใช่ content_type หรือนามสกุลที่เบราว์เซอร์บอกมา
+      real_type = Marcel::MimeType.for(StringIO.new(cover_head))
+      errors.add(:cover, "รองรับเฉพาะ PNG, JPEG, WebP") unless real_type.in?(COVER_TYPES)
+      # byte_size ของ direct upload เชื่อได้: MinIO รับไฟล์เฉพาะเมื่อขนาดตรงกับที่ Rails เซ็นไว้
+      errors.add(:cover, "ไฟล์ต้องไม่เกิน 5 MB") if cover.byte_size > 5.megabytes
+    end
+
+    # 4 KB แรกของไฟล์ (พอให้เห็น magic bytes)
+    def cover_head
+      if cover.byte_size.zero?
+        # ไฟล์ว่าง: ไม่มีอะไรให้อ่าน (ขอช่วงไบต์จากไฟล์ว่างใน MinIO จะ error)
+        ""
+      elsif cover.blob.persisted?
+        # direct upload: ไฟล์อยู่ใน MinIO แล้ว → ดึงมาแค่ช่วงต้นไฟล์
+        cover.blob.download_chunk(0...4.kilobytes)
+      else
+        # แนบไฟล์ตรง ๆ (เช่น ใน test หรือ console): ไฟล์ยังไม่ขึ้น MinIO → อ่านจากไฟล์ในมือ แล้วกรอกลับไปต้นไฟล์
+        io = attachment_changes["cover"].attachable
+        io = io[:io] if io.is_a?(Hash)
+        io.rewind
+        io.read(4.kilobytes).to_s.tap { io.rewind }
+      end
+    end

method แรก cover_must_be_small_image — ตัวตัดสิน:

method ที่สอง cover_head — ไปเอาต้นไฟล์มา ซึ่งอยู่ได้ 3 ที่ จึงมี 3 กิ่ง:

✅ กิ่ง else ผมลองจริงใน console แล้ว: แนบ tmp/fake.png ด้วย post.cover.attach(io: File.open(…), filename: "fake.png") → ได้ ["Cover รองรับเฉพาะ PNG, JPEG, WebP"] และรูปเดิมใน DB ไม่ถูกแทน

ฟอร์ม: ช่วยกรองไฟล์ + ทำช่องเป็นสีแดง

เปิด app/views/posts/_form.html.erb · แก้บรรทัด file_field ของรูปปกเป็น 3 บรรทัด (comment + file_field ที่ยาวจนต้องขึ้นบรรทัดใหม่):

    <%# direct_upload: เบราว์เซอร์ส่งไฟล์ขึ้น MinIO (:9000) ตรง ๆ ไม่ผ่าน Rails · ฟอร์มส่งแค่รหัสของไฟล์ %>
-    <%= form.file_field :cover, direct_upload: true, class: "file-input w-full mt-2" %>
+    <%# accept แค่ช่วยกรองไฟล์ในหน้าต่างเลือกไฟล์ — ของจริงตรวจที่ model จากเนื้อไฟล์ %>
+    <%= form.file_field :cover, direct_upload: true, accept: Post::COVER_TYPES.join(","),
+          class: ["file-input w-full mt-2", {"file-input-error": post.errors[:cover].any?}] %>

ลองไฟล์ปลอมอีกรอบ

เปิดหน้าแก้ไขบทความ 27 · เลือก tmp/fake.png · กดบันทึก · ผลจริงบนจอ (กล่องแดงเหนือฟอร์ม):

บันทึกไม่ได้ มี 1 จุดที่ต้องแก้:
• Cover รองรับเฉพาะ PNG, JPEG, WebP

และใน log (ตัดมาเฉพาะบรรทัดสำคัญ):

Started POST "/rails/active_storage/direct_uploads" for ::1 at 2026-11-10 10:21:38 +0700
  ActiveStorage::Blob Create (3.0ms)  INSERT INTO "active_storage_blobs" (…) VALUES ('u17psfgqs35172ueghghvdkulwkj', 'fake.png', 'image/png', NULL, 'minio', 38, …) RETURNING "id"
Completed 200 OK in 13ms (Views: 0.1ms | ActiveRecord: 5.6ms (1 query, 0 cached) | GC: 0.0ms)
Started PATCH "/posts/27" for ::1 at 2026-11-10 10:21:38 +0700
  TRANSACTION (0.6ms)  ROLLBACK
Completed 422 Unprocessable Content in 24ms (Views: 2.3ms | ActiveRecord: 5.3ms (5 queries, 0 cached) | GC: 3.2ms)

ไฟล์รูปจริงที่ใหญ่เกินก็โดนเหมือนกัน — ผมลองรูป PNG จริงหนัก 6,346,163 byte ได้ Cover ไฟล์ต้องไม่เกิน 5 MB (ผลจริง) · รูป png/jpg/webp ขนาดปกติบันทึกได้ตามเดิม

ไฟล์กำพร้า — ฝากแล้วไม่มีใครมารับ

direct upload ส่งไฟล์ขึ้นโกดังตอนกดบันทึก ก่อนที่ Rails จะเห็นฟอร์ม · ถ้า validation ไม่ผ่าน หรือผู้ใช้ปิดหน้าไปกลางทาง ไฟล์อยู่ใน MinIO แล้ว ใบรับฝากอยู่ใน DB แล้ว แต่ไม่มีป้ายผูกกับบทความไหน · เรียกว่าไฟล์กำพร้า (orphan · Rails เรียก unattached)

นับดูใน console (ผลจริง · หลังจากผมลองไฟล์ปลอม 1 ครั้ง และไฟล์ใหญ่ 1 ครั้ง):

wongnok-blog-all-in-one(dev):001> ActiveStorage::Blob.unattached.count
  ActiveStorage::Blob Count (0.5ms)  SELECT COUNT(*) 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
=> 2
wongnok-blog-all-in-one(dev):002> ActiveStorage::Blob.unattached.pluck(:filename, :byte_size)
  ActiveStorage::Blob Pluck (2.6ms)  SELECT "active_storage_blobs"."filename", "active_storage_blobs"."byte_size" FROM … WHERE "active_storage_attachments"."id" IS NULL
=> [["fake.png", 38], ["big.png", 6346163]]
wongnok-blog-all-in-one(dev):003> ActiveStorage::Blob.unattached.map { |b| b.service.exist?(b.key) }
  ActiveStorage::Blob Load (2.5ms)  SELECT "active_storage_blobs".* FROM … WHERE "active_storage_attachments"."id" IS NULL
  S3 Storage (49.9ms) Checked if file exists at key: tdxomee5263zp0xj82lynsfwkyko (yes)
  S3 Storage (1.4ms) Checked if file exists at key: w6y2kv8iuzroxbljrd13wci9et72 (yes)
=> [true, true]

ไฟล์กำพร้ามาจาก direct upload ที่ถูกปฏิเสธ หรือรูปที่ลากใส่ช่องเนื้อหา rich text แล้วไม่บันทึก · แค่เลือกไฟล์แล้วไม่กดบันทึกไม่เกิด (ลองใน Chrome แล้ว — direct upload ของ Rails เริ่มตอนกดบันทึก)

🏷️ เทียบให้เห็นภาพ — ของฝากที่ไม่มีใครมารับ

direct upload คือลูกค้าเอาของไปฝากโกดังเองก่อน แล้วค่อยถือบัตรรับฝากมายื่นที่เคาน์เตอร์ · พนักงานตรวจแล้วบอก "รับไม่ได้" — ลูกค้าเดินกลับบ้าน แต่ของยังวางอยู่ในโกดัง พร้อมบัตรรับฝากที่ไม่มีแฟ้มไหนอ้างถึง · วันละไม่กี่ชิ้นไม่เป็นไร แต่ปีหนึ่งโกดังเต็ม (และ S3 จริงคิดเงินตามพื้นที่)

ที่ไม่เหมือนกัน: โกดังจริงรู้ว่าของชิ้นไหนค้างนาน · MinIO/S3 ไม่รู้เลย ว่าไฟล์ไหนกำพร้า เพราะความรู้ว่า "ของชิ้นนี้เป็นของแฟ้มไหน" (ป้าย) อยู่ใน DB ของเรา ไม่ได้อยู่ในโกดัง · คนที่ไล่หาของค้างได้จึงต้องเป็นแอปของเราเอง

ลบได้เลยไหม — เว้นไว้สองวันก่อน

สไลด์บอก "ต้องมี job ไล่เก็บ" · เอกสาร Rails ให้ตัวอย่างไว้บรรทัดเดียว (ด้านล่าง) · ลองรันเฉพาะส่วนนับต่อใน console เดิม (ผลจริง):

wongnok-blog-all-in-one(dev):004> ActiveStorage::Blob.unattached.where(created_at: ..2.days.ago).count
  ActiveStorage::Blob Count (2.7ms)  SELECT COUNT(*) FROM "active_storage_blobs" LEFT OUTER JOIN … WHERE "active_storage_attachments"."id" IS NULL AND "active_storage_blobs"."created_at" <= '2026-11-08 03:37:40.973843'
=> 0

# ตัวอย่างในเอกสาร Rails (ยังไม่ต้องรัน): ลบไฟล์กำพร้าที่อายุเกิน 2 วัน
ActiveStorage::Blob.unattached.where(created_at: ..2.days.ago).find_each(&:purge_later)

บรรทัดนี้ต้องมีคนรันให้ทุกวันโดยอัตโนมัติ — นั่นคืองานตามเวลา (recurring job) ของบท 144 · บทนี้รู้แค่ว่าปัญหามีจริงและหน้าตาของคำตอบเป็นแบบไหน

purge กับ purge_later

ลบไฟล์ได้สองแบบ · purge (ใช้ไปตอนลบรูปปลอม) = ลบเดี๋ยวนี้ แล้วรอจนเสร็จ · purge_later = เขียนใบงาน "ลบไฟล์นี้" แขวนไว้ แล้วไปทำอย่างอื่นต่อเลย ใครว่างค่อยมาทำ · ในเครื่องตัวเองไม่ต้องรอสองวัน — ลองแบบ later กับไฟล์กำพร้าทั้งสองเลย (ผลจริง · ตัดบรรทัด SQL ของงานลบเป็น … เพราะเหมือนตอน purge):

wongnok-blog-all-in-one(dev):005> ActiveStorage::Blob.unattached.find_each(&:purge_later)
  ActiveStorage::Blob Load (1.0ms)  SELECT "active_storage_blobs".* FROM … WHERE "active_storage_attachments"."id" IS NULL ORDER BY "active_storage_blobs"."id" ASC LIMIT 1000
Enqueued ActiveStorage::PurgeJob (Job ID: d5e26f62-8ad8-43e7-9929-b83a5146d5fd) to Async(default) with arguments: #<GlobalID:0x0000000128b1ca00 @uri=#<URI::GID gid://wongnok-blog-all-in-one/ActiveStorage::Blob/13>>
↳ (wongnok-blog-all-in-one):5:in '<main>'
Enqueued ActiveStorage::PurgeJob (Job ID: 00faa663-8627-4d3e-bbcf-0a803d5ade7d) to Async(default) with arguments: #<GlobalID:0x0000000128b15a98 @uri=#<URI::GID gid://wongnok-blog-all-in-one/ActiveStorage::Blob/14>>
↳ (wongnok-blog-all-in-one):5:in '<main>'
=> nil
wongnok-blog-all-in-one(dev):006>   ActiveStorage::Blob Load (1.0ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 13 LIMIT 1
Performing ActiveStorage::PurgeJob (Job ID: d5e26f62-8ad8-43e7-9929-b83a5146d5fd) from Async(default) enqueued at 2026-11-10T03:37:44.992969000Z with arguments: …
  …
  S3 Storage (3.6ms) Deleted file from key: tdxomee5263zp0xj82lynsfwkyko
  S3 Storage (2.5ms) Deleted files by key prefix: variants/tdxomee5263zp0xj82lynsfwkyko/
Performed ActiveStorage::PurgeJob (Job ID: d5e26f62-8ad8-43e7-9929-b83a5146d5fd) from Async(default) in 13.89ms
  …  (งานของ Blob/14 แบบเดียวกัน)
ActiveStorage::Blob.unattached.count
  ActiveStorage::Blob Count (2.0ms)  SELECT COUNT(*) FROM "active_storage_blobs" LEFT OUTER JOIN … WHERE "active_storage_attachments"."id" IS NULL
=> 0

ตอนนี้ "คนทำใบงาน" (Async) คือเธรดในโปรเซส Rails เดียวกัน — คล้ายสั่ง go func() ใน Go (บท 21) · ใบงานอยู่ในหน่วยความจำ ไม่ได้จดลงที่ไหน · บล็อก E (บท 142–143) จะเปลี่ยนเป็นกระดานใบงานจริงที่จดลง Postgres

อีกเรื่องที่ไม่ต้องทำเอง: ลบบทความ → Rails ลบรูปปกให้ เพราะ has_one_attached ตั้งค่าเริ่มต้นไว้เป็น dependent: :purge_later (เอกสาร API ของ Rails) — ตรงกับที่สไลด์หน้า 12 บอก

🗣 ศัพท์ที่เพิ่งเรียนไป
orphaned file · unattached blob
ไฟล์ที่อัปขึ้น storage แล้วแต่ไม่มี record ไหนผูก · ใน Rails หาด้วย ActiveStorage::Blob.unattached · เกิดบ่อยกับ direct upload · ถาม senior ได้ว่า "โปรเจกต์เรามี job ลบ unattached blob ไหม รันทุกวันหรือเปล่า เว้นกี่วัน"
purge · purge_later
purge = ลบป้าย + ใบรับฝาก + ไฟล์ + รูปย่อ เดี๋ยวนั้น · purge_later = ส่งเป็นงานเบื้องหลัง (Active Job) คืนผลทันที · พิมพ์บอก AI ได้ว่า "use purge_later in request paths so the user does not wait for storage deletes"

📍 ตรวจก่อนไปต่อ: git status --short ต้องเห็นไฟล์ที่ถูกแก้ 5 ไฟล์พอดี — app/controllers/posts_controller.rb · app/models/post.rb · app/views/posts/_form.html.erb · app/views/posts/_post.html.erb · config/application.rb · ใน post.rb ต้องไม่เหลือ cover_is_valid ของสไลด์ · ไฟล์ใน tmp/ ไม่โผล่ (git ไม่สนโฟลเดอร์นี้) · แล้ว commit ได้ เช่น git commit -am "รูปย่อ + ตรวจรูปปกจากเนื้อไฟล์"

🎯 ภารกิจ — ทำไฟล์ปลอมสองแบบ แล้วเก็บกวาดโกดังเอง (~8 นาที)

ทำบนโค้ดตอนจบบท (validation ตัวจริงแล้ว) · ใช้บทความที่ไม่มีรูปปก

  1. สร้างไฟล์หนัก 6 MB ที่ข้างในเป็นเลข 0 ล้วน (ไม่ใช่รูป) ชื่อลงท้าย .png: mkfile -n 6m tmp/zero.png (macOS) หรือ truncate -s 6M tmp/zero.png (Linux) · ใช้ tmp/fake.png จากในบทด้วย (ถ้าลบไปแล้วสร้างใหม่ด้วย printf เดิม)
  2. เปิดหน้าแก้ไขบทความ · เลือก tmp/zero.png (ในหน้าต่างเลือกไฟล์ เข้าโฟลเดอร์โปรเจกต์ → tmp) · กดบันทึก
  3. ทำซ้ำด้วย tmp/fake.png
  4. เปิด bin/rails console แล้วพิมพ์ทีละบรรทัด:
    ActiveStorage::Blob.unattached.pluck(:filename)
    ActiveStorage::Blob.unattached.find_each(&:purge)
    ActiveStorage::Blob.unattached.count
    (ใช้ purge ไม่ใช่ purge_later เพื่อเห็นผลทันที · ลบหมดไม่เว้นสองวันได้เฉพาะในเครื่องตัวเอง)

ก่อนกดเฉลย: ข้อ 2 กล่องแดงจะมีกี่บรรทัด บรรทัดไหนบ้าง? · ข้อ 4 บรรทัดแรกจะเห็นชื่อไฟล์อะไร?

ข้อ 2 — ผลจริง: 2 จุด เพราะไฟล์ศูนย์ล้วนไม่มีลายเซ็นรูป และใหญ่เกิน 5 MB · validation รันครบทุกข้อ ไม่หยุดที่ข้อแรก:

บันทึกไม่ได้ มี 2 จุดที่ต้องแก้:
• Cover รองรับเฉพาะ PNG, JPEG, WebP
• Cover ไฟล์ต้องไม่เกิน 5 MB

ข้อ 3 — Cover รองรับเฉพาะ PNG, JPEG, WebP บรรทัดเดียว · ข้อ 4 — ผลจริง (ตัดบรรทัด SQL):

wongnok-blog-all-in-one(dev):001> ActiveStorage::Blob.unattached.pluck(:filename)
=> ["zero.png", "fake.png"]
wongnok-blog-all-in-one(dev):002> ActiveStorage::Blob.unattached.find_each(&:purge)
  …
  ActiveStorage::Blob Destroy (1.3ms)  DELETE FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 11
  …
  S3 Storage (20.0ms) Deleted file from key: rxqg8puw70j2319aqptw98puznrq
  S3 Storage (4.3ms) Deleted files by key prefix: variants/rxqg8puw70j2319aqptw98puznrq/
  …  (ไฟล์ที่สองแบบเดียวกัน)
=> nil
wongnok-blog-all-in-one(dev):003> ActiveStorage::Blob.unattached.count
=> 0

ไฟล์ที่ถูกปฏิเสธทั้งสองไปค้างอยู่ใน MinIO ระหว่างข้อ 2–3 · purge ลบทั้งใบรับฝากและตัวไฟล์ · บทความไม่ถูกแตะเลย (ไม่มีป้ายให้ลบ)

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

  • ข้อ 2 บันทึกผ่าน ขึ้นแถบเขียว — post.rb ยังเป็นโค้ดของสไลด์ (ตรวจ content_type) หรือ validate พิมพ์ชื่อ method ไม่ตรงกับ def · เปิด post.rb เทียบกับในบท
  • ข้อ 4 บรรทัดแรกเห็นชื่อไฟล์มากกว่าสองชื่อ — ปกติ · มีไฟล์กำพร้าจากการทดลองก่อนหน้า (หรือรูปที่ลากใส่เนื้อหาแล้วไม่บันทึก) · purge เก็บหมดเหมือนกัน
  • undefined method 'unattached' for module ActiveStorage::Blobs — พิมพ์ Blobs มี s เกิน (ลองแล้ว ได้ข้อความนี้เป๊ะ) · ชื่อที่ถูกคือ ActiveStorage::Blob · ถ้าได้ uninitialized constant Activestorage = พิมพ์ S ตัวเล็ก

ทดสอบความเข้าใจ

(ดัดแปลงจาก Recap case 2 สไลด์หน้า 13) หน้ารวมโชว์รูปย่อของรูปปก และข้อความล้วนของเนื้อหา rich text ในการ์ดทุกใบ · controller เขียน Post.includes(:category).limit(20) · ควรเติมอะไรไม่ให้เกิด N+1?

ตัวเลือกที่ 3 ถูก — ผลจริงในบทนี้: ไม่มี with_attached_cover ได้ 17 query (ป้ายทีละบทความ + ใบรับฝากทีละใบ) · ใส่แล้ว + ปิด track_variants เหลือ 6 · ส่วน with_rich_text_body กันเนื้อหา rich text ทีละบทความ (บท 137)

ตัวเลือกที่ 1 สับสนระหว่างตัวไฟล์ (อยู่ใน MinIO) กับป้าย/ใบรับฝาก (อยู่ใน DB) — N+1 เกิดที่ DB · ตัวเลือกที่ 2 — cover ไม่ใช่ชื่อความสัมพันธ์จริง ลองแล้วได้ Association named 'cover' was not found on Post (ชื่อจริงคือ cover_attachment) · ตัวเลือกที่ 4 — ย้ายที่เรียกไม่ได้ลดจำนวนครั้งที่ไปตู้ ปัญหาอยู่ที่ใบเบิกไม่ได้บอกล่วงหน้า

จุดที่ต้องจำ: ไฟล์แนบ = ความสัมพันธ์ใน DB เหมือน association อื่น · หน้ารวมที่โชว์ไฟล์แนบต้องมี with_attached_* แล้วดูว่าจำนวน query ใน log ไม่โตตามข้อมูล

ทำไมโค้ดในสไลด์ที่ตรวจ cover.content_type.in?(%w[image/png image/jpeg image/webp]) จึงปล่อยไฟล์ข้อความที่ตั้งชื่อ fake.png ผ่านไปได้?

ตัวเลือกที่ 1 ถูก — log จริง: "content_type" => "image/png" มากับใบขอ direct upload ของเบราว์เซอร์ และ Marcel ที่ได้ชื่อไฟล์ไปด้วยตอบ "image/png" · ให้แค่เนื้อ Marcel ตอบ "application/octet-stream"

ตัวเลือกที่ 2 — validation รันจริง (ตอน PATCH) โค้ดตัวจริงของเราปฏิเสธได้ด้วย 422 · ปัญหาคือข้อมูลที่โค้ดของสไลด์ใช้ตัดสิน · ตัวเลือกที่ 3 — content_type ถูกจดลง DB ตั้งแต่ INSERT INTO "active_storage_blobs" ตามที่เบราว์เซอร์บอก MinIO ไม่ได้ยุ่ง · ตัวเลือกที่ 4 — validation รันทุกข้อ (ภารกิจได้ 2 ข้อความพร้อมกัน) และในสไลด์ตรวจชนิดก่อนขนาดด้วยซ้ำ

จุดที่ต้องจำ: ชื่อไฟล์ นามสกุล content_type = สิ่งที่ client อ้าง · ชนิดจริงต้องดูจากเนื้อไฟล์ (magic bytes) โดยไม่บอกชื่อไฟล์ให้ตัวตรวจ

ลูกค้าคนที่สองเปิดหน้ารวม หลังจากคนแรกเปิดไปแล้ว (โค้ดตอนจบบท) · ตอนเบราว์เซอร์ขอรูปย่อของบทความ 30 Rails ทำอะไร?

ตัวเลือกที่ 4 ถูก — log ครั้งที่สอง: Checked if file exists at key: variants/… (yes) → Generated URL → 302 · ไม่มี Downloaded/Uploaded · 46ms → 8ms

ตัวเลือกที่ 1 — ไม่จดลงตาราง ≠ ไม่จำ · ชิ้นย่อยังอยู่ใน MinIO Rails แค่ถามโกดังแทนการเปิดสมุด · ตัวเลือกที่ 2 — ถ้าทำแบบนั้นก็ไม่ต้องมี variant · ผู้ใช้ได้ไฟล์เล็ก 400×250 จริง · ตัวเลือกที่ 3 — เราปิด track_variants แล้ว ตารางนั้นไม่ถูกใช้ และตาราง DB ไม่เคยเก็บตัวไฟล์ เก็บแค่ข้อมูลของไฟล์

จุดที่ต้องจำ: variant ทำครั้งแรกตอนมีคนขอ แล้วเก็บใน storage ใช้ซ้ำ · คนแรกรอ คนต่อไปเร็ว

ผู้ใช้อัปไฟล์ปลอมผ่านฟอร์ม (direct upload) แล้วได้กล่องแดง 422 · ตอนนี้ไฟล์ปลอมนั้นอยู่ที่ไหน?

ตัวเลือกที่ 2 ถูก — ผลจริง: ActiveStorage::Blob.unattached.count → 2 และ exist? ใน MinIO → [true, true] · direct upload ส่งไฟล์และสร้างใบรับฝากก่อนฟอร์มถึง model

ตัวเลือกที่ 1 — ROLLBACK ยกเลิกแค่สิ่งที่ทำใน transaction ของ PATCH · ใบรับฝากถูก COMMIT ไปตั้งแต่ใบสั่ง direct_uploads ก่อนหน้า และ MinIO ไม่รู้จัก transaction ของ Postgres · ตัวเลือกที่ 3 — direct upload ไม่ผ่านเซิร์ฟเวอร์ Rails เลย ไฟล์ไปที่ MinIO ตรง ๆ · ตัวเลือกที่ 4 — ไม่มีป้าย (Attachment Create ไม่เกิด) บทความยังไม่มีรูปปก

จุดที่ต้องจำ: direct upload + validation ไม่ผ่าน = ไฟล์กำพร้า · ต้องมีงานไล่ unattached ที่อายุเกินกำหนดแล้ว purge_later

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

หน้าสไลด์เขียนของจริง (Rails 8.1.4 · แอปของคอร์ส)ต้องสนใจไหม
12validation ตรวจ cover.content_type.in?(…)ไฟล์ข้อความชื่อ fake.png ผ่าน บันทึกได้ แล้วรูปย่อพัง 500 (ลองแล้ว) · บทนี้ใช้ Marcel อ่านเนื้อไฟล์แทนสนใจ — ใช้โค้ดในบทนี้
12"คนร้ายอัป shell script แล้วบอกว่าเป็น image/png ได้สบาย"ลองไฟล์ที่ขึ้นต้น #!/bin/sh ชื่อ .png แล้ว — โค้ดของสไลด์จับได้ เพราะ Marcel รู้จักลายเซ็นนั้น (Rails ตรวจเนื้อซ้ำตอนแนบ) · ที่หลุดคือไฟล์ที่ไม่มีลายเซ็นอะไรเลย อย่างข้อความธรรมดาไม่ต้อง — ข้อสรุปเดิมยังถูก: อย่าเชื่อ content_type
12return unless cover.attached? แล้วตรวจทุกครั้งที่บันทึกเราตรวจเฉพาะตอนแนบรูปใหม่ (cover.attachment.new_record?) · แก้หัวข้อแล้วไม่ต้องโหลดไฟล์จาก MinIO ซ้ำไม่ต้อง
12ใช้ variant ในการ์ด (ไม่พูดถึงจำนวน query)ต้องเติม with_attached_cover และปิด track_variants ถึงจะเหลือ 6 query ไม่มีคำเตือน bullet (วัดจริง 17 → 10 → 6)สนใจ ถ้าดู log
12resize_to_limit: [400, 300]ทำงานเหมือนกัน · โปรเจกต์เขียน [ 400, 300 ] มีเว้นวรรคตามรูปแบบของ rubocop · และต้องมีคลาส self-start ไม่งั้นรูปถูกยืดเป็น 720pxไม่ต้อง
12libvips "เร็วและกิน RAM น้อยกว่า ImageMagick"เอกสาร Rails ให้ตัวเลข "เร็วได้ถึง 10 เท่า ใช้หน่วยความจำ 1/10" (ผมไม่ได้วัด)ไม่ต้อง

อ้างอิง

สงสัยตรงไหน — เช่น "ทำไมไม่ตรวจไฟล์ในเบราว์เซอร์ด้วย JavaScript ไปเลย" หรือ "S3 ของจริงต้องเก็บกวาดแบบนี้ไหม" — ถามกลับมาได้เลย