🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้
- object storage · bucket · MinIO (บท 138) — โกดังรับฝากของนอกร้าน · bucket
wongnok-devคือห้องหนึ่งในโกดัง ตั้งเป็น private (ห้ามคนนอกเข้า) · MinIO พูด "ภาษาเดียวกับ S3 ของ Amazon" - blob · attachment (บท 138) — blob = ใบรับฝาก (ชื่อไฟล์ ขนาด ชนิด และ
key= เลขช่องเก็บในโกดัง) · attachment = ป้ายว่าของชิ้นนี้ของแฟ้มไหน (โพสต์เลขอะไร ช่องcover) direct_upload: true·ActiveStorage.start()(บท 138) — สองบรรทัดที่เปิดไว้ในฟอร์มกับapplication.js· บทนี้ดูว่ามันทำอะไรจริง- 302 ·
location:(บท 102) — คำตอบที่บอกว่า "ของไม่อยู่ที่นี่ ไปเอาที่location" · เบราว์เซอร์เดินตามเอง ·curlไม่เดินตาม - DevTools แท็บ Network (บท 130) — รายการทุกคำขอที่เบราว์เซอร์ยิงออกไป · CORS (บท 39 ฝั่ง Go) — เบราว์เซอร์ถามเว็บปลายทางก่อนว่า "ยอมให้หน้าเว็บจากที่อื่นส่งของมาไหม"
🧰 เวอร์ชันที่ใช้ตรวจ: 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 เอง · ใช้สองทิศ:
ร้านของเราฝากของไว้ที่โกดังรับฝากของนอกร้าน (MinIO) · ถ้าให้พนักงานเดินไปแบกของจากโกดังมาให้ลูกค้าทุกครั้ง หน้าร้านจะไม่มีคนรับออเดอร์ · ร้านจึงทำแค่ออกบัตรผ่านที่มีลายเซ็นของร้านให้ลูกค้าเดินไปที่โกดังเอง · คนเฝ้าโกดังตรวจลายเซ็นบนบัตรได้เองโดยไม่ต้องโทรถามร้าน · บัตรเขียนไว้ว่าใช้ได้ 5 นาที และใช้กับของชิ้นเดียวที่ระบุไว้
ที่ไม่เหมือนกัน: บัตรผ่านจริงมักมีชื่อคนถือ คนเฝ้าดูหน้าก่อนให้เข้า · URL ที่เซ็นแล้วไม่มีชื่อใคร — ใครถือ URL ก็เปิดได้ภายใน 5 นาที (ส่งต่อในแชตก็เปิดได้) · และ "ตรวจลายเซ็นเอง" ของ MinIO คือการคำนวณทางคณิตศาสตร์ด้วยรหัสลับที่ร้านกับโกดังรู้ร่วมกัน ไม่ใช่การดูลายมือ
เทียบของที่รู้: ถ้าเคยทำ JWT ใน บท 49 (Go) — คือไอเดียเดียวกัน: ข้อมูลที่เซ็นแล้ว + มีเวลาหมดอายุ ฝั่งที่รับตรวจลายเซ็นเองได้โดยไม่ต้องถามฐานข้อมูล · ต่างกันแค่ JWT เป็น "บัตรที่บอกว่าคุณเป็นใคร" ส่วน URL ที่เซ็นแล้วเป็น "บัตรที่บอกว่าเปิดไฟล์นี้ได้"
- 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/active_storage/blobs/redirect/— ทางเข้า (route) ที่ Active Storage เพิ่มให้แอปเองตั้งแต่ติดตั้ง · คำว่าredirectบอกว่าทางนี้จะส่ง 302eyJfcmFp…fQ==— ข้อมูลที่เข้ารหัสแบบ base64 (วิธีเขียนข้อมูลให้เป็นตัวอักษรล้วน ถอดกลับได้ ไม่ใช่การล็อก) · ลองถอดดู:
($ echo 'eyJfcmFpbHMiOnsiZGF0YSI6NCwicHVyIjoiYmxvYl9pZCJ9fQ==' | base64 -d {"_rails":{"data":4,"pur":"blob_id"}}|= ส่งข้อความต่อให้คำสั่งถัดไป ·base64 -d= ถอดกลับ) · ได้ว่า "blob เลข 4 · ใช้เป็นรหัส blob" · ใครก็ถอดได้--e48eee28…— ลายเซ็นของ Rails (คำนวณจากรหัสลับของแอป) · ถ้าใครแก้ 4 เป็น 5 เพื่อแอบดูไฟล์ของคนอื่น ลายเซ็นจะไม่ตรง Rails ไม่ยอม (ลองแล้ว: แก้เลขแล้วขอ URL นั้น ได้404 Not Found· log บอก0 queries= ลายเซ็นไม่ผ่านตั้งแต่ด่านแรก ไม่ได้ไปค้นตารางด้วยซ้ำ) · ทั้งก้อนสองท่อนนี้ Rails เรียกว่าsigned_idcover.jpg— ชื่อไฟล์ติดท้ายไว้ให้คนอ่านรู้เรื่อง · Rails ไม่ได้ใช้หาไฟล์
ขาออก ② — 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
302 Found+location: http://localhost:9000/…— "รูปไม่ได้อยู่ที่ผม ไปเอาที่ MinIO ตามที่อยู่นี้" · เบราว์เซอร์เดินตามเอง ผู้ใช้ไม่เห็นอะไรเปลี่ยนcontent-length: 0— คำตอบของ Rails ไม่มีตัวไฟล์เลยสักไบต์cache-control: max-age=300, private— บอกเบราว์เซอร์ว่า "จำทางนี้ไว้ได้ 300 วินาที (5 นาที) · เก็บไว้ในเครื่องของคุณเท่านั้น" · ตัวเลข 300 นี้จะโผล่อีกรอบในหัวข้อถัดไป
ฝั่ง 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)
Blob Load … "id" = 4— เปิดใบรับฝากเลข 4 (เลขที่ได้จากsigned_id) เพื่อดูkey= เลขช่องเก็บในโกดังS3 Storage (0.6ms) Generated URL— เซ็น URL ใช้เวลาไม่ถึง 1 ms · เป็นการคำนวณในเครื่อง ไม่ได้คุยกับ MinIO เลย- ไม่มีบรรทัด
Downloaded file— Rails ไม่ได้โหลดรูป · ตรงกับประโยคในสไลด์หน้า 10 "Rails ไม่เคยแตะตัวไฟล์เลย — มันแค่เซ็นชื่อแล้วชี้ทาง"
อ่าน 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 ของไฟล์ไปหนึ่งตัวอักษร (หวังแอบเปิดไฟล์ข้าง ๆ) → ได้
SignatureDoesNotMatch403 เหมือนกัน
สรุป: คนถือ URL แก้อะไรไม่ได้เลย ทำได้อย่างเดียวคือใช้ตามที่ร้านออกให้
⚠️ minioadmin โผล่ในทุก URL ของรูป — ส่วนนั้นคือชื่อผู้ใช้ ไม่ใช่รหัสลับ จึงยังปลอดภัย ·
แต่เป็นเหตุผลอีกข้อที่ production (เว็บตัวจริงที่คนใช้) ไม่ควรใช้ผู้ใช้ root ของโกดัง (บัญชีแม่ที่ทำได้ทุกอย่าง) · ควรสร้าง access key แยกที่มีสิทธิ์แค่ bucket เดียว (บท 138)
- 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
service_urls_expire_in→5 minutes— อายุของ URL ฝั่ง MinIO (ตัว:9000) · เป็นค่าเริ่มต้นที่ Active Storage ใส่ให้เองเมื่อเราไม่ได้ตั้ง · อยากเปลี่ยนให้เขียนconfig.active_storage.service_urls_expire_in = 1.hourในconfig/environments/…(บทนี้ไม่ต้องทำ)urls_expire_in→nil— อายุของ URL ฝั่ง Rails (ตัว:3000ในsrc) ·nil= ไม่หมดอายุ
ลองของจริง: เก็บ URL ของ :9000 ที่เซ็นตอน 10:18:55 ไว้ในตัวแปรของ shell (URL="http://localhost:9000/wongnok-dev/…" ทั้งเส้น) แล้วเปิดซ้ำตอนใกล้ครบ 5 นาที กับตอนเลย 5 นาที ·
(ไม่ต้องรอตามก็ได้ อ่านผลด้านล่างพอ)
date +%T;= พิมพ์เวลาตอนนั้น ·;= จบคำสั่งนี้แล้วรันคำสั่งถัดไปต่อเลย-o /dev/null= ทิ้งเนื้อไฟล์ ไม่พิมพ์ออกจอ ·-w '%{http_code} %{content_type} %{size_download}\n'= ให้ curl พิมพ์ status · ชนิดไฟล์ · จำนวนไบต์ที่ได้-i= โชว์หัวคำตอบก่อนเนื้อ
$ 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>
- 4 นาที 50 วินาที →
200 image/jpeg 618479(ได้รูปครบ 618,479 ไบต์) - เลย 5 นาที →
403+<Code>AccessDenied</Code><Message>Request has expired</Message>· ลายเซ็นยังถูก แต่บัตรหมดเวลาแล้ว · ในเบราว์เซอร์จะเห็นข้อความ XML ก้อนนี้แทนรูป Server: Silo— ชื่อโปรแกรมที่ตอบ · คือ MinIO ตัวที่คอร์สใช้ (imagepgsty/silo) ไม่ใช่ของแปลกปลอม
แล้วหน้าบทความล่ะ — คนที่เปิดหน้าเว็บหลัง 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 ไม่ดูหน้าใครเลย ใครถามมาด้วยป้ายที่ถูกต้องก็ออกบัตรให้ทันที — นั่นคือเหตุผลของกล่องเตือนด้านบน
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 ข้อ เล่าเป็นภาษาคน:
- URL หลุดแล้วเก็บคืนไม่ได้ — สไลด์ยกตัวอย่างว่า URL ไปโผล่ใน log · ใน referrer (หัวคำขอที่บอกเว็บปลายทางว่ามาจากหน้าไหน) · ในแชต · ของ public เปิดได้ตลอด · ของที่เซ็นแล้วหลุดไปก็ตายใน 5 นาที
- ลบในแอปแล้วแต่ไฟล์ยังเปิดได้ — ลบข้อมูลใน DB แล้วลืมลบไฟล์ = คนที่จำ URL ไว้ยังเปิดได้ตลอดกาล (หัวข้อท้ายบทจะเห็นว่าเรื่องนี้เกิดกับเราได้แม้ bucket ไม่ public)
- ของส่วนตัวกลายเป็นของสาธารณะเงียบ ๆ — วันหนึ่งมีคนอัปใบเสร็จ/บัตรประชาชนเข้า bucket เดียวกัน ไม่มีใครรู้ว่ามันเปิดให้โลกดู
- เครื่องมือค้นหาเก็บไปได้ — 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_uploads | 200 | เบราว์เซอร์ส่งข้อมูลของไฟล์ (ไม่ใช่ตัวไฟล์) · ได้ใบอนุญาตกลับมา |
| ② | 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 | หน้าบทความโหลดใหม่ แล้วรูปเดินทางขาออกแบบที่เพิ่งเรียน |
- แถว ④ เป็น
POSTที่มีช่อง_method=patch— ฟอร์ม HTML ส่งได้แค่ GET กับ POST · Rails เลยแนบช่องลับ_methodบอกว่า "จริง ๆ คือ PATCH (แก้ข้อมูล)" 303— ญาติของ 302 (บท 118): "บันทึกแล้ว ไปเปิดหน้า/posts/1ต่อ" · เบราว์เซอร์จึงโหลดหน้าบทความในแถว ⑤
สไลด์หน้า 11 วาดไว้ 3 ขั้น (ขอใบอนุญาต · คืน URL · ส่งไฟล์ตรง) = แถว ① กับ ③ · ของจริงมี ② (CORS) แทรก และ ④ ส่งฟอร์มตามหลัง · ดูทีละขั้น:
ขั้น ① ขอใบอนุญาต — ส่งแค่ "ข้อมูลของไฟล์"
สิ่งที่เบราว์เซอร์ส่งไป (กดแถว ① → แท็บ Payload):
{"blob":{"filename":"cover.jpg","content_type":"image/jpeg","byte_size":618479,"checksum":"hEZP+bedIshBmJnIpYWJig=="}}
filename·byte_size— ชื่อและขนาดไฟล์ (ไบต์)content_type— ชนิดไฟล์ที่เบราว์เซอร์บอกเอง (ดูจากนามสกุล .jpg) · Rails ไม่ได้เห็นไฟล์จึงตรวจไม่ได้ว่าจริงไหม — จำไว้ บท 140 จะจับเรื่องนี้checksum— ลายนิ้วมือของไฟล์ (ค่า MD5 ที่ JavaScript คำนวณจากเนื้อไฟล์ทั้งก้อน) · ไฟล์ต่างกันแค่ไบต์เดียว ลายนิ้วมือก็ต่าง
สิ่งที่ 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"
}
}
}
"id": 4·"key": "16r6iq…"— Rails เขียนใบรับฝาก (blob) ลงตารางแล้ว ตั้งแต่ขั้นนี้ ทั้งที่ไฟล์ยังไม่มาถึงโกดัง · ใน log เห็นActiveStorage::Blob Create … INSERT INTO "active_storage_blobs"ของคำขอนี้"metadata": {}— ข้อมูลเสริมของไฟล์ (เช่นความกว้าง-สูงของรูป) · ตอนนี้ยังว่าง เพราะ Rails ยังไม่เห็นไฟล์ · จะถูกเติมทีหลัง (ดูย่อหน้า AnalyzeJob ด้านล่าง)"service_name": "minio"— เก็บที่ storage ชื่อminioในconfig/storage.yml(บท 138) ·attachable_sgidที่ตัดออกเป็นรหัสอีกแบบไว้แปะรูปในเนื้อหา rich text ไม่ต้องสนใจ"signed_id"— ตัวเดียวกับที่เห็นในsrcของรูป · เดี๋ยวฟอร์มจะส่งตัวนี้"direct_upload" → "url"— ใบอนุญาต = URL ที่เซ็นแล้วแบบเดียวกับขาออก แต่ใช้สำหรับPUT(ส่งของเข้า) · หมดใน 300 วินาทีเหมือนกันX-Amz-SignedHeaders=content-length;content-md5;content-type;host— ต่างจากขาออกตรงนี้: ลายเซ็นครอบขนาดไฟล์ ลายนิ้วมือ และชนิดไฟล์ไว้ด้วย · เอาใบอนุญาตนี้ไปส่งไฟล์อื่นไม่ได้ · ผมลองส่งไฟล์ขนาดเท่ากันแต่เนื้อต่างกันด้วยใบอนุญาตแบบนี้ → MinIO ตอบ400ไม่รับ · ส่งไฟล์ที่ตรง →200"headers"— หัวคำขอที่ JavaScript ต้องแนบตอน PUT ให้ตรงกับที่เซ็นไว้
ขั้น ② ③ ส่งไฟล์ตรงไป 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
-X OPTIONS= ใช้ method OPTIONS ·-H= แนบหัวคำขอ (สามบรรทัดนี้ลอกจากที่ Chrome ส่งจริง: "ผมมาจากlocalhost:3000· จะPUT· พร้อมหัวพวกนี้") ·\ท้ายบรรทัด = คำสั่งยังไม่จบ ต่อบรรทัดถัดไปAccess-Control-Allow-Origin: http://localhost:3000+Allow-Methods: PUT— "ยอม · ส่งมาได้" → เบราว์เซอร์จึงยิงPUTตัวจริงต่อ (แถว ③ ได้ 200) · บรรทัดที่เหลือ (Allow-Credentials·Vary·Date) ข้ามได้
⚠️ 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 จะตามเก็บ
- 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
200 OK+downloaded 618479— ไม่มี 302 · Rails ส่งรูปให้เองครบทุกไบต์last-modified·accept-ranges·transfer-encoding— หัวมาตรฐานของการส่งไฟล์ ข้ามได้ · ที่ต้องดูคือสองบรรทัด cache ด้านล่างx-miniprofiler-original-cache-control: max-age=3155695200, public, immutable— หัวที่ Rails ตั้งใจส่ง: เก็บไว้ได้ 3,155,695,200 วินาที (= 100 ปี) ·public= ใครระหว่างทางก็เก็บสำเนาได้ ·immutable= ไฟล์นี้ไม่มีวันเปลี่ยนcache-control: no-store, …— ใน development rack-mini-profiler (บท 122) เขียนทับเป็น "ห้ามเก็บ" แล้วย้ายค่าเดิมไปไว้ในหัวx-miniprofiler-original-…· production ไม่มี mini-profiler หัวจริงคือบรรทัดข้างบน
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 | |
|---|---|---|
| เขียนใน view | image_tag post.cover | image_tag rails_storage_proxy_path(post.cover) |
| Rails ตอบ | 302 + URL ที่เซ็นแล้ว · content-length: 0 | 200 + ตัวไฟล์ทั้งก้อน |
| ไฟล์ผ่าน Rails ไหม | ไม่ผ่าน (log ไม่มี Downloaded) | ผ่าน (log มี Downloaded file from key) |
| cache | max-age=300, private · ปลายทางเปลี่ยนทุกรอบ | max-age=3155695200, public, immutable · URL คงที่ |
| เหมาะกับ | ไฟล์ใหญ่ · ไฟล์ส่วนตัว · ไม่แน่ใจ = ใช้อันนี้ | รูปเล็กที่ทุกคนเห็นเหมือนกัน คนดูเยอะ และมี CDN คั่นหน้า |
วิธีเลือกของสไลด์หน้า 11 สรุปได้ประโยคเดียว: เริ่มด้วย redirect · เปลี่ยนเป็น proxy เมื่อวัดได้แล้วว่ารูปเล็กยอดนิยมเป็นคอขวด และมี CDN · proxy ที่ไม่มี CDN = ให้ Rails แบกไฟล์ทุกครั้งเปล่า ๆ
redirect = พนักงานบอกทางและให้บัตรผ่าน ลูกค้าไปรับของเองที่โกดัง · พนักงานว่างทันที · proxy = พนักงานเดินไปหยิบของจากโกดังมาให้ที่โต๊ะ · ลูกค้าสบาย แต่พนักงานหายไปจากหน้าร้านช่วงหนึ่ง · proxy จะคุ้มก็ต่อเมื่อมีตู้กดของหน้าร้าน (CDN) ที่พนักงานเติมของครั้งเดียว แล้วลูกค้าทุกคนกดเอาจากตู้
ที่ไม่เหมือนกัน: ตู้กดของจริงต้องมีคนเติมเรื่อย ๆ · CDN เก็บสำเนาไว้เองตามที่ Rails บอก (100 ปี) — ข้อเสียคือเอาออกยาก: ถ้าลบรูปในแอป สำเนาในตู้ยังอยู่จนกว่าจะสั่งตู้ให้ทิ้ง (หัวข้อถัดไป)
- 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
...ใต้#<Post:0x…— console ย่อรายละเอียดของโพสต์ไว้ ข้ามได้ · ที่ต้องดูคือคำสั่งที่สอง · (ผมตัดคอมเมนต์/*application='WongnokBlogAllInOne'*/ที่ต่อท้าย SQL ทุกบรรทัดออกให้สั้นลง ของคุณจะเห็นอยู่)Attachment Destroy— ลบป้าย (แถวในactive_storage_attachments) · ถัดมาPost Update … "updated_at"= Rails แตะเวลาแก้ล่าสุดของโพสต์ให้ด้วยBlob Destroy— ลบใบรับฝาก (แถวในactive_storage_blobs) · ผมตัดบรรทัด SELECT ที่ Rails เช็คของเกี่ยวข้องก่อนลบออกไป 3 บรรทัดS3 Storage … Deleted file from key— ลบของในโกดัง · ทำหลังCOMMIT(ลบในตารางสำเร็จแล้วค่อยลบไฟล์)Deleted files by key prefix: variants/16r6iq…/— ลบรูปย่อทุกขนาดที่เคยทำจากรูปนี้ด้วย (ตอนนี้ยังไม่มี · บท 140 จะเริ่มทำ)post.cover.attached?→false— โพสต์ไม่มีรูปปกแล้ว
แล้ว 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
- URL
:9000ที่ยังไม่หมดอายุ →NoSuchKey404 — ลายเซ็นยังถูก แต่ช่องเก็บว่างแล้ว · บัตรผ่านใช้ได้ แต่ไม่มีของให้รับ - URL
:3000ทั้ง redirect และ proxy →404— Rails หาใบรับฝากเลขนั้นไม่เจอแล้ว
⚠️ detach ไม่ใช่การลบ — post.cover.detach ลบแค่ป้าย (attachment) · ผมลองกับอีกโพสต์แล้ว: ใบรับฝากกับไฟล์ใน MinIO ยังอยู่ ·
URL :3000 เดิมยังได้ 302 → รูป 200 · หน้าบทความไม่มีรูปแล้ว แต่ใครที่เก็บลิงก์ไว้ยังเปิดได้ — ลูกค้าจะคิดว่าลบแล้ว ทั้งที่ยังไม่ได้ลบ ·
เทียบร้าน: detach = ถอดป้ายออกจากแฟ้ม แต่ใบรับฝากกับของในโกดังยังอยู่ครบ · purge = ถอดป้าย ฉีกใบรับฝาก และเอาของออกจากโกดัง
เช็คลิสต์ก่อนตอบลูกค้า (คำตอบของ case 4):
- ใช้
purge(หรือpurge_later= ให้งานหลังร้านลบทีหลัง ผู้ใช้ไม่ต้องรอ) ไม่ใช่detach— ดู log ให้เห็นDeleted file from key - URL ที่เคยแจก — แบบ redirect ตายทันทีที่ไฟล์หาย (404) · ต่อให้ลืมลบไฟล์ URL
:9000ก็หมดเองใน 5 นาที แต่ URL:3000ไม่หมด - รูปย่อ (variant) — รูปขนาดเล็กที่ทำจากรูปเดิม (บท 140) เก็บไว้ใต้
variants/<key>/·purgeลบให้ด้วย (บรรทัดDeleted files by key prefix) - สำเนานอกโกดัง — ถ้าใช้ proxy + CDN สำเนาที่ CDN เก็บไว้ (Rails บอกให้เก็บ 100 ปี) ไม่หายตาม ต้องสั่งลบที่ CDN เอง · แท็บเบราว์เซอร์ที่เปิดค้างไว้ก็ยังเห็นรูปบนจอจนกว่าจะ refresh
🎯 ภารกิจ — พิสูจน์ด้วยตาว่าไฟล์ไม่ผ่าน Rails และบัตรแก้ไม่ได้ (~10 นาที)
ทำบนโค้ดตอนจบบท 138 (git status --short ว่าง) · bin/dev และ docker compose เปิดอยู่
- Chrome เปิด
http://localhost:3000/posts/2/edit→ DevTools → แท็บ Network → ติ๊ก Preserve log (ไม่ให้รายการหายตอนเปลี่ยนหน้า) → กดล้างรายการ 🚫 - เลือกรูปอะไรก็ได้ในช่อง "รูปปก" → กดบันทึก
- ในรายการ หาแถวที่ปลายทางเป็น
localhost:9000· ดูคอลัมน์ Size ของแถวdirect_uploadsเทียบกับขนาดไฟล์รูป - ในเทอร์มินัล (โฟลเดอร์โปรเจกต์) ดูว่า Rails คุยกับ MinIO เรื่องอะไรบ้าง:
grep "S3 Storage" log/development.log | tail -n 3(grep= เลือกเฉพาะบรรทัดที่มีคำนี้จากไฟล์ log ·tail -n 3= เอาแค่ 3 บรรทัดสุดท้าย) - บนหน้าบทความ คลิกขวาที่รูป → Open image in new tab → แถบที่อยู่จะเป็น
localhost:9000/…แล้ว · แก้X-Amz-Expires=300เป็น3000แล้วกด Enter (แก้ URL ได้เฉพาะของแอปในเครื่องตัวเอง)
ก่อนกดเฉลย: มีกี่แถวที่ไป :9000 · แถวไหนพาไฟล์ไป · log มีคำว่า Uploaded ไหม · ข้อ 5 ได้อะไร?
- แถวที่ไป
:9000ตอนอัป = 2 แถว:OPTIONS204 (ถาม CORS) และPUT200 ที่พาไฟล์ไป · ตอนหน้าบทความโหลดจะมีอีกแถวคือGETรูปจาก:9000 - แถว
direct_uploadsเล็กมาก — ของผมคำขอเป็น JSON 118 ตัวอักษร (ชื่อ ชนิด ขนาด checksum) · คำตอบราว 2.4 kB รวมหัว · เพราะส่งแค่ข้อมูลของไฟล์ ขนาดแถวนี้จึงไม่ขึ้นกับขนาดรูป - log — บรรทัด
S3 Storageที่เกิดจากการอัปหนึ่งครั้งของผม (ตัดจาก log จริง · key ของคุณต่าง · ถ้าเปิดหน้าอื่นที่มีรูปหลังอัป จะมีบรรทัดGenerated URLใหม่ดันบรรทัดเหล่านี้ขึ้นไป ให้เปลี่ยนเป็นtail -n 10):
บรรทัดแรก = เซ็นใบอนุญาต PUT (ขั้น ①) · บรรทัดสอง = งานหลังร้าน AnalyzeJob โหลดไปอ่านขนาดรูป · บรรทัดสาม = เซ็น URL ขาออกตอนหน้าบทความขอรูป · ไม่มี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…?…)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 | สนใจ |
| 11 | direct upload 3 ขั้น (ขอใบอนุญาต · คืน URL · ส่งไฟล์ตรง) | ใน Network มี OPTIONS (CORS) ก่อน PUT และมีการส่งฟอร์มที่มีแค่ signed_id ตามหลัง · MinIO ในคอร์สไม่ต้องตั้ง CORS · S3 จริงต้องตั้ง | สนใจถ้าจะใช้ S3 จริง |
| 11 | redirect: "CDN cache ยาก เพราะ URL ปลายทางเปลี่ยนทุกครั้ง" · proxy: "URL คงที่" | URL :3000 คงที่ทั้งสองโหมด · ที่เปลี่ยนคือ URL :9000 หลัง 302 (X-Amz-Date ใหม่ทุกรอบ) และ 302 ของ Rails ติด private | ไม่ต้อง |
| 13 | case 3 · case 4 ไม่มีเฉลย | case 3 = quiz ข้อแรก · case 4 = หัวข้อ "ลูกค้าขอให้ลบรูป" (case 1–2 อยู่ในบท 137 และบท 140) | ไม่ต้อง |
อ้างอิง
- Rails Guides — Active Storage Overview § Redirect Mode —
อ่านหัวข้อนี้ต่อด้วย Proxy Mode และ Putting a CDN in Front of Active Storage (สั้นทั้งสาม) · ที่มาของ
rails_storage_proxy_pathและเหตุผลว่าทำไม CDN ต้องใช้ proxy - Rails Guides — Active Storage Overview § Direct Uploads —
อ่านหัวข้อย่อย Usage — บรรทัด
pin "@rails/activestorage"กับdirect_upload: trueคือของที่บท 138 ใส่ไว้ · ถัดไปคือหัวข้อ CORS ที่บอกว่า S3 ต้องยอมหัวอะไรบ้าง - Rails Guides — Active Storage Overview § Removing Files —
ย่อหน้าเดียว ·
purgeกับpurge_later· หน้านี้ไม่พูดถึงdetachเลย → ดูข้อถัดไป - Rails API — ActiveStorage::Attached::One#detach —
อ่านคำอธิบายของ
detachเทียบกับpurgeที่อยู่ถัดลงมา · สองบรรทัดที่บอกว่าอันไหนลบไฟล์จริง - Rails Guides — Configuring Rails § config.active_storage.service_urls_expire_in —
ค่าเริ่มต้น 5 นาที · ดูข้อถัดไปด้วย (
urls_expire_in) ที่อธิบายว่าทำไม URL ของ Rails ไม่หมด - rails/rails v8.1.4 — redirect_controller.rb — ไฟล์สั้นราว 30 บรรทัด · อ่านคอมเมนต์ด้านบน: URL ถาวรโดยตั้งใจ และคำเตือนว่า controller ของ Active Storage เปิดให้ทุกคนเข้า
- MDN — Cross-Origin Resource Sharing (CORS) —
อ่านหัวข้อ Preflighted requests · คือแถว
OPTIONSที่เห็นก่อนPUT - MDN Glossary — CDN — นิยามสั้นสามบรรทัด
ติดตรงไหน หรือผลของคุณต่างจากในบท — ก็อปผลจริงทั้งก้อน (ตัดลายเซ็นยาว ๆ ออกได้) ไปถาม AI หรือเพื่อนได้เลย โดยบอกว่ามาจากบท 139 ขั้นไหน