ข้ามไปยังเนื้อหา

Storage: Buckets and Policies

Supabase Storage คือ object storage แบบ S3-compatible ที่จัดระเบียบเป็น bucket และการเข้าถึงทุกอ็อบเจกต์ถูกบังคับโดย Row Level Security policy ธรรมดาบนตาราง Postgres — กลไกเดียวกับที่คุณรู้จักอยู่แล้วจากโมดูล RLS ไม่ใช่ระบบสิทธิ์แยกต่างหาก

bucket คือ container ระดับบนสุดสำหรับไฟล์ คล้าย S3 bucket bucket หนึ่งตั้งเป็น public ได้ หมายความว่าใครก็ตามที่มี URL ของอ็อบเจกต์นั้นอ่านได้โดยไม่ต้อง authenticate หรือตั้งเป็น private หมายความว่าการเข้าถึงถูกเช็คต่อ request bucket ก็แค่แถวหนึ่งใน storage.buckets ดังนั้นสร้างได้ทั้งด้วย SQL ตรง ๆ หรือผ่าน supabase-js:

-- supabase/migrations/6004_rt_storage_buckets.sql
insert into storage.buckets (id, name, public)
values ('rt-avatars', 'rt-avatars', false);
src/rt-create-bucket.ts
import { createClient } from '@supabase/supabase-js';
declare const process: { env: Record<string, string | undefined> };
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
const { data, error } = await supabase.storage.createBucket('rt-avatars', {
public: false,
});

ทั้งสองแบบสร้างแถวเดียวกันเป๊ะใน storage.buckets — createBucket เป็นแค่ wrapper บาง ๆ รอบ insert เดียวกัน การ upload, download, และอ่านไฟล์จาก supabase-js ทั้งหมดทำงานบน bucket ที่ระบุชื่อ:

src/rt-storage-basic-ops.ts
import { createClient } from '@supabase/supabase-js';
declare const process: { env: Record<string, string | undefined> };
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
// Upload a file into a path inside the bucket
const { data, error } = await supabase.storage
.from('rt-avatars')
.upload('user123/photo.png', new Blob());
// Download the raw file bytes
const { data: fileData } = await supabase.storage
.from('rt-avatars')
.download('user123/photo.png');
// Get a public URL — only meaningful for a public bucket
const { data: publicUrlData } = supabase.storage
.from('rt-avatars')
.getPublicUrl('user123/photo.png');
// Get a time-limited signed URL — for a private bucket
const { data: signedUrlData } = await supabase.storage
.from('rt-avatars')
.createSignedUrl('user123/photo.png', 60);

getPublicUrl แค่สร้าง URL string เท่านั้น — ไม่เช็คอะไรเลย ดังนั้นเรียกกับอ็อบเจกต์ของ private bucket ก็ได้ URL ที่ยังถูกปฏิเสธอยู่ดี createSignedUrl คือสิ่งที่คุณต้องการจริง ๆ สำหรับเนื้อหา private: มันสร้าง URL ที่ใช้ได้ตามจำนวนวินาทีที่คุณระบุ โดยฝัง signed token ที่ให้สิทธิ์เข้าถึงชั่วคราวสำหรับอ็อบเจกต์นั้นอันเดียว

สิ่งที่บังคับการเข้าถึงจริง ๆ: Row Level Security บน storage.objects จำกัดขอบเขตด้วย bucket_id

หัวข้อที่มีชื่อว่า “สิ่งที่บังคับการเข้าถึงจริง ๆ: Row Level Security บน storage.objects จำกัดขอบเขตด้วย bucket_id”

metadata ของทุกอ็อบเจกต์ที่ upload ไว้ — bucket, path, owner, content type — อยู่เป็นแถวจริงในตาราง Postgres คือ storage.objects การควบคุมการเข้าถึงก็แค่ Row Level Security policy บนตารางนั้น เขียนด้วย syntax using และ with check เดียวกับที่คุณใช้กับตารางอื่น ๆ อยู่แล้ว และทุก policy ที่สำคัญจะถูกจำกัดขอบเขตด้วย bucket_id เพื่อไม่ให้ policy ที่เขียนสำหรับ bucket หนึ่งรั่วไปยังอีก bucket:

