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

signed URL และ direct upload — แอปออกใบอนุญาต ไม่ได้แบกไฟล์เอง

~25 นาที · ควรผ่าน บทเรียน 138 (MinIO · Active Storage · รูปปก) มาก่อน · บทนี้ไม่เพิ่มโค้ดใหม่ — เปิดดูข้างในของที่บท 138 ติดตั้งไว้

บท 138 รูปปกขึ้นจอได้แล้ว · บทนี้ดูว่าไฟล์วิ่งทางไหน — และทำไมทางที่ถูกคือไม่ผ่าน Rails
อ่านจบบทนี้ คุณจะอ่าน URL ของรูปที่มี X-Amz-Signature ออกว่าแต่ละท่อนคืออะไร · รู้ว่ามันหมดอายุเมื่อไหร่และหมดแล้วเห็นอะไร · ชี้ใน Network ได้ว่าตอนอัปรูปไฟล์ไปหา MinIO ตรง ๆ ส่วน Rails ได้แค่รหัส · เลือกได้ว่าเมื่อไหร่ให้ Rails บอกทาง (redirect) เมื่อไหร่ให้ Rails ส่งไฟล์เอง (proxy) · และรู้ว่าลบรูปแล้วต้องเช็คอะไรก่อนบอกเจ้าของว่า "ลบแล้ว"

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

🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · aws-sdk-s3 1.238.0 · MinIO (image pgsty/silo ตัวเดียวกับ compose.yaml ของอาจารย์) · Chrome (สั่งด้วยสคริปต์) — สไลด์และ repo ของอาจารย์เขียน Ruby 4.0.5–4.0.6 · Rails 8.1.3 เรื่องในบทนี้ไม่ต่างกัน
📍 โค้ดเริ่มจาก: ตอนจบบท 138 (รูปปก + direct upload · git status --short ว่าง) · บทนี้ทดลองแล้วคืนทิ้งตอนจบด้วย git restore — มีแก้ไฟล์แค่บรรทัดเดียวชั่วคราว (หัวข้อ proxy)
🖥 เปิดค้างไว้: docker compose up -d (Postgres + MinIO) · bin/dev หนึ่งหน้าต่าง (เว็บที่ localhost:3000) · อีกหน้าต่างไว้พิมพ์คำสั่ง · Chrome ที่เปิด DevTools ได้ · ผลรันทุกกล่องมาจากเครื่องจริง · key ของไฟล์ · ลายเซ็น · signed_id · วันเวลา · เลข ms · id ของ blob ของคุณจะไม่ตรงกับในบท

ประโยคเดียวของบทนี้: แอปออกใบอนุญาต ไม่ได้แบกไฟล์เอง

ตอนนี้ไฟล์รูปอยู่ใน MinIO แล้ว · คำถามต่อไปคือใครเป็นคนขนไฟล์ระหว่างเบราว์เซอร์กับ MinIO · ถ้าให้ Rails ขนเอง รูปขนาด 600 KB ที่คนเปิดดู 1,000 ครั้ง = Rails ต้องอ่านไฟล์จาก MinIO แล้วส่งต่อ 600 MB

ระหว่างส่ง Rails ต้องกันคนรับคำขอไว้หนึ่งคนจนกว่าจะส่งเสร็จ · และ Rails มีคนรับคำขอจำกัด (Puma = โปรแกรมที่รับคำขอแทน Rails · ไฟล์ config/puma.rb ของเราตั้งไว้ 3 คน) · ถ้าคนรับคำขอทุกคนกำลังขนรูป คนที่แค่อยากเปิดหน้าบทความก็ต้องรอคิว

ทางที่ Active Storage ใช้คือ Rails ไม่ขนไฟล์ แต่ออก "ใบอนุญาต" ให้เบราว์เซอร์ไปคุยกับ MinIO เอง · ใช้สองทิศ:

ขาออก — มีคนเปิดดูรูป เบราว์เซอร์ ──① ขอรูป────────────────────▶ Rails (:3000) เบราว์เซอร์ ◀─② 302 + URL ที่เซ็นแล้ว───── Rails (Rails แค่เซ็นชื่อ ไม่แตะไฟล์) เบราว์เซอร์ ──③ ดึงไฟล์ตรง────────────────▶ MinIO (:9000) ขาเข้า — มีคนอัปรูป (direct upload) เบราว์เซอร์ ──① ขอใบอนุญาต (ชื่อ ขนาด ลายนิ้วมือไฟล์)──▶ Rails เบราว์เซอร์ ◀─② URL ที่เซ็นแล้ว สำหรับส่งไฟล์───────── Rails เบราว์เซอร์ ──③ ส่งไฟล์ตรง──────────────────────────▶ MinIO เบราว์เซอร์ ──④ ส่งฟอร์ม (มีแค่รหัสของไฟล์)─────────▶ Rails
🍜 เทียบกับร้านอาหาร

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

ที่ไม่เหมือนกัน: บัตรผ่านจริงมักมีชื่อคนถือ คนเฝ้าดูหน้าก่อนให้เข้า · URL ที่เซ็นแล้วไม่มีชื่อใคร — ใครถือ URL ก็เปิดได้ภายใน 5 นาที (ส่งต่อในแชตก็เปิดได้) · และ "ตรวจลายเซ็นเอง" ของ MinIO คือการคำนวณทางคณิตศาสตร์ด้วยรหัสลับที่ร้านกับโกดังรู้ร่วมกัน ไม่ใช่การดูลายมือ

เทียบของที่รู้: ถ้าเคยทำ JWT ใน บท 49 (Go) — คือไอเดียเดียวกัน: ข้อมูลที่เซ็นแล้ว + มีเวลาหมดอายุ ฝั่งที่รับตรวจลายเซ็นเองได้โดยไม่ต้องถามฐานข้อมูล · ต่างกันแค่ JWT เป็น "บัตรที่บอกว่าคุณเป็นใคร" ส่วน URL ที่เซ็นแล้วเป็น "บัตรที่บอกว่าเปิดไฟล์นี้ได้"

🏷 ศัพท์ไว้คุยกับ senior / AI
signed URL · presigned URL
URL ของไฟล์ใน storage ที่มีลายเซ็นและเวลาหมดอายุติดมาด้วย · AWS เรียก presigned URL · ถาม senior ได้ว่า "ไฟล์ของเราเสิร์ฟผ่าน presigned URL หรือ bucket เปิด public?"
direct upload
เบราว์เซอร์ส่งไฟล์ขึ้น storage ตรง ๆ แอปได้แค่รหัสของไฟล์ · พิมพ์บอก AI ได้ว่า "อัปโหลดแบบ direct upload ของ Active Storage ห้ามรับไฟล์ผ่าน controller"
offload
ย้ายงานหนัก (ขนไฟล์) ออกจากแอปไปให้ระบบที่ถนัดกว่าทำ · "เรา offload การเสิร์ฟไฟล์ไปที่ S3 แล้ว"

ขาออก ① — <img> ของเราชี้ไปที่ไหน

เปิดบทความที่มีรูปปก (บท 138 คุณอัปให้บทความไหนไว้ ใช้เลขนั้น · ในบทนี้ใช้บทความ 1 · ถ้ายังไม่มี ให้เข้า /posts/1/edit เลือกรูปแล้วกดบันทึกก่อน) · แล้วดูแท็ก <img> ที่ Rails ส่งมา · curl -s = ขอหน้าเว็บแบบเงียบ (ไม่โชว์แถบความคืบหน้า) · grep -o = ตัดมาเฉพาะส่วนที่ตรงกับรูปแบบ · รูปแบบ '<img[^>]*>' อ่านว่า "ตั้งแต่ <img ไปจนเจอ > ตัวแรก" = แท็กรูปหนึ่งแท็ก:

$ curl -s localhost:3000/posts/1 | grep -o '<img[^>]*>'
<img alt="" class="w-full max-h-96 object-cover rounded-box" src="http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NCwicHVyIjoiYmxvYl9pZCJ9fQ==--e48eee28ac016f73b5f68e117da8de5324704ad9/cover.jpg" />

