跳到正文
DEV Community· Plopino·· 3 小時前AI 評分48

Plopino 匿名意見收件箱應儲存甚麼、不應儲存甚麼

What an anonymous feedback inbox should store — and what it should not

AI 導讀

Plopino 加入毋須登入的站內意見表單和管理收件箱,並以限制每個 IP 每日提交 10 則訊息作為主要防護。資料列不儲存 IP 或 User-Agent;訊息和回覆以 textContent 顯示,避免把內容當作 HTML 渲染。管理端點要求管理員權限,匿名用戶只有留下電郵才能收到回覆。

正文

AI disclosure: I used AI assistance to draft and edit this article. I checked the code excerpts against the production codebase, ran the relevant tests locally, and stand behind the technical claims below.

Plopino is a small static host that accepts anonymous HTML uploads. That works for the sending side and leaves a hole on the receiving side: until this week, someone who hit a bug only had an abuse email address.

On October 20 I shipped an in-site feedback inbox: a form that does not require login, and an admin inbox where I read and answer messages. This post covers the tradeoffs I chose, including the cases the happy path does not show.

Why build it into the product

An external form would have taken an afternoon, but it adds another data origin, a third-party script, and a privacy-policy paragraph. The product already accepts anonymous uploads, so the contact channel had to accept the same audience.

I cut the rest: no notifications, no attachments, no categories, and no state beyond new, read, and archived.

Check cheap signals before spending quota

The endpoint is public and the rate limit is daily. The order matters: check cheap automation signals, then validate the input, then consume quota. A typo should not burn the allowance of someone acting in good faith.

// POST /api/feedback — trimmed, imports and the insert omitted
if (body?.website != null && String(body.website).trim() !== '') {
  return sendJson(res, 200, { ok: true });
}
const startedAt = Number(body?.startedAt);
if (Number.isFinite(startedAt)) {
  const elapsed = Date.now() - startedAt;
  if (elapsed >= 0 && elapsed < 2000) return sendJson(res, 200, { ok: true });
}
const message = typeof body?.message === 'string' ? body.message.trim() : '';
if (!message) return sendJson(res, 400, { error: tErr(req, 'error.feedbackRequired') });
if (message.length > 4000) return sendJson(res, 400, { error: tErr(req, 'error.feedbackTooLong', { n: 4000 }) });
const daily = consumeDailyQuota(gwdb, `feedback:ip:${clientIp(req)}`, feedbackDailyLimit);
if (!daily.allowed) return sendJson(res, 429, { error: tErr(req, 'error.feedbackRateLimited') });

website is a hidden honeypot that a person will not fill. startedAt is recorded when the page opens; less than two seconds to submit is a script signal. Both checks return 200 with ok: true, because an explicit error would tell automation which rule to avoid.

The silence has a cost: a very fast human can lose a message without knowing. A clock that is ahead also becomes a real edge case. Future timestamps therefore follow the normal flow rather than being treated as suspicious.

With no captcha, the limit is the main defense: ten messages per IP per day, stored in the same database as the upload quota and reset at the UTC day boundary. Signed-in users share the IP bucket, so a shared network splits those ten messages between everyone.

Render nothing as HTML

Messages and replies are stored as they arrive, without an HTML sanitizer. Feedback is not a page. The rendering rule is simpler than cleaning arbitrary HTML: text from the inbox always goes into the textContent of a DOM-created element, never string-concatenated into innerHTML.

const msg = document.createElement('p');
msg.className = 'fb-msg';
msg.textContent = item.message;

The admin reply renders the same way. pageUrl accepts only an internal path; absolute URLs, protocol-relative URLs, and backslashes become NULL, with a 300-character cap. An admin UI test reads the HTML and fails if the message body lands in innerHTML.

This choice moves the risk rather than removing it. Safety depends on every future renderer following the same rule. That is why the test checks the rendering path, not just the database layer.

Three audiences, three permission levels

Submission is public. "My feedback" requires a session, filters by user_id, returns at most 20 rows, and omits email and the admin's name. Every admin endpoint requires the admin role. Status is a closed list; replies are capped at 4,000 characters; an empty reply clears the existing one.

Those APIs return Cache-Control: private, no-store, and the public form carries noindex.

The known limit: an anonymous sender gets a reply only if they leave an email. A one-time lookup link was cut because it would add another state machine for the same problem.

What one row keeps

A row holds the message, an optional email, the internal page path, and, for a signed-in sender, a reference to the account plus the username at submission time.

It does not store IP or User-Agent. Abuse control uses a daily per-IP counter and resets it the next day. The analytics event contains no message text and no email; it records only the path and whether the sender was signed in.

Writing down what the inbox must not store was more useful than choosing a schema first. The omissions reduced what had to be protected, exported, or explained later.

Translations are data with contracts

The UI already supported 20 languages. The inbox added 32 keys, translated into the other 19 languages, and localized the server errors so a 429 arrives as "too many messages today" instead of a bare code.

The structural test compares every dictionary with English on three axes: the key set, key order, and placeholder names. It does not guarantee translation quality. The 19 versions are AI-assisted and have not all been reviewed by native speakers, but no raw key reaches the screen without a failing test.

What the tests cover

I ran the 13 feedback test cases: validation, the honeypot and minimum fill time, the shared per-IP limit, isolation of "my feedback", admin permissions, replies, deletion, and 404 responses. I also ran the 4 admin UI tests; one fails if the message body goes through innerHTML.

Those tests check the code, not real traffic. There is no browser visual test yet, and I will understand shared-network behavior from the inbox itself.

The design decision I would repeat: define the data the inbox must not keep before choosing the database or building the UI. The implementation then has less to forget later.


This article is a canonicalized cross-post of the original in my engineering notes: Adding feedback to an account-free service: what the inbox does not store.

來源:DEV Community · dev.to