-- supabase/migrations/6004_rt_storage_buckets.sql
create policy "rt_avatars_insert_own_folder"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'rt-avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);
create policy "rt_avatars_select_own_folder"
on storage.objects
for select
to authenticated
using (
bucket_id = 'rt-avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);
create policy "rt_avatars_update_own_folder"
on storage.objects
for update
to authenticated
using (
bucket_id = 'rt-avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);

storage.foldername(name) แยก path เต็มของอ็อบเจกต์ (เช่น b41ba47f-.../photo.png) ให้เป็น array ของส่วนต่าง ๆ ของ path และ [1] เอาส่วนแรก — ธรรมเนียมคือให้ส่วนแรกนั้นเป็น auth.uid() ของผู้ upload เอง ดังนั้น policy ที่เทียบมันกับ auth.uid()::text จะจำกัดขอบเขตให้ผู้ใช้แต่ละคนอยู่ในโฟลเดอร์ของตัวเอง พิสูจน์ด้วยผู้ใช้จริงที่สมัครไว้สองคน — bob พยายามเขียนเข้าโฟลเดอร์ของ alice จากนั้นเข้าโฟลเดอร์ของตัวเอง:

scripts/rt-storage-cross-user-write.sh
curl -X POST "$API_URL/storage/v1/object/rt-avatars/$ALICE_ID/bob-intrusion.txt" \
-H "Authorization: Bearer $BOB_TOKEN" -H "apikey: $ANON_KEY" -H "Content-Type: text/plain" \
--data-binary @bob-intrusion.txt
curl -X POST "$API_URL/storage/v1/object/rt-avatars/$BOB_ID/bob-note.txt" \
-H "Authorization: Bearer $BOB_TOKEN" -H "apikey: $ANON_KEY" -H "Content-Type: text/plain" \
--data-binary @bob-intrusion.txt
--- bob tries to write into alice's folder ---
{"statusCode":"403","error":"Unauthorized","message":"new row violates row-level security policy","code":"AccessDenied"}
HTTP_STATUS:400
--- bob writes into his OWN folder ---
{"Key":"rt-avatars/d2ef5bb4-f15d-447f-80f2-a0833f80c4e7/bob-note.txt","Id":"d0f79e7c-bb40-4c03-b46b-7f8d881e78f7"}
HTTP_STATUS:200

body 403/AccessDenied จริง — "new row violates row-level security policy" — error family เดียวกันเป๊ะกับที่โมดูล RLS สอนไว้แล้วสำหรับตารางธรรมดา เพราะ storage.objects ก็คือตารางธรรมดา request แบบเดียวกันเป๊ะที่ยิงเข้าโฟลเดอร์ของตัวเองสำเร็จและได้ object key จริงกลับมา

src/rt-storage-folder-rls.ts
// @run
import { createClient } from '@supabase/supabase-js';
declare const process: { env: Record<string, string | undefined>; exit: (code?: number) => never };
const API_URL = process.env.SUPABASE_URL!;
const ANON_KEY = process.env.SUPABASE_ANON_KEY!;
const admin = createClient(API_URL, process.env.SUPABASE_SERVICE_ROLE_KEY!);
async function ensureUser(email: string, password: string) {
const { data, error } = await admin.auth.admin.createUser({ email, password, email_confirm: true });
if (!error) return data.user;
const { data: list } = await admin.auth.admin.listUsers();
const existing = list.users.find((u) => u.email === email);
if (!existing) throw error;
return existing;
}
const alice = await ensureUser('[email protected]', 'rt-pw-alice-12345');
const bob = await ensureUser('[email protected]', 'rt-pw-bob-12345');
const bobClient = createClient(API_URL, ANON_KEY);
await bobClient.auth.signInWithPassword({ email: '[email protected]', password: 'rt-pw-bob-12345' });
const intoAlicesFolder = await bobClient.storage
.from('rt-avatars')
.upload(`${alice.id}/bob-was-here.txt`, new Blob(['nope']));
console.log('bob -> alice folder: error =', intoAlicesFolder.error?.message ?? null);
const intoOwnFolder = await bobClient.storage
.from('rt-avatars')
.upload(`${bob.id}/bob-own-file.txt`, new Blob(['fine']));
console.log('bob -> own folder: error =', intoOwnFolder.error?.message ?? null, 'path =', intoOwnFolder.data?.path);
process.exit(intoAlicesFolder.error && !intoOwnFolder.error ? 0 : 1);

signed URL ฝัง JWT ที่มี claim url กับ exp — พิสูจน์จริงว่าการหมดอายุถูกบังคับฝั่ง server ไม่ใช่ฝั่ง client ด้วยการสร้างอันหนึ่งที่อายุ 2 วินาที แล้ว fetch ทั้งก่อนและหลัง:

scripts/rt-signed-url-expiry.sh
curl -X POST "$API_URL/storage/v1/object/sign/rt-avatars/$ALICE_ID/tus-test.txt" \
-H "Authorization: Bearer $TOKEN" -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \
-d '{"expiresIn": 2}'
--- fetch immediately (should succeed) ---
0123456789ABCDEFGHIJ
HTTP_STATUS:200
--- wait 4s for expiry ---
--- fetch after expiry (should fail) ---
{"statusCode":"400","error":"InvalidJWT","message":"\"exp\" claim timestamp check failed","code":"InvalidJWT"}
HTTP_STATUS:400

InvalidJWT/"exp" claim timestamp check failed จริง — การเช็คการหมดอายุ JWT เดียวกับที่โมดูล Auth สอนไว้แล้ว เพียงแต่ใช้กับ access token ของ Storage แทน session token signed upload URL คือด้านตรงข้าม: แทนที่จะให้สิทธิ์อ่านชั่วคราวกับอ็อบเจกต์ที่มีอยู่แล้ว มันให้สิทธิ์ เขียน ชั่วคราวกับ path ที่ยังไม่ต้องมีอยู่เลยก็ได้ โดยไม่ต้องมี header Authorization เลยตอน upload — มีประโยชน์สำหรับให้ client ที่ไม่น่าเชื่อถือ (หรือเบราว์เซอร์ที่ไม่มี session) upload ตรงได้ เมื่อ server ของคุณสร้าง token ไว้แล้ว:

src/rt-signed-upload-url.ts
import { createClient } from '@supabase/supabase-js';
declare const process: { env: Record<string, string | undefined> };
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
const { data, error } = await supabase.storage
.from('rt-avatars')
.createSignedUploadUrl('user123/upload-later.txt');
// data.token is then handed to the untrusted client, which uploads with:
// await supabase.storage.from('rt-avatars').uploadToSignedUrl('user123/upload-later.txt', data.token, file);
--- PUT using ONLY the signed upload token, no Authorization header at all ---
{"Key":"rt-avatars/b41ba47f-4b83-4945-8dd6-637df563e1d3/signed-upload-demo.txt"}
HTTP_STATUS:200

Supabase Storage ทำ TUS protocol ที่ /storage/v1/upload/resumable แนะนำสำหรับไฟล์ที่ใหญ่กว่าประมาณ 6MB หรือบน connection ที่ไม่เสถียร (supabase.com/docs/guides/storage/uploads/resumable-uploads) container supabase_storage_probe ในเครื่อง (storage-api 1.72.1) รองรับเต็มรูปแบบ — พิสูจน์ด้านล่างด้วย curl ดิบ ๆ ที่คุยกับ TUS protocol โดยตรง (ไม่ต้องใช้ client library เพื่อพิสูจน์กลไก แม้ tus-js-client — แสดงต่อจากนี้ — จะเป็นสิ่งที่คุณใช้จริงในแอป) การ upload 20 byte แบ่งเป็นสอง chunk ละ 10 byte พร้อมการเช็ค HEAD คั่นกลางเพื่อจำลองการ resume หลังการขัดจังหวะ:

scripts/rt-tus-resumable.sh
curl -X POST "$API_URL/storage/v1/upload/resumable" \
-H "Authorization: Bearer $TOKEN" -H "apikey: $ANON_KEY" \
-H "Tus-Resumable: 1.0.0" -H "Upload-Length: 20" \
-H "Upload-Metadata: bucketName $META_BUCKET,objectName $META_OBJECT,contentType $META_CT"
# -> 201 Created, Location: http://127.0.0.1:55321/storage/v1/upload/resumable/<upload-id>
curl -X HEAD "$LOCATION" -H "Authorization: Bearer $TOKEN" -H "apikey: $ANON_KEY" -H "Tus-Resumable: 1.0.0"
# -> Upload-Offset: 0, Upload-Length: 20
curl -X PATCH "$LOCATION" -H "Authorization: Bearer $TOKEN" -H "apikey: $ANON_KEY" \
-H "Tus-Resumable: 1.0.0" -H "Upload-Offset: 0" -H "Content-Type: application/offset+octet-stream" \
--data-binary "0123456789"
# -> 204, Upload-Offset: 10
# ...connection drops here in a real client; on reconnect, a HEAD confirms where to resume from...
curl -X HEAD "$LOCATION" -H "Authorization: Bearer $TOKEN" -H "apikey: $ANON_KEY" -H "Tus-Resumable: 1.0.0"
# -> Upload-Offset: 10 (the server remembers exactly how many bytes it already has)
curl -X PATCH "$LOCATION" -H "Authorization: Bearer $TOKEN" -H "apikey: $ANON_KEY" \
-H "Tus-Resumable: 1.0.0" -H "Upload-Offset: 10" -H "Content-Type: application/offset+octet-stream" \
--data-binary "ABCDEFGHIJ"
# -> 204, Upload-Offset: 20
--- HEAD before any bytes uploaded ---
HTTP/1.1 200
upload-length: 20
upload-offset: 0
--- PATCH first 10 bytes (chunk 1) ---
HTTP/1.1 204
upload-offset: 10
--- HEAD after chunk 1 (simulating resume check) ---
HTTP/1.1 200
upload-length: 20
upload-offset: 10
--- PATCH chunk 2, resuming from offset 10 ---
HTTP/1.1 204
upload-offset: 20
--- final downloaded object content ---
0123456789ABCDEFGHIJ

สอง chunk — 0123456789 และ ABCDEFGHIJ — ต่อกันได้ 20 byte เดิมพอดี และ HEAD ที่คั่นกลางพิสูจน์ว่า server ติดตามความคืบหน้าแยกจาก client จริง: client จริงที่ crash หลัง chunk 1 เชื่อมต่อใหม่ได้ HEAD เพื่อพบ Upload-Offset: 10 แล้ว resume จากตรงนั้นพอดีแทนที่จะ upload ใหม่จากศูนย์ โค้ดฝั่ง client ที่เทียบเท่ากัน โดยใช้ library ที่คุณจะใช้จริงในแอป:

src/rt-tus-upload.ts
// tus-js-client is not a dependency of this course's probe project. In a
// real app: `npm install tus-js-client`, then
// `import * as tus from 'tus-js-client';` — the `tus` const below stands
// in for that import purely so this snippet type-checks in isolation.
declare const tus: {
Upload: new (
file: File,
options: Record<string, unknown>
) => {
findPreviousUploads(): Promise<unknown[]>;
resumeFromPreviousUpload(previous: unknown): void;
start(): void;
};
};
declare const process: { env: Record<string, string | undefined> };
async function uploadFile(bucketName: string, fileName: string, file: File, accessToken: string) {
return new Promise<void>((resolve, reject) => {
const upload = new tus.Upload(file, {
endpoint: `${process.env.SUPABASE_URL}/storage/v1/upload/resumable`,
retryDelays: [0, 3000, 5000, 10000, 20000],
headers: { authorization: `Bearer ${accessToken}` },
uploadDataDuringCreation: true,
metadata: { bucketName, objectName: fileName, contentType: file.type },
chunkSize: 6 * 1024 * 1024,
onError: reject,
onSuccess: () => resolve(),
});
upload.findPreviousUploads().then((previous: unknown[]) => {
if (previous.length) upload.resumeFromPreviousUpload(previous[0]);
upload.start();
});
});
}

bucket หนึ่งจำกัดสิ่งที่มันรับได้ตั้งแต่ตอนสร้าง file_size_limit (byte) และ allowed_mime_types บังคับโดยตัว Storage API เอง ไม่ใช่ RLS:

-- supabase/migrations/6004_rt_storage_buckets.sql
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('rt-restricted', 'rt-restricted', false, 100, array['image/png']);
create policy "rt_restricted_insert_own_folder"
on storage.objects for insert to authenticated
with check (bucket_id = 'rt-restricted' and (storage.foldername(name))[1] = auth.uid()::text);
create policy "rt_restricted_select_own_folder"
on storage.objects for select to authenticated
using (bucket_id = 'rt-restricted' and (storage.foldername(name))[1] = auth.uid()::text);
create policy "rt_restricted_update_own_folder"
on storage.objects for update to authenticated
using (bucket_id = 'rt-restricted' and (storage.foldername(name))[1] = auth.uid()::text);

พิสูจน์ด้วย bucket 100 byte / image/png เท่านั้นด้านบน:

--- wrong mime type (text/plain into image/png-only bucket) ---
{"statusCode":"415","error":"invalid_mime_type","message":"mime type text/plain is not supported","code":"InvalidMimeType"}
--- file too large (bucket limit is 100 bytes, file is 500) ---
{"statusCode":"413","error":"Payload too large","message":"The object exceeded the maximum allowed size","code":"EntityTooLarge"}
--- valid: 50-byte png, correct mime type ---
{"Key":"rt-restricted/.../small.png","Id":"411d477e-..."} HTTP 200

HTTP status code จริงที่ต่างกันชัดเจนสำหรับแต่ละความล้มเหลว — 415 สำหรับ MIME ผิด 413 สำหรับขนาดเกิน ทั้งคู่ถูกจับก่อนที่ RLS จะถูกประเมินด้วยซ้ำ upsert (การ upload ซ้ำ path เดิม) มีข้อกำหนดที่แหลมคมกว่าที่เห็น:

--- re-upload same path WITHOUT x-upsert (already exists) ---
{"statusCode":"409","error":"Duplicate","message":"The resource already exists","code":"KeyAlreadyExists"}
--- re-upload same path WITH x-upsert: true, but only insert+update policies (no select) ---
{"statusCode":"403","error":"Unauthorized","message":"new row violates row-level security policy","code":"AccessDenied"}
--- same request, WITH a select policy added ---
{"Key":"rt-restricted/.../small.png","Id":"411d477e-..."} HTTP 200
flowchart LR
  client["Client upload/download/upsert request"] --> api["Storage API"]
  api --> restrict{"file_size_limit / allowed_mime_types check (bucket config, not RLS)"}
  restrict -->|fails| reject413["413 / 415, before RLS runs"]
  restrict -->|passes| objects[("storage.objects row: bucket_id, name, owner")]
  objects --> rls{"RLS: insert/update with check, select using, scoped by bucket_id"}
  rls -->|allowed| data["Object written or returned"]
  rls -->|denied| reject403["403 AccessDenied"]
ทุก Storage request ถูกกั้นด้วย RLS บน storage.objects จำกัดขอบเขตด้วย bucket_id

AI assistant ถูกขอให้ “ให้ผู้ใช้ upload avatar ของตัวเอง และเขียนทับได้ถ้า upload อีกครั้ง” แล้วเขียนโค้ดนี้มา:

🐛 หา bug ในโค้ดนี้

create policy "avatars_write"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'rt-avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);
create policy "avatars_update"
on storage.objects
for update
to authenticated
using (
bucket_id = 'rt-avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);
เฉลย

ดูสมบูรณ์แล้ว — มี policy insert กับ update ทั้งคู่จำกัดขอบเขตถูกต้องไปที่โฟลเดอร์ของผู้ใช้เอง — แต่การ upload ซ้ำ path เดิมด้วย upsert: true ล้มเหลวด้วย 403/AccessDenied จริง พิสูจน์แล้วก่อนหน้านี้ในบทนี้ด้วยรูปแบบ error เดียวกันเป๊ะ upsert คอมไพล์เป็น INSERT ... ON CONFLICT DO UPDATE ... RETURNING * และ Postgres ต้องการ select policy เพื่อประเมินด้วยซ้ำว่าแถวที่ conflict มองเห็นได้กับผู้เรียกคนนี้หรือไม่ ไม่ใช่แค่ policy insert กับ update ทางแก้คือ policy ที่สาม: create policy "avatars_read" on storage.objects for select to authenticated using (bucket_id = 'rt-avatars' and (storage.foldername(name))[1] = auth.uid()::text); — ถ้าไม่มี upload(path, file, { upsert: true }) บน path ที่มีอยู่แล้วจะล้มเหลวทุกครั้ง และข้อความ error (“row-level security policy”) ก็ไม่ได้บอกใบ้เลยว่า policy ที่ขาดคือ select ไม่ใช่ insert หรือ update

Storage ไม่ใช่ระบบสิทธิ์แยกต่างหากที่สวมชุดของ Postgres — มันคือ API บาง ๆ ที่อยู่หน้าสองสิ่งจริง: blob store แบบ S3-compatible สำหรับ byte และตาราง Postgres จริง storage.objects สำหรับทุกอย่างเกี่ยวกับ byte เหล่านั้น (owner, path, bucket, content type) ทุกการตัดสินใจเรื่องการเข้าถึงที่ Storage ทำเกี่ยวกับ แถว (ใครเห็น metadata ของอ็อบเจกต์นี้ได้ ใครสร้างหรือเขียนทับได้) คือ RLS เต็ม ๆ เอนจิ้นเดียวกันและ policy syntax เดียวกับตารางไหนก็ตามที่คุณเคยเขียน policy ไว้แล้ว — พิสูจน์แล้วด้านบนด้วยรูปแบบ 403/AccessDenied/42501 เดียวกันเป๊ะกับตัวอย่างตารางของโมดูล RLS เอง ทุกอย่างที่ ไม่ใช่ การตัดสินใจของ RLS — 413/415 จาก file_size_limit/allowed_mime_types ระดับ bucket, 409 จาก unique-constraint conflict ธรรมดาบน (name, bucket_id), การหมดอายุของ JWT ของ signed URL เอง — ถูกบังคับที่จุดอื่นใน stack ก่อนหรือควบคู่กับ RLS ซึ่งเป็นเหตุผลที่แต่ละความล้มเหลวด้านบนกลับมาเป็น HTTP status กับ error code ที่ต่างกันจริง ๆ แทนที่จะเป็น “storage error” แบบเดียวกันหมด

  • RLS ของ storage.objects ใช้ทุกอย่างที่คุณรู้จักอยู่แล้วเกี่ยวกับ Postgres RLS ซ้ำ — ไม่มี policy language ใหม่ ไม่มี mental model ใหม่ — แลกมาด้วยความละเอียดอ่อนที่ไม่มีใน table RLS ธรรมดา เช่น upsert ที่ต้องการ select policy แบบเงียบ ๆ เพราะวิธีที่ ON CONFLICT DO UPDATE ถูกคอมไพล์ พิสูจน์แล้วด้านบน
  • Signed URL ให้คุณให้สิทธิ์เข้าถึงแบบจำกัดเวลาที่แม่นยำกับอ็อบเจกต์หนึ่งโดยไม่ต้องทำให้ bucket เป็น public — แลกมาด้วยการหมดอายุที่ถูกกำหนดตายตัวตอนสร้าง (2 ชั่วโมงโดย default สำหรับ signed upload URL ตามเอกสาร และเท่าไหร่ก็ได้ที่คุณส่งให้ createSignedUrl) โดยไม่มีทางเพิกถอน token เดียวก่อนกำหนดได้ นอกจากหมุนเวียน signing key ทั้ง bucket
  • TUS resumable upload รอดจาก connection ที่หลุดโดยไม่ต้อง upload ใหม่จากศูนย์ — แลกมาด้วยข้อกำหนด chunk size คงที่ 6MB และความซับซ้อนฝั่ง client ที่มากกว่าเยอะ (สร้าง session, ติดตาม offset, logic การ resume) กว่าการเรียก .upload() ครั้งเดียว ซึ่งเป็นเหตุผลที่เอกสารแนะนำมันเฉพาะสำหรับกรณีไฟล์ใหญ่หรือ network ไม่เสถียร ไม่ใช่ default แบบครอบคลุมทุกกรณี
  • “การเข้าถึง Storage ถูกควบคุมด้วย flag public/private ของ bucket ไม่ใช่ RLS” พิสูจน์แล้วด้านบน: แม้ flag public: false/true ระดับ bucket ก็แค่เปลี่ยนพฤติกรรมการอ่านแบบไม่ authenticate เริ่มต้น เท่านั้น — ทุกการเขียนที่ authenticated และทุกการอ่านบน private bucket ยังถูกกั้นด้วย policy จริงของ storage.objects รูปแบบเหมือน table RLS เป๊ะ
  • “ถ้า policy insert กับ update ถูกต้อง upsert จะทำงาน” พิสูจน์แล้วด้านบน: 403 จริงบน upsert: true แม้ policy ทั้งสองจะมีอยู่และถูกต้อง เพราะ query เบื้องหลังยังต้องการ select policy เพื่อ resolve ON CONFLICT ด้วย
  • “การหมดอายุของ signed URL เป็นแค่คำแนะนำฝั่ง client เหมือน URL parameter ธรรมดา” พิสูจน์แล้วด้านบน: การหมดอายุคือ claim exp จริงใน JWT ที่เซ็นไว้ เช็คฝั่ง server — request หลังหมดอายุได้ error InvalidJWT จริง ไม่ใช่ URL ที่ยังทำงานได้เงียบ ๆ
  • “TUS resumable upload ต้องมี client library พิเศษถึงจะทำงานได้” พิสูจน์แล้วด้านบนด้วยการขับเคลื่อน protocol ทั้งหมดด้วย curl ดิบ ๆ — POST เพื่อสร้าง PATCH เพื่อส่ง byte พร้อม Upload-Offset HEAD เพื่อเช็คความคืบหน้า library อย่าง tus-js-client ทำให้สะดวก (retry logic, chunking, resume detection) แต่ตัว protocol เองเป็นแค่ HTTP header ธรรมดา
ความต่างระหว่าง public กับ private Storage bucket คืออะไร
อะไรที่บังคับจริง ๆ ว่าใคร upload, อ่าน, หรือลบ Storage object หนึ่งได้
bucket หนึ่งมี file_size_limit: 100 และ allowed_mime_types: [image/png] เกิดอะไรขึ้นเมื่อ upload ไฟล์ text/plain ขนาด 500 byte
การ upload ด้วย upsert: true ไปยัง path ที่มีอยู่แล้วล้มเหลวด้วย 403/AccessDenied แม้ policy insert กับ update จะจำกัดขอบเขตให้ผู้เรียกถูกต้องทั้งคู่ ขาดอะไรไป
signed URL ที่สร้างด้วย createSignedUrl(path, 2) บังคับอะไรจริง ๆ หลังผ่านไป 2 วินาที
อะไรพิสูจน์ว่า TUS resumable upload ไม่ต้องการ client library เฉพาะถึงจะทำงานได้