มาจากบรรทัดนี้ใน app/views/posts/_post.html.erb (บท 138):

<%# app/views/posts/_post.html.erb (ในกิ่ง if full) %>
<%= image_tag post.cover, alt: "", class: "w-full max-h-96 object-cover rounded-box" if post.cover.attached? %>

src ไม่ได้ชี้ไปที่ MinIO · มันชี้กลับมาที่ Rails เอง แบ่งเป็น 4 ท่อน:

ขาออก ② — Rails ตอบ 302 แล้วชี้ไป MinIO

ขอ URL นั้นด้วย curl -I (-I = ขอดูแค่หัวของคำตอบ ไม่เอาเนื้อ) · ใช้ src ของคุณเองแทน · หัวคำตอบยาว ข้างล่างตัดมาเฉพาะบรรทัดที่ต้องดู:

$ curl -I http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NCwicHVyIjoiYmxvYl9pZCJ9fQ==--e48eee28ac016f73b5f68e117da8de5324704ad9/cover.jpg
HTTP/1.1 302 Found
…
date: Tue, 10 Nov 2026 03:18:55 GMT
location: http://localhost:9000/wongnok-dev/16r6iq1cr83g24mawphb39a7j0oi?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=20261110T031855Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=7731b001629a439069df22519f98af2188713a248d8dd88e20c4fc2f8572e52f
content-type: text/html; charset=utf-8
cache-control: max-age=300, private
…
content-length: 0

ฝั่ง Rails ทำอะไรบ้าง ดูใน log (หน้าต่าง bin/dev) ของคำขอนั้น:

