Một hộp thư góp ý cho bảy app iOS: một edge function trên Supabase
Endpoint submit-feedback dùng chung cho bảy app indie: validate, rate limit mà không lưu IP, screenshot riêng tư, báo qua Telegram, RLS không có policy.

Trong bài này
Tới tuần này, muốn báo cho mình biết app có lỗi thì cách duy nhất là tìm email hỗ trợ trên trang App Store rồi tự viết thư. Gần như chẳng ai làm vậy. Người gặp bug trong Lockboxy hay Baton thì lặng lẽ bỏ đi, còn người có ý tưởng hay thì giữ cho riêng mình.
Nên mình làm một màn hình "Feedback & bug report" tử tế. Bảy app (Lockboxy, Linkeeper, Minivid, Ringsy, Talkzy, Baton, Stampzy) nghĩa là bảy form, nhưng mình muốn chỉ một backend, một bảng và một chỗ để đọc hết. Bài này kể về phần dùng chung đó: một edge function Supabase tên submit-feedback, bảng đứng sau nó, và form gọi tới nó. Bài cũng nói về privacy label, thứ mà với những app hứa "không analytics" thì quan trọng không kém code.
Một endpoint public, không cần tài khoản
Không app nào của mình có đăng nhập. Lockboxy là két offline, Baton là nhật ký cho bé không bao giờ rời khỏi máy, vân vân. Nên endpoint phải nhận request từ một app không có Supabase session, tức là verify_jwt = false cho riêng function này:
# supabase/config.toml
[functions.submit-feedback]
verify_jwt = falseNó nằm trong project Supabase vốn đang chạy backend của Linkeeper, vì project đó có sẵn và free plan vẫn còn chỗ. App gửi một body JSON:
{
"app": "baton",
"kind": "bug",
"message": "The night timer kept running after I stopped it",
"email": "optional",
"appVersion": "1.1.1",
"osVersion": "26.0",
"deviceModel": "iPhone",
"locale": "en",
"installId": "a random UUID made on first launch",
"screenshotBase64": "optional",
"screenshotMime": "image/jpeg"
}Response cũng gọn: 200 {"ok":true,"id":"…"}, hoặc một status kèm mã lỗi máy đọc được như 400 invalid_email, 413 screenshot_too_large, 429 rate_limited_install. 500 chỉ ghi internal_error, không hơn. Chi tiết nằm trong log của function, không bao giờ nằm trong response.
Validate hết rồi mới ghi
Endpoint public sớm muộn gì cũng nhận rác, nên thứ tự các bước là quyết định thiết kế quan trọng nhất. Function chia làm hai: lib.ts chứa các helper thuần không I/O (validate, tính kích thước base64, format tin Telegram), có deno test phủ, còn index.ts lo phần I/O. Request đi qua các bước sau, và chưa qua hết các bước kiểm tra rẻ thì chưa đụng tới database.