Started GET "/rails/active_storage/blobs/redirect/eyJfcmFp…/cover.jpg" for ::1 at 2026-11-10 10:18:55 +0700
Processing by ActiveStorage::Blobs::RedirectController#show as JPEG
  …
  ActiveStorage::Blob Load (0.7ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 4 LIMIT 1 …
  S3 Storage (0.6ms) Generated URL for file at key: 16r6iq1cr83g24mawphb39a7j0oi (http://localhost:9000/wongnok-dev/16r6iq1cr83g24mawphb39a7j0oi?…)
Redirected to http://localhost:9000/wongnok-dev/16r6iq1cr83g24mawphb39a7j0oi?…
Completed 302 Found in 9ms (ActiveRecord: 2.4ms (1 query, 0 cached) | GC: 0.0ms)

อ่าน URL ที่เซ็นแล้วทีละท่อน

URL ใน location: ยาวมากแต่ไม่ได้ซับซ้อน · ผมตัดขึ้นบรรทัดใหม่ตรงเครื่องหมาย ? และ & ให้อ่านง่าย (ของจริงเป็นบรรทัดเดียว) · %2F %3B %20 คือวิธีเขียน / ; และช่องว่างใน URL

http://localhost:9000/wongnok-dev/16r6iq1cr83g24mawphb39a7j0oi
?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=20261110T031855Z
&X-Amz-Expires=300
&X-Amz-SignedHeaders=host
&X-Amz-Signature=7731b001629a439069df22519f98af2188713a248d8dd88e20c4fc2f8572e52f
ท่อนแปลเป็นภาษาคน
/wongnok-dev/16r6iq…ห้อง wongnok-dev · ช่องเก็บเลข 16r6iq… (= key ของ blob สุ่มมาตอนอัป · ไม่ใช่ชื่อ cover.jpg)
response-content-disposition · response-content-typeสั่ง MinIO ว่าตอนส่งไฟล์ ให้บอกเบราว์เซอร์ว่า "นี่คือรูป jpeg ชื่อ cover.jpg · เปิดโชว์ในหน้า (inline) ไม่ต้องดาวน์โหลด"
X-Amz-Algorithmวิธีคำนวณลายเซ็น (มาตรฐานของ S3 ชื่อ Signature Version 4) · MinIO พูดภาษา S3 จึงใช้ชื่อขึ้นต้น X-Amz- (Amz = Amazon)
X-Amz-Credential"ใครเซ็น" — minioadmin คือ access key id (ชื่อผู้ใช้ของโกดัง) + วันที่ + us-east-1 (ค่า region ที่เขียนไว้ใน config/storage.yml บท 138 · S3 จริงใช้บอกว่าโกดังอยู่ประเทศไหน) · รหัสลับ (secret) ไม่อยู่ใน URL
X-Amz-Dateเวลาที่เซ็น · T031855Z = 03:18:55 เวลาสากล (Z) = 10:18:55 เวลาไทย (บวก 7 ชั่วโมง) — ตรงกับบรรทัด date: ของคำตอบ 302
X-Amz-Expires=300ใช้ได้ 300 วินาทีนับจาก X-Amz-Date = 5 นาที
X-Amz-SignedHeaders=hostลายเซ็นครอบหัวคำขอตัวไหนด้วย · ตรงนี้แค่ host (= ต้องเรียกที่ localhost:9000 เท่านั้น)
X-Amz-Signatureลายเซ็น · คำนวณจากทุกท่อนด้านบน + รหัสลับของ MinIO ที่อยู่ใน credentials ของเรา (บท 138)

MinIO รู้รหัสลับตัวเดียวกัน · พอได้คำขอ มันคำนวณลายเซ็นซ้ำจากทุกท่อนในคำขอ · ถ้าตรงกับ X-Amz-Signature = ร้านออกบัตรนี้จริงและไม่มีใครแก้ตัวอักษรสักตัว · แล้วค่อยเช็คว่ายังไม่หมดเวลา

คนที่ได้ URL นี้ไปอยากเปิดได้นานขึ้น · เลยแก้ X-Amz-Expires=300 เป็น X-Amz-Expires=86400 (1 วัน) แล้วเปิดทันที (ยังไม่ถึง 5 นาที) · -w '\n[%{http_code}]\n' = ให้ curl พิมพ์เลข status ในวงเล็บเหลี่ยมตอนจบ:

$ curl -s "http://localhost:9000/wongnok-dev/16r6iq…&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=…" -w '\n[%{http_code}]\n'

(การแก้ URL เล่นแบบนี้ ทำได้กับแอปในเครื่องตัวเองเท่านั้น · ห้ามลองกับเว็บหรือ storage ของคนอื่น)

ได้รูป และเปิดได้ 1 วัน · ได้รูปแต่ยังหมดใน 5 นาที · หรือไม่ได้อะไรเลย?

ผลจริง (URL ที่เซ็นใหม่หลังเวลา 10:24 · ตัดท่อนกลางออก):

$ curl -s "http://localhost:9000/wongnok-dev/16r6iq1cr83g24mawphb39a7j0oi?…&X-Amz-Date=20261110T032423Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=3da33dbc…" -w '\n[%{http_code}]\n'
<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>SignatureDoesNotMatch</Code><Message>The request signature we calculated does not match the signature you provided. Check your key and signing method.</Message>
<Key>16r6iq1cr83g24mawphb39a7j0oi</Key><BucketName>wongnok-dev</BucketName>…</Error>
[403]
  • คำตอบเป็น XML (ข้อความแบบมีแท็กคล้าย HTML · S3 และ MinIO ใช้รูปแบบนี้ตอบ error) · อ่านแค่ <Code> กับ <Message> พอ
  • SignatureDoesNotMatch — "ลายเซ็นที่เราคำนวณ ไม่ตรงกับลายเซ็นที่คุณส่งมา" · เพราะ 300 เป็นหนึ่งในท่อนที่ถูกเอาไปคำนวณลายเซ็น แก้ตัวเดียวลายเซ็นก็ไม่ตรง
  • [403] = ห้ามเข้า · ไม่ได้รูปเลยแม้จะยังไม่ถึง 5 นาที
  • ผมลองแก้อีกแบบคือเปลี่ยน key ของไฟล์ไปหนึ่งตัวอักษร (หวังแอบเปิดไฟล์ข้าง ๆ) → ได้ SignatureDoesNotMatch 403 เหมือนกัน

สรุป: คนถือ URL แก้อะไรไม่ได้เลย ทำได้อย่างเดียวคือใช้ตามที่ร้านออกให้

⚠️ minioadmin โผล่ในทุก URL ของรูป — ส่วนนั้นคือชื่อผู้ใช้ ไม่ใช่รหัสลับ จึงยังปลอดภัย · แต่เป็นเหตุผลอีกข้อที่ production (เว็บตัวจริงที่คนใช้) ไม่ควรใช้ผู้ใช้ root ของโกดัง (บัญชีแม่ที่ทำได้ทุกอย่าง) · ควรสร้าง access key แยกที่มีสิทธิ์แค่ bucket เดียว (บท 138)

🏷 ศัพท์ไว้คุยกับ senior / AI
signature (ลายเซ็น)
ค่าที่คำนวณจากข้อมูล + รหัสลับ · แก้ข้อมูลตัวเดียว ลายเซ็นไม่ตรงทันที · คนที่ไม่มีรหัสลับสร้างลายเซ็นใหม่ไม่ได้ · "URL นี้ถูกแก้ระหว่างทาง ได้ SignatureDoesNotMatch"
access key id · secret access key
คู่ "ชื่อผู้ใช้ + รหัสผ่าน" ของ S3/MinIO · id โผล่ใน URL ได้ · secret ห้ามหลุด · ถาม senior ได้ว่า "production ใช้ access key แยกที่มีสิทธิ์แค่ bucket นี้ใช่ไหม"
SigV4 (AWS Signature Version 4)
มาตรฐานการเซ็นคำขอของ S3 ที่ MinIO ก็ใช้ · คือที่มาของ X-Amz-* ทั้งหมด · พิมพ์ถาม AI ได้ว่า "อธิบาย query parameter ของ SigV4 presigned URL"

5 นาทีมาจากไหน — และหมดแล้วเห็นอะไร

เราไม่เคยตั้งค่าเลข 5 นาทีที่ไหน · ถาม Rails ตรง ๆ ใน bin/rails console (พิมพ์ชื่อค่าตั้งแล้ว Enter = ถามว่าตอนนี้เป็นเท่าไร):

Loading development environment (Rails 8.1.4)
wongnok-blog-all-in-one(dev):001> ActiveStorage.service_urls_expire_in
=> 5 minutes
wongnok-blog-all-in-one(dev):002> ActiveStorage.urls_expire_in
=> nil
wongnok-blog-all-in-one(dev):003> exit

ลองของจริง: เก็บ URL ของ :9000 ที่เซ็นตอน 10:18:55 ไว้ในตัวแปรของ shell (URL="http://localhost:9000/wongnok-dev/…" ทั้งเส้น) แล้วเปิดซ้ำตอนใกล้ครบ 5 นาที กับตอนเลย 5 นาที · (ไม่ต้องรอตามก็ได้ อ่านผลด้านล่างพอ)

$ date +%T; curl -s -o /dev/null -w '%{http_code} %{content_type} %{size_download}\n' "$URL"
10:23:45
200 image/jpeg 618479
$ date +%T; curl -s -i "$URL"
10:24:05
HTTP/1.1 403 Forbidden
…
Content-Type: application/xml
Server: Silo
…

<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>AccessDenied</Code><Message>Request has expired</Message><Key>16r6iq1cr83g24mawphb39a7j0oi</Key><BucketName>wongnok-dev</BucketName>…</Error>

แล้วหน้าบทความล่ะ — คนที่เปิดหน้าเว็บหลัง 5 นาทีจะเห็นรูปเสียไหม? ขอ URL ของ Rails (:3000 ตัวเดิมใน src) อีกรอบ:

# 10:24:23 — เลย 5 นาทีหลังเซ็นครั้งแรก (10:18:55)
$ curl -I http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NCwicHVyIjoiYmxvYl9pZCJ9fQ==--e48eee28ac016f73b5f68e117da8de5324704ad9/cover.jpg
HTTP/1.1 302 Found
…
date: Tue, 10 Nov 2026 03:24:23 GMT
location: http://localhost:9000/wongnok-dev/16r6iq1cr83g24mawphb39a7j0oi?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=20261110T032423Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=3da33dbc0a09fda5a8e0b0ea97bbca49233b31e3ca88d8a18fb854e1a91b3073
cache-control: max-age=300, private
…

ได้ 302 เหมือนเดิม แต่ X-Amz-Date เป็นเวลาใหม่ และลายเซ็นใหม่ · ทุกครั้งที่มีคนขอรูปผ่าน :3000 Rails เซ็นบัตรใบใหม่ให้ · สรุป: URL ของ MinIO หมดใน 5 นาที · URL ของ Rails ไม่หมด คนเปิดหน้าเว็บเมื่อไหร่ก็เห็นรูป

⚠️ URL :3000 คือลิงก์ถาวร ใครได้ไปก็เปิดได้ ไม่ต้อง login — คอมเมนต์ใน source ของ Rails เตือนไว้เองว่า controller ของ Active Storage เปิดให้ทุกคนเข้าเป็นค่าเริ่มต้น · รูปปกบล็อกที่ทุกคนเห็นอยู่แล้วไม่มีปัญหา · แต่ไฟล์ที่ควรเห็นเฉพาะเจ้าของ (ใบเสร็จ · เอกสารส่วนตัว) ห้ามใช้ท่านี้เฉย ๆ ต้องมีด่านตรวจสิทธิ์ก่อนเซ็น (Rails Guides เรียก Authenticated Controllers · เรื่องสิทธิ์อยู่ใน Part 2 ของสไลด์)

🍜 เทียบกับร้านอาหาร

src ของ Rails คือป้ายหน้าร้านที่เขียนว่า "อยากได้ของในโกดัง ถามพนักงาน" — ป้ายนี้ตั้งถาวร · ลูกค้าเดินมาถามเมื่อไหร่ พนักงานก็ออกบัตรผ่านใบใหม่ที่มีอายุ 5 นาทีให้ · บัตรใบเก่าที่ตกอยู่บนพื้นหมดอายุไปแล้วใช้ไม่ได้ แต่ป้ายยังพาไปขอใบใหม่ได้เสมอ

ที่ไม่เหมือนกัน: พนักงานร้านจริงจะมองหน้าคนถามก่อนออกบัตร · พนักงานของ Active Storage ไม่ดูหน้าใครเลย ใครถามมาด้วยป้ายที่ถูกต้องก็ออกบัตรให้ทันที — นั่นคือเหตุผลของกล่องเตือนด้านบน

🏷 ศัพท์ไว้คุยกับ senior / AI
service_urls_expire_in
อายุของ URL ที่เซ็นแล้วของ storage (ค่าเริ่มต้น 5 นาที) · "รูปเปิดค้างนาน ๆ แล้วเสีย เพราะ service_urls_expire_in หมด ต้อง reload หน้า"
urls_expire_in · permanent URL
อายุของ URL ฝั่ง Rails (ค่าเริ่มต้นไม่หมด) · ถาม senior ได้ว่า "ไฟล์ส่วนตัวเราใช้ authenticated controller หรือยัง เพราะ URL ของ Active Storage เป็น permanent"

ทำไมไม่เปิด bucket เป็น public ไปเลย

ถ้าเปิด bucket ให้ทุกคนอ่านได้ ก็ไม่ต้องเซ็นอะไร URL สั้นกว่า · สไลด์หน้า 10 ให้เหตุผลที่ไม่ทำ 4 ข้อ เล่าเป็นภาษาคน:

  1. URL หลุดแล้วเก็บคืนไม่ได้ — สไลด์ยกตัวอย่างว่า URL ไปโผล่ใน log · ใน referrer (หัวคำขอที่บอกเว็บปลายทางว่ามาจากหน้าไหน) · ในแชต · ของ public เปิดได้ตลอด · ของที่เซ็นแล้วหลุดไปก็ตายใน 5 นาที
  2. ลบในแอปแล้วแต่ไฟล์ยังเปิดได้ — ลบข้อมูลใน DB แล้วลืมลบไฟล์ = คนที่จำ URL ไว้ยังเปิดได้ตลอดกาล (หัวข้อท้ายบทจะเห็นว่าเรื่องนี้เกิดกับเราได้แม้ bucket ไม่ public)
  3. ของส่วนตัวกลายเป็นของสาธารณะเงียบ ๆ — วันหนึ่งมีคนอัปใบเสร็จ/บัตรประชาชนเข้า bucket เดียวกัน ไม่มีใครรู้ว่ามันเปิดให้โลกดู
  4. เครื่องมือค้นหาเก็บไปได้ — Google อาจเก็บไฟล์ที่ไม่ควรถูกค้นเจอไปขึ้นผลค้นหา

เทียบร้าน: bucket public = เปิดประตูห้องในโกดังทิ้งไว้ ใครรู้เลขช่องก็หยิบของได้ ไม่ต้องมีบัตร และไม่มีวันหมดเวลา

bucket ของเราเป็น private (บท 138) · ลองเปิดไฟล์เดียวกันแบบไม่มีลายเซ็น:

$ curl -s http://localhost:9000/wongnok-dev/16r6iq1cr83g24mawphb39a7j0oi
<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>AccessDenied</Code><Message>Access Denied.</Message><Key>16r6iq1cr83g24mawphb39a7j0oi</Key><BucketName>wongnok-dev</BucketName>…</Error>

ไม่มีบัตร = ไม่ได้เข้า · นี่คือสิ่งที่เราต้องการ

ขาเข้า — direct upload ดูของจริงใน Network

ทีนี้กลับทิศ: คนอัปรูป · บท 138 ใส่ direct_upload: true ไว้ในฟอร์ม · ผลของมันใน HTML ของหน้าแก้ไข:

$ curl -s localhost:3000/posts/1/edit | grep 'type="file"'
    <input class="file-input w-full mt-2" data-direct-upload-url="http://localhost:3000/rails/active_storage/direct_uploads" type="file" name="post[cover]" id="post_cover" />

data-direct-upload-url คือป้ายที่ ActiveStorage.start() (JavaScript ใน application.js บท 138) มองหา · ช่องไฟล์ที่มีป้ายนี้ ตอนกดบันทึก JavaScript จะดักฟอร์มไว้ก่อน → ไปขอใบอนุญาตที่ URL นี้ → ส่งไฟล์ขึ้น MinIO → แล้วค่อยปล่อยฟอร์มไป

ผมเปิด /posts/1/edit ใน Chrome · เปิด Network · เลือกรูป cover.jpg (1600×1000 ขนาด 618,479 ไบต์) · กดบันทึก · ได้คำขอตามลำดับนี้ (ตัดแถว mini-profiler-resources ออก — เป็นของ rack-mini-profiler เครื่องมือวัดความเร็วที่ติดมากับแอป บท 122 · URL ยาวตัดด้วย …):

#method · ปลายทางstatusอะไรวิ่ง
①POST localhost:3000/rails/active_storage/direct_uploads200เบราว์เซอร์ส่งข้อมูลของไฟล์ (ไม่ใช่ตัวไฟล์) · ได้ใบอนุญาตกลับมา
②OPTIONS localhost:9000/wongnok-dev/16r6iq…?X-Amz-…204เบราว์เซอร์ถาม MinIO ก่อนว่ายอมรับของจากหน้าเว็บ localhost:3000 ไหม (CORS)
③PUT localhost:9000/wongnok-dev/16r6iq…?X-Amz-…200ตัวไฟล์ 618,479 ไบต์ ไปหา MinIO ตรง ๆ
④POST localhost:3000/posts/1 (ฟอร์ม _method=patch)303 → /posts/1ฟอร์มที่มีหัวข้อ เนื้อหา และรหัสของรูป
⑤GET localhost:3000/posts/1 → GET …/blobs/redirect/… 302 → GET localhost:9000/…200หน้าบทความโหลดใหม่ แล้วรูปเดินทางขาออกแบบที่เพิ่งเรียน

สไลด์หน้า 11 วาดไว้ 3 ขั้น (ขอใบอนุญาต · คืน URL · ส่งไฟล์ตรง) = แถว ① กับ ③ · ของจริงมี ② (CORS) แทรก และ ④ ส่งฟอร์มตามหลัง · ดูทีละขั้น:

ขั้น ① ขอใบอนุญาต — ส่งแค่ "ข้อมูลของไฟล์"

สิ่งที่เบราว์เซอร์ส่งไป (กดแถว ① → แท็บ Payload):

{"blob":{"filename":"cover.jpg","content_type":"image/jpeg","byte_size":618479,"checksum":"hEZP+bedIshBmJnIpYWJig=="}}

สิ่งที่ Rails ตอบกลับ (แท็บ Response · จัดบรรทัดให้อ่านง่าย · ตัดท่อน attachable_sgid ที่ยาวออก):

{
    "id": 4,
    "key": "16r6iq1cr83g24mawphb39a7j0oi",
    "filename": "cover.jpg",
    "content_type": "image/jpeg",
    "metadata": {},
    "service_name": "minio",
    "byte_size": 618479,
    "checksum": "hEZP+bedIshBmJnIpYWJig==",
    "created_at": "2026-11-10T10:18:40.613+07:00",
    "attachable_sgid": "…",
    "signed_id": "eyJfcmFpbHMiOnsiZGF0YSI6NCwicHVyIjoiYmxvYl9pZCJ9fQ==--e48eee28ac016f73b5f68e117da8de5324704ad9",
    "direct_upload": {
        "url": "http://localhost:9000/wongnok-dev/16r6iq1cr83g24mawphb39a7j0oi?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20261110%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20261110T031840Z&X-Amz-Expires=300&X-Amz-SignedHeaders=content-length%3Bcontent-md5%3Bcontent-type%3Bhost&X-Amz-Signature=6d0a145959a4b155fcc93af4bac1034cec9fbdb6dff8bb2df7611afdf4838b19",
        "headers": {
            "Content-Type": "image/jpeg",
            "Content-MD5": "hEZP+bedIshBmJnIpYWJig==",
            "Content-Disposition": "inline; filename=\"cover.jpg\"; filename*=UTF-8''cover.jpg"
        }
    }
}

ขั้น ② ③ ส่งไฟล์ตรงไป MinIO — และเรื่อง CORS

หน้าเว็บอยู่ที่ localhost:3000 แต่จะส่งของไป localhost:9000 (คนละพอร์ต = คนละ "ที่มา" ในสายตาเบราว์เซอร์) · เบราว์เซอร์จึงยิง OPTIONS ไปถามก่อน (CORS แบบที่บท 39 ทำให้ Gin ตอบ) · ผมยิงคำถามเดียวกันด้วย curl ให้เห็นคำตอบของ MinIO:

$ curl -s -i -X OPTIONS "http://localhost:9000/wongnok-dev/16r6iq1cr83g24mawphb39a7j0oi" \
    -H "Origin: http://localhost:3000" -H "Access-Control-Request-Method: PUT" \
    -H "Access-Control-Request-Headers: content-disposition,content-md5,content-type"
HTTP/1.1 204 No Content
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: content-disposition,content-md5,content-type
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Origin: http://localhost:3000
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
Date: Tue, 10 Nov 2026 03:23:11 GMT

⚠️ MinIO ในคอร์สไม่ต้องตั้ง CORS — แต่ S3 ของจริงต้องตั้ง · MinIO ตัวนี้ตอบ "ยอม" ให้ทุกที่มา (ผมลอง Origin: http://example.com ก็ได้ 204) · ถ้าวันหนึ่งย้ายไป S3 ของ Amazon แล้วอัปรูปไม่ขึ้น เห็นแถว PUT แดงพร้อมคำว่า CORS ใน Console = ต้องไปตั้ง CORS ของ bucket ให้ยอม PUT กับหัว Content-Type · Content-MD5 · Content-Disposition (Rails Guides มีตัวอย่าง)

ขั้น ④ ส่งฟอร์ม — Rails ได้แค่รหัส

เนื้อของฟอร์มที่ส่งไป Rails ในแถว ④ (ตัดช่องหัวข้อกับเนื้อหาให้สั้น):

------WebKitFormBoundaryiYgQeghA911I9ZZ4
Content-Disposition: form-data; name="_method"

patch
…
Content-Disposition: form-data; name="post[cover]"

eyJfcmFpbHMiOnsiZGF0YSI6NCwicHVyIjoiYmxvYl9pZCJ9fQ==--e48eee28ac016f73b5f68e117da8de5324704ad9
…

บรรทัด ------WebKitFormBoundary… คือเส้นคั่นระหว่างช่องของฟอร์ม · แต่ละช่องมีบรรทัด name="…" บอกชื่อช่อง เว้นบรรทัด แล้วตามด้วยค่า

ช่อง post[cover] ไม่มีรูป · มีแค่ signed_id ตัวที่ได้จากขั้น ① · ฝั่ง PostsController#update รับไปแปะป้ายให้โพสต์ (Attachment Create ใน log) จบ · เทียบให้เห็นชัด: ผมลองปิด ActiveStorage.start() ชั่วคราว (JavaScript ไม่ดักฟอร์ม · ไฟล์เลยถูกส่งไปกับฟอร์มแบบปกติ) แล้วอัปรูปเดิม · log ของคำขอส่งฟอร์มสองแบบต่างกันแบบนี้:

❌ ไม่มี direct upload — ไฟล์วิ่งผ่าน Rails

Parameters: {… "cover" => #<ActionDispatch::Http::UploadedFile:0x0000000133ea9168 @tempfile=#<Tempfile:/var/folders/…/RackMultipart20261110-94567-txj1vu.jpg>, @content_type="image/jpeg", @original_filename="cover.jpg", …
S3 Storage (13.7ms) Uploaded file to key: ivhoccqk8o86gm81qbgcggyldl8s (checksum: hEZP+bedIshBmJnIpYWJig==)

#<ActionDispatch::Http::UploadedFile …> = ก้อนข้อมูลไฟล์ที่ Rails รับมา (Ruby เขียน object แบบนี้เวลาพิมพ์ออก log) · Rails ได้ไฟล์ทั้งก้อน (เก็บพักไว้ในไฟล์ชั่วคราว Tempfile) แล้วส่งต่อขึ้น MinIO เอง (Uploaded file) — คนรับคำขอติดงานนี้จนเสร็จ

✅ direct upload — Rails ได้แค่รหัส

Parameters: {… "cover" => "eyJfcmFpbHMiOnsiZGF0YSI6NCwicHVyIjoiYmxvYl9pZCJ9fQ==--e48eee28ac016f73b5f68e117da8de5324704ad9", "category_id" => "1"}, …
ActiveStorage::Attachment Create (0.9ms)  INSERT INTO "active_storage_attachments" ("name", "record_type", "record_id", "blob_id", "created_at") VALUES ('cover', 'Post', 1, 4, …)

cover เป็นตัวอักษรสั้น ๆ · ไม่มีบรรทัด Uploaded file · Rails แค่แปะป้าย "blob 4 เป็นรูปปกของโพสต์ 1"

ข้อสังเกตเดียวที่ Rails แตะไฟล์: หลังบันทึกเสร็จ log มี [ActiveJob] [ActiveStorage::AnalyzeJob] … S3 Storage (12.9ms) Downloaded file from key: 16r6iq… · นี่คืองานหลังร้าน (background job — งานที่ Rails ทำทีหลังโดยผู้ใช้ไม่ต้องยืนรอ · เรื่องของบท 142) ที่โหลดรูปไปอ่านความกว้าง-สูงหนึ่งครั้ง · ไม่ได้เกิดระหว่างที่ผู้ใช้รอหน้าเว็บ

🍜 เทียบกับร้านอาหาร

ลูกค้าอยากฝากของ · บอกพนักงานว่า "กล่องหนึ่งใบ หนัก 618 กรัม ตราประทับบนกล่องเป็นแบบนี้" · พนักงานเขียนใบรับฝากไว้ก่อน แล้วออกใบอนุญาตฝากของที่ระบุน้ำหนักและตราประทับไว้ · ลูกค้าแบกกล่องไปโกดังเอง · คนเฝ้าโกดังรับเฉพาะกล่องที่ตรงกับใบอนุญาต · ลูกค้ากลับมาที่ร้านยื่นแค่เลขใบรับฝาก พนักงานแปะป้าย "ของโต๊ะนี้" จบ

ที่ไม่เหมือนกัน: ร้านจริงคงไม่เขียนใบรับฝากก่อนเห็นของ · Active Storage เขียน blob ลงตารางตั้งแต่ขั้น ① · ถ้าลูกค้าเลือกไฟล์ กดบันทึก แล้วปิดแท็บก่อนส่งฟอร์มเสร็จ จะเหลือใบรับฝากที่ไม่มีป้ายแปะแฟ้มไหนเลย — "ไฟล์กำพร้า" ที่บท 140 จะตามเก็บ

🏷 ศัพท์ไว้คุยกับ senior / AI
preflight request
คำขอ OPTIONS ที่เบราว์เซอร์ยิงถามก่อนส่งของข้ามที่มา · "PUT ไป S3 พังเพราะ preflight ไม่ผ่าน ต้องตั้ง CORS ของ bucket"
checksum (MD5)
ลายนิ้วมือของไฟล์ ใช้ยืนยันว่าไฟล์ที่ถึงปลายทางตรงกับไฟล์ต้นทางทุกไบต์ · Active Storage ส่งเป็นหัว Content-MD5
signed_id
รหัส blob ที่ Rails เซ็นกันปลอมไว้ · ฟอร์มส่งตัวนี้แทนไฟล์ · พิมพ์บอก AI ได้ว่า "controller รับ signed_id จาก direct upload ไม่ต้องรับไฟล์"
multipart/form-data
รูปแบบเนื้อฟอร์มที่แบ่งเป็นช่อง ๆ คั่นด้วย ------WebKitFormBoundary… · ฟอร์มที่มีช่องไฟล์ใช้แบบนี้เสมอ แม้ direct upload จะทำให้ช่องไฟล์เหลือแค่ตัวอักษร

redirect vs proxy — บอกทางให้ไปรับเอง หรือเดินไปหยิบมาให้

ขาออกที่เราใช้อยู่เรียกว่า redirect mode (ค่าเริ่มต้น) · Active Storage มีอีกโหมดชื่อ proxy = Rails โหลดไฟล์จาก MinIO แล้วส่งต่อให้เบราว์เซอร์เอง · ฟังดูขัดกับประโยคของบท แต่มีที่ใช้ · ลองดูของจริงก่อน: แก้บรรทัดรูปใน app/views/posts/_post.html.erb ชั่วคราว

<%# ก่อน %>
<%= image_tag post.cover, alt: "", class: "…" if post.cover.attached? %>
<%# หลัง (ทดลอง) %>
<%= image_tag rails_storage_proxy_path(post.cover), alt: "", class: "…" if post.cover.attached? %>

rails_storage_proxy_path(…) = helper ที่ให้ URL แบบ proxy ของไฟล์นั้น · บันทึก แล้วดู <img> ใหม่:

$ curl -s localhost:3000/posts/1 | grep -o '<img[^>]*>'
<img alt="" class="w-full max-h-96 object-cover rounded-box" src="/rails/active_storage/blobs/proxy/eyJfcmFpbHMiOnsiZGF0YSI6NCwicHVyIjoiYmxvYl9pZCJ9fQ==--e48eee28ac016f73b5f68e117da8de5324704ad9/cover.jpg" />

redirect ใน URL เปลี่ยนเป็น proxy · signed_id ตัวเดิม · ขอ URL นี้ (-o /dev/null = ทิ้งเนื้อไฟล์ · -D - = โชว์หัวคำตอบ · -w = พิมพ์ขนาดที่โหลดได้ตอนจบ):

$ curl -s -o /dev/null -D - http://localhost:3000/rails/active_storage/blobs/proxy/eyJfcmFp…/cover.jpg -w 'downloaded %{size_download}\n'
HTTP/1.1 200 OK
last-modified: Fri, 31 Dec 2010 17:00:00 GMT
accept-ranges: bytes
content-type: image/jpeg
content-disposition: inline; filename="cover.jpg"; filename*=UTF-8''cover.jpg
cache-control: no-store, must-revalidate, private, max-age=0
…
x-miniprofiler-original-cache-control: max-age=3155695200, public, immutable
…
transfer-encoding: chunked

downloaded 618479

log ของคำขอนี้:

Started GET "/rails/active_storage/blobs/proxy/eyJfcmFp…/cover.jpg" for ::1 at …
Processing by ActiveStorage::Blobs::ProxyController#show as JPEG
  …
  S3 Storage (5.3ms) Downloaded file from key: 16r6iq1cr83g24mawphb39a7j0oi
Completed 200 OK in 8ms (ActiveRecord: 0.6ms (1 query, 0 cached) | GC: 0.0ms)

Downloaded file from key — ไฟล์วิ่งผ่าน Rails (MinIO → Rails → เบราว์เซอร์) · ในเครื่องเรา log บอกว่าใช้ 5.3 ms เพราะ MinIO อยู่เครื่องเดียวกัน · ของจริงที่ storage อยู่อีกที่และไฟล์ใหญ่ เวลานี้คือเวลาที่คนรับคำขอหนึ่งคนของ Rails ติดอยู่

แล้ว proxy มีไว้ทำไม? คำตอบอยู่ที่ public + 100 ปี · ถ้ามี CDN (บริการที่มีเครื่องเก็บสำเนาไฟล์กระจายอยู่ใกล้ผู้ใช้ทั่วโลก คั่นอยู่หน้าเว็บของเรา) · URL proxy คงที่ทุกครั้ง CDN เก็บสำเนาไว้แล้วตอบแทนเราได้นานตามที่ Rails บอก — คำขอมาถึง Rails เฉพาะตอนที่ CDN ยังไม่มีสำเนา · ส่วน URL ปลายทางของ redirect เปลี่ยนทุก 5 นาที (เห็นแล้วว่า X-Amz-Date ใหม่ทุกรอบ) และ 302 ของ Rails บอกว่า private · CDN ช่วยได้น้อย

redirect (ค่าเริ่มต้น)proxy
เขียนใน viewimage_tag post.coverimage_tag rails_storage_proxy_path(post.cover)
Rails ตอบ302 + URL ที่เซ็นแล้ว · content-length: 0200 + ตัวไฟล์ทั้งก้อน
ไฟล์ผ่าน Rails ไหมไม่ผ่าน (log ไม่มี Downloaded)ผ่าน (log มี Downloaded file from key)
cachemax-age=300, private · ปลายทางเปลี่ยนทุกรอบmax-age=3155695200, public, immutable · URL คงที่
เหมาะกับไฟล์ใหญ่ · ไฟล์ส่วนตัว · ไม่แน่ใจ = ใช้อันนี้รูปเล็กที่ทุกคนเห็นเหมือนกัน คนดูเยอะ และมี CDN คั่นหน้า

วิธีเลือกของสไลด์หน้า 11 สรุปได้ประโยคเดียว: เริ่มด้วย redirect · เปลี่ยนเป็น proxy เมื่อวัดได้แล้วว่ารูปเล็กยอดนิยมเป็นคอขวด และมี CDN · proxy ที่ไม่มี CDN = ให้ Rails แบกไฟล์ทุกครั้งเปล่า ๆ

🍜 เทียบกับร้านอาหาร

redirect = พนักงานบอกทางและให้บัตรผ่าน ลูกค้าไปรับของเองที่โกดัง · พนักงานว่างทันที · proxy = พนักงานเดินไปหยิบของจากโกดังมาให้ที่โต๊ะ · ลูกค้าสบาย แต่พนักงานหายไปจากหน้าร้านช่วงหนึ่ง · proxy จะคุ้มก็ต่อเมื่อมีตู้กดของหน้าร้าน (CDN) ที่พนักงานเติมของครั้งเดียว แล้วลูกค้าทุกคนกดเอาจากตู้

ที่ไม่เหมือนกัน: ตู้กดของจริงต้องมีคนเติมเรื่อย ๆ · CDN เก็บสำเนาไว้เองตามที่ Rails บอก (100 ปี) — ข้อเสียคือเอาออกยาก: ถ้าลบรูปในแอป สำเนาในตู้ยังอยู่จนกว่าจะสั่งตู้ให้ทิ้ง (หัวข้อถัดไป)

🏷 ศัพท์ไว้คุยกับ senior / AI
redirect mode · proxy mode
สองวิธีเสิร์ฟไฟล์ของ Active Storage · ถาม senior ได้ว่า "รูป avatar เราใช้ proxy mode หลัง CDN หรือ redirect?"
CDN (Content Delivery Network)
เครือข่ายเครื่องเก็บสำเนาไฟล์ใกล้ผู้ใช้ · "ใส่ CDN หน้า proxy ของ Active Storage เพื่อลดโหลดของแอป"
Cache-Control: public, immutable
หัวคำตอบที่บอกว่าใครก็เก็บสำเนาได้และไฟล์ไม่เปลี่ยน · คู่กับ private = เก็บได้เฉพาะเบราว์เซอร์ของผู้ใช้คนนั้น

คืนไฟล์ก่อนไปต่อ: git restore app/views/posts/_post.html.erb แล้ว git status --short ต้องว่าง

ลูกค้าขอให้ลบรูป — ต้องเช็คอะไรก่อนบอกว่า "ลบแล้ว"

สไลด์หน้า 13 (case 4): ลูกค้าโทรมาว่า "ช่วยลบรูปนี้ให้หน่อย ผมอัปผิด" · ข้อมูลรูปหนึ่งรูปอยู่หลายที่: แถว attachment · แถว blob · ไฟล์ใน MinIO · URL ที่เคยแจกออกไป · ลองลบของจริงด้วย purge (= ลบทั้งป้าย ใบรับฝาก และของในโกดัง) ใน bin/rails console · ก่อนลบ ผมเก็บ URL :9000 ที่เพิ่งเซ็น (ยังไม่หมด 5 นาที) ไว้ลองเปิดหลังลบ:

Loading development environment (Rails 8.1.4)
wongnok-blog-all-in-one(dev):001> post = Post.find(1)
  Post Load (0.6ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = 1 LIMIT 1
=> 
#<Post:0x00000001271518c8
...
wongnok-blog-all-in-one(dev):002> post.cover.purge
  ActiveStorage::Attachment Load (0.5ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = 1 AND "active_storage_attachments"."record_type" = 'Post' AND "active_storage_attachments"."name" = 'cover' LIMIT 1
  TRANSACTION (0.3ms)  BEGIN
  ActiveStorage::Attachment Destroy (0.7ms)  DELETE FROM "active_storage_attachments" WHERE "active_storage_attachments"."id" = 4
  Post Update (0.3ms)  UPDATE "posts" SET "updated_at" = '2026-11-10 03:24:32.275796' WHERE "posts"."id" = 1
  TRANSACTION (0.4ms)  COMMIT
  ActiveStorage::Blob Load (0.5ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 4 LIMIT 1
  TRANSACTION (0.3ms)  BEGIN
  ActiveStorage::Blob Destroy (0.5ms)  DELETE FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 4
  TRANSACTION (0.5ms)  COMMIT
  S3 Storage (6.6ms) Deleted file from key: 16r6iq1cr83g24mawphb39a7j0oi
  S3 Storage (2.5ms) Deleted files by key prefix: variants/16r6iq1cr83g24mawphb39a7j0oi/
=> nil
wongnok-blog-all-in-one(dev):003> post.cover.attached?
=> false
wongnok-blog-all-in-one(dev):004> exit

แล้ว URL ที่เคยแจกไป:

$ curl -s "http://localhost:9000/wongnok-dev/16r6iq1cr83g24mawphb39a7j0oi?…&X-Amz-Date=20261110T032423Z&X-Amz-Expires=300&…" -w '\n[%{http_code}]\n'
<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>NoSuchKey</Code><Message>The specified key does not exist.</Message><Key>16r6iq1cr83g24mawphb39a7j0oi</Key><BucketName>wongnok-dev</BucketName>…</Error>
[404]
$ curl -I http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NCwicHVyIjoiYmxvYl9pZCJ9fQ==--e48eee28ac016f73b5f68e117da8de5324704ad9/cover.jpg
HTTP/1.1 404 Not Found
…
$ curl -s -o /dev/null -w 'proxy %{http_code}\n' http://localhost:3000/rails/active_storage/blobs/proxy/eyJfcmFp…/cover.jpg
proxy 404

⚠️ detach ไม่ใช่การลบ — post.cover.detach ลบแค่ป้าย (attachment) · ผมลองกับอีกโพสต์แล้ว: ใบรับฝากกับไฟล์ใน MinIO ยังอยู่ · URL :3000 เดิมยังได้ 302 → รูป 200 · หน้าบทความไม่มีรูปแล้ว แต่ใครที่เก็บลิงก์ไว้ยังเปิดได้ — ลูกค้าจะคิดว่าลบแล้ว ทั้งที่ยังไม่ได้ลบ · เทียบร้าน: detach = ถอดป้ายออกจากแฟ้ม แต่ใบรับฝากกับของในโกดังยังอยู่ครบ · purge = ถอดป้าย ฉีกใบรับฝาก และเอาของออกจากโกดัง

เช็คลิสต์ก่อนตอบลูกค้า (คำตอบของ case 4):

  1. ใช้ purge (หรือ purge_later = ให้งานหลังร้านลบทีหลัง ผู้ใช้ไม่ต้องรอ) ไม่ใช่ detach — ดู log ให้เห็น Deleted file from key
  2. URL ที่เคยแจก — แบบ redirect ตายทันทีที่ไฟล์หาย (404) · ต่อให้ลืมลบไฟล์ URL :9000 ก็หมดเองใน 5 นาที แต่ URL :3000 ไม่หมด
  3. รูปย่อ (variant) — รูปขนาดเล็กที่ทำจากรูปเดิม (บท 140) เก็บไว้ใต้ variants/<key>/ · purge ลบให้ด้วย (บรรทัด Deleted files by key prefix)
  4. สำเนานอกโกดัง — ถ้าใช้ proxy + CDN สำเนาที่ CDN เก็บไว้ (Rails บอกให้เก็บ 100 ปี) ไม่หายตาม ต้องสั่งลบที่ CDN เอง · แท็บเบราว์เซอร์ที่เปิดค้างไว้ก็ยังเห็นรูปบนจอจนกว่าจะ refresh

🎯 ภารกิจ — พิสูจน์ด้วยตาว่าไฟล์ไม่ผ่าน Rails และบัตรแก้ไม่ได้ (~10 นาที)

ทำบนโค้ดตอนจบบท 138 (git status --short ว่าง) · bin/dev และ docker compose เปิดอยู่

  1. Chrome เปิด http://localhost:3000/posts/2/edit → DevTools → แท็บ Network → ติ๊ก Preserve log (ไม่ให้รายการหายตอนเปลี่ยนหน้า) → กดล้างรายการ 🚫
  2. เลือกรูปอะไรก็ได้ในช่อง "รูปปก" → กดบันทึก
  3. ในรายการ หาแถวที่ปลายทางเป็น localhost:9000 · ดูคอลัมน์ Size ของแถว direct_uploads เทียบกับขนาดไฟล์รูป
  4. ในเทอร์มินัล (โฟลเดอร์โปรเจกต์) ดูว่า Rails คุยกับ MinIO เรื่องอะไรบ้าง: grep "S3 Storage" log/development.log | tail -n 3 (grep = เลือกเฉพาะบรรทัดที่มีคำนี้จากไฟล์ log · tail -n 3 = เอาแค่ 3 บรรทัดสุดท้าย)
  5. บนหน้าบทความ คลิกขวาที่รูป → Open image in new tab → แถบที่อยู่จะเป็น localhost:9000/… แล้ว · แก้ X-Amz-Expires=300 เป็น 3000 แล้วกด Enter (แก้ URL ได้เฉพาะของแอปในเครื่องตัวเอง)

ก่อนกดเฉลย: มีกี่แถวที่ไป :9000 · แถวไหนพาไฟล์ไป · log มีคำว่า Uploaded ไหม · ข้อ 5 ได้อะไร?

  • แถวที่ไป :9000 ตอนอัป = 2 แถว: OPTIONS 204 (ถาม CORS) และ PUT 200 ที่พาไฟล์ไป · ตอนหน้าบทความโหลดจะมีอีกแถวคือ GET รูปจาก :9000
  • แถว direct_uploads เล็กมาก — ของผมคำขอเป็น JSON 118 ตัวอักษร (ชื่อ ชนิด ขนาด checksum) · คำตอบราว 2.4 kB รวมหัว · เพราะส่งแค่ข้อมูลของไฟล์ ขนาดแถวนี้จึงไม่ขึ้นกับขนาดรูป
  • log — บรรทัด S3 Storage ที่เกิดจากการอัปหนึ่งครั้งของผม (ตัดจาก log จริง · key ของคุณต่าง · ถ้าเปิดหน้าอื่นที่มีรูปหลังอัป จะมีบรรทัด Generated URL ใหม่ดันบรรทัดเหล่านี้ขึ้นไป ให้เปลี่ยนเป็น tail -n 10):
      S3 Storage (7.6ms) Generated URL for file at key: 16r6iq1cr83g24mawphb39a7j0oi (http://localhost:9000/wongnok-dev/16r6iq…?…)
    [ActiveJob] [ActiveStorage::AnalyzeJob] […]   S3 Storage (12.9ms) Downloaded file from key: 16r6iq1cr83g24mawphb39a7j0oi
      S3 Storage (1.1ms) Generated URL for file at key: 16r6iq1cr83g24mawphb39a7j0oi (http://localhost:9000/wongnok-dev/16r6iq…?…)
    บรรทัดแรก = เซ็นใบอนุญาต PUT (ขั้น ①) · บรรทัดสอง = งานหลังร้าน AnalyzeJob โหลดไปอ่านขนาดรูป · บรรทัดสาม = เซ็น URL ขาออกตอนหน้าบทความขอรูป · ไม่มี Uploaded file
  • ข้อ 5 — ได้ XML <Code>SignatureDoesNotMatch</Code> แทนรูป (ผลเดียวกับกล่องทายผลด้านบน) · แก้กลับเป็น 300 ก็เปิดได้อีกครั้ง ถ้ายังไม่เลย 5 นาที

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

  • ไม่มีแถว direct_uploads เลย แต่รูปก็ขึ้น · และ log มี Uploaded file to key — JavaScript ของ direct upload ไม่ทำงาน ไฟล์วิ่งผ่าน Rails (แบบฝั่ง ❌ ในหัวข้อ direct upload) · เช็คว่า application.js มี ActiveStorage.start() และ config/importmap.rb มี pin @rails/activestorage (บท 138) แล้วกด refresh หน้าแก้ไขใหม่
  • แก้กลับเป็น 300 แล้วได้ Request has expired — ปกติ · เลย 5 นาทีนับจากตอนที่หน้าบทความขอรูปแล้ว บัตรใบนั้นหมดอายุ · กลับไปหน้าบทความ refresh แล้วเปิดรูปใน tab ใหม่ จะได้บัตรใบใหม่
  • แถว PUT แดง และ Console มีคำว่า CORS — ไม่ได้ใช้ MinIO ของคอร์ส (เช่นไปต่อ S3 จริง) · ต้องตั้ง CORS ของ bucket ตามกล่องเตือนในหัวข้อ direct upload

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

(สไลด์หน้า 13 case 3) ไฟล์ 4 แบบ — ใช้ redirect · proxy หรือไม่ใช่งานของ Active Storage: ① รูป avatar 40×40 ที่โผล่ทุกหน้า ② PDF ใบเสร็จของลูกค้า ③ วิดีโอสอน 800 MB ④ โลโก้เว็บที่ไม่เคยเปลี่ยน

ตัวเลือกที่ 1 ถูก — avatar เล็ก ทุกคนเห็นเหมือนกัน คนดูเยอะ = งานของ proxy + CDN · ใบเสร็จเป็นของส่วนตัว ห้ามให้ CDN เก็บแบบ public = redirect (และยังต้องมีด่านตรวจสิทธิ์ เพราะ URL :3000 ใครได้ไปก็เปิดได้) · วิดีโอใหญ่ห้ามให้ Rails แบก = redirect · โลโก้เป็นส่วนหนึ่งของแอป ไม่ใช่ของที่ผู้ใช้อัป → ไฟล์ใน app/assets ที่มีเลขรุ่นต่อท้าย (บท 127)

ตัวเลือกที่ 2 — proxy ไม่ได้ "คุมสิทธิ์" ให้ · และวิดีโอ 800 MB ผ่าน proxy = คนรับคำขอของ Rails ติดอยู่นานมาก · ตัวเลือกที่ 3 — ใบเสร็จได้ public, immutable 100 ปี = ใครระหว่างทางก็เก็บสำเนาใบเสร็จได้ · ตัวเลือกที่ 4 — avatar เป็นงานที่ redirect ทำได้แต่ไม่ได้ช่วยเรื่อง CDN · และวิดีโอผ่าน Rails ผิดเหมือนตัวเลือกที่ 2

จุดที่ต้องจำ: ใหญ่หรือส่วนตัว → redirect · เล็ก ยอดนิยม มี CDN → proxy · เป็นส่วนของแอปเอง → asset ไม่ใช่ Active Storage

เพื่อนก็อป URL รูปปกจาก src ของหน้าบทความ (ขึ้นต้น http://localhost:3000/rails/active_storage/blobs/redirect/…) ส่งเข้ากลุ่มแชต · อีกวันมีคนกดเปิด จะเกิดอะไรขึ้น?

ตัวเลือกที่ 3 ถูก — URL :3000 มี urls_expire_in = nil (ไม่หมด) · ลองแล้ว: เปิดหลัง 5 นาทีได้ 302 ที่มี X-Amz-Date ใหม่

ตัวเลือกที่ 1 — ที่หมดใน 5 นาทีคือ URL :9000 หลัง redirect เท่านั้น · ตัวเลือกที่ 2 — ลายเซ็นไม่ผูกกับคน · ใครถือ URL ก็ใช้ได้ (และ signed_id ไม่ได้ถูกแก้) · ตัวเลือกที่ 4 — bucket เป็น private · เปิดไม่มีลายเซ็นได้ AccessDenied 403

จุดที่ต้องจำ: URL ของ Rails = ลิงก์ถาวร · URL ของ storage = บัตร 5 นาที · ไฟล์ส่วนตัวต้องมีด่านตรวจสิทธิ์ก่อนถึง URL ของ Rails

ฟอร์มที่มี direct_upload: true · ผู้ใช้เลือกรูป 5 MB แล้วกดบันทึก · ตอน PostsController#update ทำงาน Rails ได้อะไรมาในช่อง post[cover]?

ตัวเลือกที่ 2 ถูก — log จริง: "cover" => "eyJfcmFp…--e48e…" · ไฟล์ขึ้นไปก่อนในแถว PUT · ฟอร์มส่งแค่รหัสมาให้แปะป้าย

ตัวเลือกที่ 1 — คือแบบที่ไม่มี direct upload (UploadedFile + Tempfile + Uploaded file to key) · ตัวเลือกที่ 3 — URL ที่เซ็นแล้วอยู่ในคำตอบของขั้น ① และใช้ PUT ไม่ได้ส่งกลับมาในฟอร์ม · ตัวเลือกที่ 4 — ช่องยังอยู่ ไม่งั้น Rails จะไม่รู้ว่ารูปไหนเป็นของโพสต์ไหน

จุดที่ต้องจำ: direct upload = ไฟล์ไป storage · รหัสไป Rails

(case 4) ลูกค้าขอให้ลบรูปปก · ทีมรัน post.cover.detach แล้วเปิดหน้าบทความดู รูปหายแล้ว · บอกลูกค้าว่า "ลบแล้ว" ได้หรือยัง?

ตัวเลือกที่ 4 ถูก — ลองแล้ว: หลัง detach URL :3000 เดิมยังได้ 302 → รูป 200 · ต้อง purge (log ขึ้น Deleted file from key) แล้ว URL เดิมเป็น 404

ตัวเลือกที่ 1 — URL :3000 ไม่หมดอายุ · ลิงก์ที่ใครเก็บไว้ยังพาไปขอบัตรใบใหม่ได้ · ตัวเลือกที่ 2 — นั่นคือ purge · detach ลบแค่แถว attachment · ตัวเลือกที่ 3 — รอเท่าไรก็ไม่ช่วย เพราะไฟล์กับใบรับฝากยังอยู่

จุดที่ต้องจำ: ลบจริง = purge · แล้วเช็ค URL เก่า · variant · และสำเนาใน CDN ถ้าใช้ proxy

🧹 ก่อนไปบทถัดไป: git status --short ต้องว่าง (ถ้ายังเห็น _post.html.erb ให้ git restore app/views/posts/_post.html.erb) · รูปปกที่อัปในภารกิจเก็บไว้ได้ ไม่กระทบบทต่อไป · บท 140 จะใช้รูปเหล่านี้ทำรูปย่อ

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

หน้าสไลด์เขียนของจริง (Rails 8.1.4 · แอปของคอร์ส)ต้องสนใจไหม
10ตัวอย่าง URL https://minio/wongnok/abc123?X-Amz-Expires=300&X-Amz-Signature=9f2e...ของจริงยาวกว่ามาก มี 8 ท่อน (ตารางในหัวข้อ "อ่าน URL ที่เซ็นแล้ว") · path คือ /wongnok-dev/<key สุ่ม> ไม่ใช่ชื่อไฟล์ไม่ต้อง
10"Rails ไม่เคยแตะตัวไฟล์เลย"จริงสำหรับขาออกแบบ redirect · แต่หลังอัป งานหลังร้าน AnalyzeJob โหลดไฟล์หนึ่งครั้งไปอ่านขนาดรูป (log มี Downloaded file from key)ไม่ต้อง
10"ลบ attachment ใน DB แล้ว แต่ URL เก่ายังเปิดได้ตลอดกาล" (เป็นเหตุผลที่ไม่เปิด bucket public)bucket private ก็เจอได้: detach ลบแค่แถว attachment แล้ว URL :3000 เดิมยังเปิดได้ · ต้อง purgeสนใจ
11direct upload 3 ขั้น (ขอใบอนุญาต · คืน URL · ส่งไฟล์ตรง)ใน Network มี OPTIONS (CORS) ก่อน PUT และมีการส่งฟอร์มที่มีแค่ signed_id ตามหลัง · MinIO ในคอร์สไม่ต้องตั้ง CORS · S3 จริงต้องตั้งสนใจถ้าจะใช้ S3 จริง
11redirect: "CDN cache ยาก เพราะ URL ปลายทางเปลี่ยนทุกครั้ง" · proxy: "URL คงที่"URL :3000 คงที่ทั้งสองโหมด · ที่เปลี่ยนคือ URL :9000 หลัง 302 (X-Amz-Date ใหม่ทุกรอบ) และ 302 ของ Rails ติด privateไม่ต้อง
13case 3 · case 4 ไม่มีเฉลยcase 3 = quiz ข้อแรก · case 4 = หัวข้อ "ลูกค้าขอให้ลบรูป" (case 1–2 อยู่ในบท 137 และบท 140)ไม่ต้อง

อ้างอิง

ติดตรงไหน หรือผลของคุณต่างจากในบท — ก็อปผลจริงทั้งก้อน (ตัดลายเซ็นยาว ๆ ออกได้) ไปถาม AI หรือเพื่อนได้เลย โดยบอกว่ามาจากบท 139 ขั้นไหน