- Kích thước. Body tối đa 7 MB, kiểm bằng
content-lengthtrước rồi kiểm lại trên số byte đọc được thật. - Hình dạng.
appphải là một trong bảy app,kindlàbug,ideahoặcother. Message dài 3 tới 5.000 ký tự sau khi trim. Các chuỗi tuỳ chọn có giới hạn riêng (32 cho version, 64 cho device model, 35 cho locale), chuỗi rỗng thànhnull. Install ID phải khớp[A-Za-z0-9-]{8,64}. - Screenshot. Nếu có, kích thước sau decode tối đa 5 MB, và chữ ký file phải khớp kiểu đã khai:
FF D8 FFcho JPEG, header 8 byte của PNG,ftypở offset 4 cho HEIC. Cách rẻ để không ai nhét file tuỳ ý vào bucket của mình. - Rate limit, nói ở dưới.
- Insert dòng dữ liệu, rồi upload screenshot, rồi báo tin.
Có một chi tiết nhỏ mà test đã chứng minh là đáng: độ dài message đếm theo Unicode code point, không theo đơn vị UTF-16, để khớp với char_length() của Postgres và constraint check trên bảng. Một message 5.000 ký tự toàn emoji thì cả hai cùng cho qua, hoặc cùng chặn.
/** Length in Unicode code points — matches Postgres char_length(). */
export function codePointLength(s: string): number {
return Array.from(s).length;
}Rate limit mà không giữ địa chỉ IP
Có hai giới hạn, và chúng nằm ở hai chỗ khác nhau là có lý do.
5 lần mỗi install mỗi giờ. Mỗi app tạo một UUID ngẫu nhiên ở lần mở đầu tiên và lưu trên máy. Nó không gắn với tài khoản hay advertising ID. Function đếm số dòng trong app_feedback có install_id đó trong một giờ qua, dùng partial index trên (install_id, created_at desc) where install_id is not null. Không cần thêm bảng.
20 lần mỗi IP mỗi giờ. Cái này bắt những ai bỏ install ID đi. Mình không muốn có địa chỉ IP trong database, nên function lưu hash thay vì IP:
const ip = clientIp(req.headers); // first hop of X-Forwarded-For
const ipKey = ip ? await sha256Hex(`submit-feedback:${ip}`) : null;Các hash đó vào một bảng sổ cái nhỏ, app_feedback_rate (key, created_at). Mỗi request xoá các dòng cũ hơn một giờ trước khi đếm, nên bảng không bao giờ chứa quá một giờ hash.
Sao lại dùng bảng mà không dùng một Map trong bộ nhớ? Isolate của edge function sống ngắn và không được chia sẻ giữa các request theo cách nào đáng tin. Bộ đếm trong bộ nhớ sẽ reset liên tục và gần như chẳng chặn được gì.
Request bị từ chối với 400, 413 hay 429 thì không bị tính. Người dùng sửa lỗi gõ email không nên mất lượt.
Screenshot giữ riêng tư
Screenshot vào một bucket Storage tên feedback-screenshots, để private, giới hạn 5 MB mỗi file và chỉ nhận JPEG, PNG, HEIC ngay ở cấp bucket. Đường dẫn là <app>/<feedback id>.<ext>. Không có gì tạo ra URL public cả. Khi mình muốn xem, admin console ký một URL hết hạn sau một giờ.
Upload diễn ra sau insert, và upload lỗi thì request vẫn không fail. Góp ý đã được lưu, đó mới là phần quan trọng, nên function ghi log lỗi và để trống screenshot_path.
Báo qua Telegram, có escape
Khi đã set secret cho bot, mỗi lần gửi sẽ báo cho mình qua Telegram:
🐞 Baton · Bug
The night timer kept running after I stopped it
📱 1.1.1 · iOS 26.0 · iPhone · en
✉️ none
🆔 3f2a9c1eTin dùng HTML parse mode của Telegram, nên mọi giá trị do người dùng nhập đều đi qua hàm escape. Không thì một message có <b> hay một dấu & lạc sẽ hoặc phá format, hoặc khiến Telegram từ chối cả lệnh gọi:
export function escapeHtml(s: string): string {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}Nội dung bị cắt ở 1.500 ký tự, lại đếm theo code point, để emoji không bao giờ bị chẻ đôi. Screenshot đi theo dưới dạng ảnh, gửi từ chính các byte function đang giữ, nên cũng không tạo URL nào cho Telegram. HEIC thì gửi dạng document, vì sendPhoto không nhận HEIC.
Ba quy tắc giữ cho phần này vô hại. Telegram lỗi thì request không bao giờ fail. Thiếu secret thì bỏ qua việc báo tin, không lỗi gì, và function đọc secret ở mỗi request nên set xong không cần deploy lại. Và chỗ xử lý lỗi không bao giờ log URL của fetch, vì bot token nằm ngay trong đó.
Bật RLS, không có policy nào
Cả hai bảng đều bật row level security và, trong migration đầu tiên, không có policy nào:
alter table public.app_feedback enable row level security;
alter table public.app_feedback_rate enable row level security;
-- no create policy: anon and authenticated can neither read nor writeCố ý như vậy. Function ghi bằng service role, vốn bỏ qua RLS. Mọi client khác, kể cả ai đó moi được anon key public ra từ file app, đều không đọc được góp ý, không insert được dòng nào lách qua validate, không thấy được sổ rate. Cửa duy nhất là đi qua function.

Phần đọc hộp thư đến sau, qua một migration riêng cấp một nhóm quyền hẹp cho đúng một admin. Đó là bài tiếp theo: một admin console không tốn đồng nào để host.
Form trong app
Mỗi app có màn hình riêng, nhưng dựng cùng một kiểu. Baton là ví dụ tốt. Nó mở từ Settings › About, từ tab More, và từ deep link baton://feedback?kind=bug, để một mục What's New có thể nhảy thẳng vào.
- Bug, Idea hoặc Other, mỗi loại một placeholder riêng ("What happened, and what did you expect?" cho bug).
- Email (tuỳ chọn), kèm gợi ý "Only if you want a reply."
- Screenshot (tuỳ chọn) ở những app vốn đã có image picker. Baton encode lại ảnh được chọn thành JPEG (tối đa 2.048 px, quality 0.8, bỏ EXIF và GPS) và giới hạn 1,5 MB, thấp hơn nhiều so với 5 MB của server. Linkeeper và Talkzy không có picker, nên form của hai app này bỏ screenshot thay vì xin thêm một quyền chỉ để làm việc này.
- "What gets sent", phần giải thích liệt kê mọi trường: message và loại, email và screenshot chỉ khi có thêm, version app, version iOS, loại thiết bị, ngôn ngữ, và một install ID ngẫu nhiên "không gắn với bạn hay gia đình bạn". Form của Baton còn dặn đừng ghi tên bé hay thông tin sức khoẻ.
Quy tắc mình ghi thẳng vào code là danh sách này phải khớp với hàm dựng body request, buildFeedbackBody. Đổi một bên thì phải đổi bên kia, và file copy có comment nói rõ điều đó.
Lệnh gọi mạng không bao giờ throw. Mọi kết quả đều thành một outcome mà màn hình biết cách hiển thị:
export type FeedbackOutcome =
| { status: 'sent'; id: string }
| { status: 'invalid'; field: string | null }
| { status: 'tooLarge' }
| { status: 'rateLimited' }
/** Offline, timed out, or a 5xx / non-JSON answer. */
| { status: 'unavailable' };unavailable phủ nhiều trường hợp hơn bạn nghĩ. Lúc smoke test với body 8 MB, mình không nhận lại 413 của function. Gateway của Supabase cắt request trước và trả 503. Nên form coi mọi 5xx hoặc response không phải JSON là "thử lại sau", và đưa ra lựa chọn Send by email instead: một link mailto: mang cùng nội dung và metadata. Install ID thì để ngoài, vì Mail đã cho biết ai viết. Ảnh không đi theo link mailto: được, nên người dùng muốn thì tự đính kèm trong Mail.
Privacy label là một phần của tính năng
Với những app mà trang listing ghi "Data Not Collected" hoặc gần như vậy, thêm form góp ý là phải đổi câu trả lời App Privacy. Mình ghi lại thay đổi cho từng app trước khi ship:
- User Content → Other User Content, thêm Photos or Videos ở app có đính kèm screenshot. Mục đích: Customer Support.
- Contact Info → Email Address, vì người dùng có thể gõ vào. Mục đích: App Functionality, Customer Support.
- Với tất cả: không liên kết với danh tính người dùng và không dùng để tracking.
Label phải đổi trong App Store Connect cùng lúc với build đầu tiên có form, không phải sau đó. Hôm nay Lockboxy 1.2.13 đã lên TestFlight kèm form này. Bài đưa app két riêng tư qua App Review có thêm về cách reviewer đọc các tuyên bố về quyền riêng tư.
Checklist
- Một endpoint cho mọi app, tên app là enum có validate, chứ không phải mỗi app một backend.
verify_jwt = falsechỉ cho function public, và RLS không policy trên các bảng của nó.- Validate kích thước, hình dạng và chữ ký file trước lần ghi đầu tiên.
- Đếm độ dài theo code point để function và Postgres đồng ý với nhau.
- Rate limit theo install ID từ bảng chính, theo IP bằng sổ cái đã hash và tự dọn. Đừng dùng bộ đếm trong bộ nhớ ở edge function.
- Bucket private, chỉ URL có ký, và upload lỗi không bao giờ làm mất message.
- Escape mọi chuỗi của người dùng trong HTML của Telegram, không bao giờ log URL chứa token, không để việc báo tin làm request fail.
- Trong app: email và screenshot tuỳ chọn, danh sách "What gets sent" khớp với body request, outcome thay cho exception, và fallback
mailto:. - Cập nhật App Privacy label cùng với build ship form.
Bảy app chia sẻ phần còn lại của stack thế nào thì có trong bài Một mình ship bảy app iOS.
Bài liên quan


