A small Node.js application for collecting anonymous text feedback with no logins, no accounts, no applicant IDs, and no special links tied to a person. The app uses a public feedback page, a private admin page, SQLite for storage, and a Sunday-only rule for viewing unread feedback.
This application is designed to collect plain-text feedback while minimizing stored metadata. It uses Fastify route schemas for request validation, SQLite for a minimal local database, and a reviewed flag stored as an integer because SQLite commonly represents Boolean-like values as 0 and 1.
The current design intentionally keeps the data model small:
- Public users can submit anonymous text feedback.
- The application stores the feedback text and whether it has been reviewed.
- Unread feedback is only visible on Sundays.
- Reviewed feedback remains visible in the admin interface.
- Public and admin entry pages can be moved to randomized paths.
The application is designed around data minimization rather than a promise of perfect anonymity. OWASP guidance warns that logs and operational metadata can contain sensitive information, so the app avoids storing unnecessary fields and keeps logging intentionally minimal.
Important privacy limits:
- This app avoids logins and avoids storing explicit user identifiers.
- This app does not make a person untraceable at the network level.
- Writing style, infrastructure logs, reverse-proxy logs, and operational mistakes can still reduce anonymity.
- Admin URLs hidden behind random paths are only light obscurity, not real security on their own.
For real-world use, protect the admin route with a reverse proxy, basic auth, or another proper access control mechanism. Hidden URLs should be treated as convenience and noise reduction, not as the primary security boundary.
Feedback bodies are treated as untrusted user input at every layer:
- Rendering. The admin UI (
public/admin.js) always inserts feedback text viatextContent.innerHTML(and equivalents likeinsertAdjacentHTMLordocument.write) is never used for DB-sourced data, so payloads such as<script>alert(1)</script>or"><img src=x onerror=alert(1)>are shown verbatim as text. - Content Security Policy.
@fastify/helmetis configured insrc/server.jswith a strict policy:default-src 'self',script-src 'self',script-src-attr 'none',style-src 'self',object-src 'none',base-uri 'self',form-action 'self',frame-ancestors 'none'. Notably,'unsafe-inline'is not present forscript-srcorstyle-src. Any inline<script>...</script>, inline event handler (onclick="..."), or inlinestyle="..."attribute will be blocked by the browser. - Static assets. All JavaScript and CSS live in dedicated files under
public/and are served under the fixed/assets/<name>route (seesrc/server.js). Only an explicit allow-list of asset names is exposed.
- Create the file in
public/(e.g.public/foo.js). - Add its filename to the
staticAssetsallow-list insrc/server.js. - Reference it from the HTML via
<script src="/assets/foo.js"></script>or<link rel="stylesheet" href="/assets/foo.css">. - Do not add inline
<script>/<style>blocks or inline event-handler attributes; they will be blocked by the CSP. If inline code is ever truly required, prefer switching to a CSP nonce over reintroducing'unsafe-inline'.
| Component | Choice | Notes |
|---|---|---|
| Runtime | Node.js | Uses native ESM imports and environment variables through process.env. |
| Web framework | Fastify | Fastify supports schema-based route validation and explicit route handling. |
| Static files | @fastify/static |
With serve: false, files are only exposed through explicit routes. |
| Database | SQLite | Simple embedded storage, appropriate for a small single-app deployment. |
| SQLite driver | better-sqlite3 |
Supports straightforward prepared statements and direct access patterns. |
| Security headers | @fastify/helmet |
Applies a strict Content Security Policy, Referrer-Policy: no-referrer, and X-Frame-Options: DENY. |
- Anonymous text submission form.
- No public login flow.
- Sunday-only unread review gate.
- Separate reviewed and unread views in admin.
- Randomized public and admin paths via environment variables.
- Admin page and admin API protected by a shared
ADMIN_TOKENsecret. - Strict Content Security Policy and other security headers via
@fastify/helmet(no'unsafe-inline'for scripts or styles). - Per-IP rate limiting on
POST /api/feedbackvia@fastify/rate-limit. - Startup logs that can print the full public and admin URLs.
- Minimal database schema.
anonymous-feedback/
data/
feedback.sqlite
public/
admin.html
admin.css
admin.js
index.html
index.css
index.js
src/
data.js
server.js
test/
server.test.js
package.json
- A person opens the randomized public feedback URL.
- They type plain-text feedback into a textarea.
- The browser sends a
POST /api/feedbackrequest. - The server validates the request body using a Fastify schema and stores the text with
reviewed = 0.
- An admin opens the randomized admin URL.
- The admin page requests unread feedback from
/api/admin/feedback/unreviewed, sending thex-admin-tokenheader. - If the token is missing or wrong, the server returns
401. - If the current server day is not Sunday, the server returns
403and the page shows a locked message. - If it is Sunday, unread feedback is returned.
- When the admin marks an item as reviewed, the app updates that row to
reviewed = 1. - Reviewed items are available from
/api/admin/feedback/reviewed(also token-protected).
This version intentionally avoids a submission timestamp to reduce stored timing metadata. The Sunday delay is implemented as an application rule rather than a per-record release schedule, which keeps the system simpler at the cost of less granular delayed-release logic.
The application uses a single table:
CREATE TABLE IF NOT EXISTS feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body TEXT NOT NULL,
reviewed INTEGER NOT NULL DEFAULT 0 CHECK (reviewed IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
reviewed_at TEXT
);SQLite does not provide a dedicated Boolean storage type in the way many other databases do, so INTEGER with 0 and 1 is the practical pattern for a field like reviewed.
created_at and reviewed_at are lightweight audit columns stored as ISO-8601 UTC strings. They are set by the database (via strftime) so the app never has to trust a client clock. created_at records when a feedback row was inserted; reviewed_at is NULL until an admin marks the row as reviewed. Existing databases created before these columns existed are migrated in place at startup (ALTER TABLE ... ADD COLUMN, backfilled with the current timestamp).
Note: created_at is a per-row insertion timestamp, not a user-visible submission time. It is used only for internal auditing (e.g. backup/restore verification, spam-rate investigation) and is never returned by the public POST /api/feedback endpoint. Admin endpoints currently do not surface it either — see “Why there is no submitted_at” above.
src/data.js applies these pragmas at startup:
| Pragma | Value | Reason |
|---|---|---|
journal_mode |
WAL |
Allows readers to proceed while a single writer commits — important because Fastify is concurrent. |
busy_timeout |
5000 (ms) |
On lock contention, SQLite waits up to 5 s instead of immediately throwing SQLITE_BUSY. Dramatically reduces spurious errors under bursty writes. |
synchronous |
NORMAL |
Safe with WAL and noticeably faster than FULL. |
foreign_keys |
ON |
Enforces FK constraints for any future related tables. |
The database initialization is designed to fail fast with actionable errors instead of leaving the process in a half-working state:
- Missing / unwritable data directory —
src/data.jscallsfs.mkdirSyncandfs.accessSync(W_OK)before opening SQLite. If the directory cannot be created or is read-only, the process exits with a message pointing at the exact path and (in Docker) thenodeuser permission requirement. - Corrupt or unreadable database file —
new Database(dbPath)is wrapped in atry/catchthat re-throws with the file path included. The container will crash-loop instead of accepting writes into a broken file. - Runtime DB failure — the
/healthzendpoint (whenENABLE_HEALTHCHECK=true) callspingDatabase()and returns HTTP503if a trivialSELECT 1fails, so orchestrators (Docker Compose, Kubernetes, Dokploy) can mark the container unhealthy and restart it.
The database lives in a single directory (/app/data in the container, data/ in local development) that contains feedback.sqlite plus its WAL/SHM sidecar files. Because SQLite uses a WAL, do not copy feedback.sqlite alone with cp while the app is running — you will get a torn snapshot missing the most recent commits.
better-sqlite3 exposes SQLite's official Online Backup API, which produces a consistent snapshot without stopping the app:
# Inside the running container (writes to the same volume):
docker compose exec app node -e "
const Database = require('better-sqlite3');
const db = new Database(process.env.DB_PATH, { readonly: true });
db.backup(process.env.DB_PATH + '.backup')
.then(() => { console.log('backup ok'); process.exit(0); })
.catch((e) => { console.error(e); process.exit(1); });
"
# Then copy the snapshot out of the container:
docker compose cp app:/app/data/feedback.sqlite.backup ./feedback-$(date +%F).sqliteThe resulting file is a self-contained SQLite database (no WAL needed) and can be opened with the sqlite3 CLI, DB Browser for SQLite, etc.
If the app is stopped, the on-disk file is consistent and can be copied directly:
docker compose stop app
docker compose cp app:/app/data/feedback.sqlite ./feedback-$(date +%F).sqlite
docker compose start appAlways restore into a stopped container so no WAL is being written:
docker compose stop app
docker compose cp ./feedback-YYYY-MM-DD.sqlite app:/app/data/feedback.sqlite
# Remove stale WAL/SHM sidecars so SQLite rebuilds them from the restored file:
docker compose run --rm --entrypoint sh app -c 'rm -f /app/data/feedback.sqlite-wal /app/data/feedback.sqlite-shm'
docker compose start appOn startup the app will fail fast if the restored file is unreadable or the directory has the wrong permissions — check docker compose logs app for the exact error.
For disaster-recovery snapshots of the whole volume (schema + data + any future files), tar the named volume from a throwaway container:
docker run --rm \
-v anonymous-feedback_anonymous_feedback_data:/data:ro \
-v "$PWD":/backup \
busybox tar czf /backup/anonymous_feedback_data-$(date +%F).tar.gz -C /data .Adjust the volume name to match docker volume ls output (Compose prefixes it with the project name).
| Method | Route | Purpose |
|---|---|---|
POST |
/api/feedback |
Submit anonymous feedback. |
GET |
/api/admin/feedback/unreviewed |
Get unread feedback, but only on Sunday. Requires x-admin-token. |
GET |
/api/admin/feedback/reviewed |
Get reviewed feedback. Requires x-admin-token. |
POST |
/api/admin/feedback/:id/review |
Mark one item as reviewed. Requires x-admin-token. |
Request body:
{
"body": "This is anonymous feedback"
}Validation rules:
bodyis required.bodymust be a string.bodymust be at least 10 characters.bodymust be no more than 3000 characters.- Extra properties are rejected.
Fastify recommends schema-based validation for routes, which keeps request rules close to the endpoint definition.
Success response:
{
"ok": true
}Example error response (validation failure):
{
"ok": false,
"error": "Invalid request."
}Validation errors are intentionally reported with a generic message so that request-body details are not echoed back in responses or logs.
- Node.js 20+ is recommended.
- npm is required.
- SQLite is not required as a separate service because the app uses an embedded SQLite database file.
- Clone or copy the project.
- Open a terminal in the project folder.
- Install dependencies:
npm installnpm install fastify @fastify/static @fastify/helmet better-sqlite3The app can be configured with environment variables.
| Variable | Purpose | Example |
|---|---|---|
PORT |
Port the Node app listens on | 3000 |
HOST |
Bind address for the Node app | 127.0.0.1 |
PUBLIC_BASE_URL |
Base URL used in startup logs | https://feedback.example.com |
PUBLIC_PATH |
Randomized public feedback path | /f/1c3f4d9a7b21e8d44f8c1a0b |
ADMIN_PATH |
Randomized admin path | /r/8aa2e1f4d7c903b18d2f6c55 |
ADMIN_TOKEN |
Secret token required for the admin page and admin API (min 16 chars). Sent only as the x-admin-token request header — query-string tokens are not accepted. |
s3cret-admin-token-please-change |
LOG_PUBLIC_URL |
Whether to print the full public feedback URL (including PUBLIC_PATH) at startup. Defaults to false when NODE_ENV=production, and true otherwise. Set to true temporarily to debug on a production host, then unset. |
false |
LOG_ADMIN_URL |
Removed. The admin review URL is never printed to logs, regardless of environment. Read ADMIN_PATH from your .env on the host to obtain it. |
— |
DB_PATH |
Override the SQLite database file path. Defaults to data/feedback.sqlite. Primarily used by the test suite to isolate a temporary database. |
/tmp/afb-test/feedback.sqlite |
FEEDBACK_RATE_MAX |
Max POST /api/feedback submissions per IP per window. Defaults to 7. |
5 |
FEEDBACK_RATE_WINDOW |
Time window for FEEDBACK_RATE_MAX. Accepts @fastify/rate-limit duration strings or milliseconds. Defaults to 1 minute. |
1 minute |
ENABLE_HEALTHCHECK |
Register the GET /healthz liveness probe. Defaults to false. When true, responds with {"ok":true} — no auth, no secrets, no DB access. Intended for container-internal healthchecks (Docker Compose, Kubernetes, Dokploy). Do not expose /healthz through the public reverse proxy unless you add an IP allowlist. |
false |
ENABLE_DEBUG_ROUTES |
Register the GET /debug/routes diagnostic endpoint. Defaults to false. Even when true, the endpoint requires the x-admin-token header (ADMIN_TOKEN) and its response never includes PUBLIC_PATH, ADMIN_PATH, ADMIN_TOKEN, raw environment variables, or database contents. Enable only briefly on a scratch instance. |
false |
Node exposes environment variables through process.env, and current Node versions also support loading them from a file with --env-file.
PORT=3000
HOST=127.0.0.1
PUBLIC_BASE_URL=http://localhost:3000
PUBLIC_PATH=/f/1c3f4d9a7b21e8d44f8c1a0b
ADMIN_PATH=/r/8aa2e1f4d7c903b18d2f6c55
ADMIN_TOKEN=s3cret-admin-token-please-change
LOG_PUBLIC_URL=falseUse a strong random string instead of a human-readable path name:
openssl rand -hex 12This produces a 24-character hex string that can be used as part of the public or admin path.
Example:
PUBLIC_PATH=/f/1c3f4d9a7b21e8d44f8c1a0b
ADMIN_PATH=/r/8aa2e1f4d7c903b18d2f6c55
ADMIN_TOKEN=s3cret-admin-token-please-changeUse the same command (openssl rand -hex 32) to generate a strong ADMIN_TOKEN.
npm startnode --env-file=.env src/server.jsThe repository includes a Dockerfile and a docker-compose.yml so the app can be launched with a single command.
-
Create a
.envfile (copy from.env.exampleand set strong values forPUBLIC_PATH,ADMIN_PATH, andADMIN_TOKEN). -
Build and start the container:
docker compose up -d --build
-
The app will be available on
http://localhost:${PORT:-3000}. -
The SQLite database is persisted on the host under
./data/feedback.sqlitevia a bind mount.
To stop and remove the container:
docker compose downNotes:
- Inside the container,
HOSTis forced to0.0.0.0andDB_PATHto/app/data/feedback.sqlite; other variables (includingPUBLIC_BASE_URL,PUBLIC_PATH,ADMIN_PATH,ADMIN_TOKEN) are read from.env. - The published host port defaults to
3000and can be overridden with thePORTvariable in.env.
Server listening on http://localhost:3000
Public feedback URL logging disabled (set LOG_PUBLIC_URL=true to enable).
Admin review URL is not logged. Read ADMIN_PATH from your .env file to obtain it.
The app is designed so that sensitive operational data — randomized PUBLIC_PATH and ADMIN_PATH, the ADMIN_TOKEN, and any query strings — cannot appear in logs by default. OWASP guidance warns that log aggregators, container stdout, and archived log files should not be trusted with secrets.
What is logged:
- A single
Server listening on <PUBLIC_BASE_URL>line at startup. - For 5xx errors, a
[error] <METHOD> <ROUTE_PATTERN> -> <STATUS>line, where<ROUTE_PATTERN>is the Fastify route template (e.g./api/admin/feedback/:id/review) — neverrequest.url, so randomized paths, ids, and query strings are not written to logs. - Shutdown signals (
SIGINT/SIGTERM).
What is never logged:
- The admin URL /
ADMIN_PATH(regardless ofNODE_ENV). ADMIN_TOKENor any request header.- Raw request URLs, query strings, request bodies, or client IPs.
- Feedback submissions.
Defaults per environment:
NODE_ENV=production:LOG_PUBLIC_URLdefaults tofalse. The public URL line is replaced with aPublic feedback URL logging disablednotice.- Non-production (local dev):
LOG_PUBLIC_URLdefaults totruefor convenience.
If you need to confirm the public URL a running production instance is serving, or diagnose a startup issue, do the following on the host — not by adding permanent config:
- Export the flag inline for a single run, e.g.
LOG_PUBLIC_URL=true node src/server.js(ordocker compose run --rm -e LOG_PUBLIC_URL=true app). - Copy the URL from stdout.
- Do not commit
LOG_PUBLIC_URL=trueto your.envon a production host, and do not enable a global request logger. If you must enable Fastify's built-in request logger (logger: trueinsrc/server.js) for deep debugging, do it on a scratch instance, redirect logs to a file readable only by you, and revert the change before redeploying.
There is no supported way to log the admin URL — that is deliberate. Read ADMIN_PATH directly from the host's .env file when you need it.
- Open the public feedback URL.
- Type feedback into the textarea.
- Submit the form.
- On success, the page shows a generic confirmation message.
There is no reply link, no receipt code, and no edit token. That keeps the public flow minimal and avoids introducing identifiers tied to a single submission.
- Open the randomized admin URL in a browser.
- The admin page prompts once for the admin token and keeps it in
sessionStoragefor the lifetime of the tab. The token is sent as thex-admin-tokenheader on every admin API call. - If it is Sunday, unread items are displayed.
- If it is not Sunday, the unread section remains locked.
- Reviewed items are shown in a separate list.
- Click Mark reviewed to move an item out of the unread list.
The admin page and admin API require the ADMIN_TOKEN secret. The server accepts it only as the x-admin-token request header. Query-string tokens (?token=...) are not accepted and, since v2, are rejected as unauthenticated.
In a browser:
- Open the randomized admin URL (without any token in the URL).
- When prompted, paste the
ADMIN_TOKENvalue. It is stored insessionStoragefor the tab and included on every admin API call asx-admin-token. - To clear it, close the tab or run
sessionStorage.removeItem("adminToken")in the browser dev tools.
Why no query-string token:
- Tokens in URLs leak into browser history,
Refererheaders, reverse-proxy access logs, and error logs. - The header-only flow keeps the secret out of the address bar and out of any URL-based log line.
- Rotate
ADMIN_TOKENin your.envif you suspect it has been exposed.
For non-browser clients (e.g. curl), send the token as a header:
curl -H "x-admin-token: your-admin-token" http://localhost:3000/api/admin/feedback/reviewedThe project includes a small node:test suite that covers the most important behaviors of the HTTP API. Tests use Fastify's in-process app.inject() — no ports are bound and no network requests are made.
Run the suite with:
npm testWhat is covered:
POST /api/feedbackaccepts a valid payload and rejects too-short input (schema validation).GET /api/admin/feedback/unreviewedis blocked with403on non-Sunday (the current date is stubbed to a Monday for the test).GET /api/admin/feedback/reviewedrequires a validx-admin-tokenheader (401without it,200with it).POST /api/admin/feedback/:id/reviewmarks an item as reviewed and returns404for an unknown id.
Implementation notes:
src/server.jsexports abuildApp()factory so tests can construct a fresh app without triggeringlisten()or signal handlers. The auto-start block only runs when the file is executed directly (node src/server.js).src/data.jshonors aDB_PATHenvironment variable so the test suite can point at a temporary SQLite file (created under the OS temp directory and cleaned up afterwards).- The test file sets
PUBLIC_PATH,ADMIN_PATH,ADMIN_TOKEN, andDB_PATHbefore importing the server modules, so the app boots in an isolated, deterministic configuration.
To keep the public submission endpoint resistant to floods and spam while preserving a friction-free UX (no CAPTCHA, no login), the app applies a per-IP rate limit on POST /api/feedback using @fastify/rate-limit.
Defaults:
FEEDBACK_RATE_MAX = 7submissionsFEEDBACK_RATE_WINDOW = "1 minute"
When the limit is exceeded, the server responds with HTTP 429 and a minimal JSON body:
{ "ok": false, "error": "Too many requests. Please retry in 42s." }Because the app runs behind Traefik, Fastify is started with trustProxy: true so request.ip reflects the real client IP from X-Forwarded-For instead of the proxy address. The rate limiter uses request.ip as its key, and its error responses intentionally do not include the client IP or the request body, keeping logs free of sensitive data.
Only POST /api/feedback is rate-limited (the plugin is registered with global: false); admin endpoints are unaffected and remain gated by ADMIN_TOKEN.
Tuning:
- Lower
FEEDBACK_RATE_MAX(e.g.5) for stricter protection against bursty spam. - Raise it (e.g.
20) if you expect legitimate users on a shared NAT (schools, offices) to submit multiple items in quick succession. FEEDBACK_RATE_WINDOWaccepts human-readable strings ("30 seconds","5 minutes") or a number of milliseconds.
Hidden routes and static-file behavior
The app uses @fastify/static with serve: false, which means static files are not exposed automatically by filename. This allows reply.sendFile() to work only for the explicit routes registered by the application, reducing accidental exposure of index.html or admin.html under default paths.
This means the intended behavior is:
/returns404./adminreturns404./index.htmlreturns404.- Only the randomized public path serves the feedback page.
- Only the randomized admin path serves the admin page.
This app is designed to run behind a reverse proxy (Traefik, as configured by Dokploy). A few assumptions follow from that:
- Bind address. Inside the container the app listens on
0.0.0.0:${PORT:-3000}. Traefik reaches it on the internal Docker network; the container port is not published to the host in production. - Trusted proxy. Fastify is initialized with
trustProxy: true(seesrc/server.js). This means:request.ipreflects the real client IP taken fromX-Forwarded-For(the left-most non-proxy address), not Traefik's internal container IP.request.protocolandrequest.hostnamefollowX-Forwarded-Proto/X-Forwarded-Host, so redirects and logs use the externally visible scheme/host.- The per-IP rate limiter (
@fastify/rate-limit, see the "Abuse protection" section) keys onrequest.ip, so limits are applied per real client IP, not per proxy.
- Only trust proxies you control.
trustProxy: trueis safe here because the only network path to the app is through Traefik on the Docker network. Do not publish the container port directly to the internet withtrustProxy: trueenabled — clients could then forgeX-Forwarded-Forand bypass the rate limiter. - Entrypoints. The Dokploy/Traefik router is currently attached to the
web(HTTP, port 80) entrypoint because the deployment domain (e.g. ansslip.iohostname) does not have a TLS certificate. Once the app is moved to a domain with a valid certificate, the router should be switched to thewebsecure(HTTPS, port 443) entrypoint and a redirect fromweb→websecureshould be added. PUBLIC_BASE_URL. In HTTP-only deployments this ishttp://<host>. Once TLS is available it must be updated tohttps://<host>so that links printed at startup and any absolute URLs match the entrypoint clients actually reach.
When the deployment domain gains a valid TLS certificate (Let's Encrypt via Traefik, or an uploaded cert), work through this list:
- Confirm Traefik has a working certresolver (e.g.
letsencrypt) and that the domain resolves to the Traefik host. - In Dokploy (or the Traefik dynamic config), switch the router's
entrypointsfromwebtowebsecureand settls.certresolver(ortls: truewith an uploaded cert). - Add a second router on the
webentrypoint that redirects everything tohttps://(Traefikredirectschememiddleware,scheme=https,permanent=true). - Update
PUBLIC_BASE_URLin the deployment's.envto thehttps://…form and redeploy. - Verify externally:
curl -I http://<host>/<PUBLIC_PATH>returns a301/308tohttps://….curl -I https://<host>/<PUBLIC_PATH>returns200and a valid certificate.- The startup log line
Server listening on https://<host>matches the certificate's CN/SAN.
- Consider enabling HSTS at Traefik (
Strict-Transport-Security) once you're confident the domain will stay on HTTPS. Do not enable HSTS while still testing on plain HTTP — browsers will remember it. - Optionally, add a production-only guard so the app refuses to start when
NODE_ENV=productionandPUBLIC_BASE_URLdoes not begin withhttps://. This is intentionally not enforced today because the currentsslip.io-style host is HTTP-only.
This application aims for simplicity and privacy, but it is not a complete hardened anonymous reporting system. Hidden URLs are not a replacement for access control, and the admin route should be protected with a reverse proxy and authentication.
Recommended deployment protections:
- Put the Node app behind Caddy or Nginx.
- Protect the admin route with HTTP basic auth or stronger access control.
- Prefer binding the app to
127.0.0.1when the reverse proxy runs on the same machine. - Avoid request-body logging.
- Avoid third-party analytics, cookies, and other tracking features.
Binding to 0.0.0.0 exposes the app on all IPv4 interfaces, while 127.0.0.1 keeps it local to the machine. For a same-box reverse proxy setup, 127.0.0.1 is usually the safer default.
For local development, this is a useful configuration:
PORT=3000
HOST=127.0.0.1
PUBLIC_BASE_URL=http://localhost:3000
PUBLIC_PATH=/f/local-test-path
ADMIN_PATH=/r/local-admin-path
ADMIN_TOKEN=local-dev-admin-token-please-changeThen verify behavior:
curl -i http://localhost:3000/
curl -i http://localhost:3000/index.html
curl -i http://localhost:3000/f/local-test-path
curl -i http://localhost:3000/r/local-admin-pathExpected results:
/should return404./index.htmlshould return404.- The randomized paths should return
200.
If Node reports ERR_MODULE_NOT_FOUND, check that all referenced files exist at the exact paths used in imports. With ESM, relative imports are resolved literally, including filename and extension.
Examples:
./data.jsrequires a file named exactlydata.js.Data.jsis not the same asdata.json Linux.- A file created in an editor but not saved to disk will still be treated as missing.
If http://localhost:3000/ still serves the public page, @fastify/static is probably still auto-serving files from public/. Set serve: false in the static registration block so files are only served through explicit routes.
Example:
await app.register(fastifyStatic, {
root: publicDir,
serve: false
});If /index.html still works, one of these is likely true:
serve: falseis not actually active.- A custom route explicitly serves
index.html. - A reverse proxy is serving static files directly.
- A catch-all route or not-found handler is returning
index.html.
Unread feedback is only available when the server thinks it is Sunday. Check the server timezone and current date if the route appears locked unexpectedly.
If HOST=0.0.0.0 is set, the application listens on all IPv4 interfaces. Change it to 127.0.0.1 if the app should only be reachable from the local machine or from a same-host reverse proxy.
- No attachments.
- No threaded replies.
- No anti-spam system.
- No moderation queue beyond the
reviewedflag. - No per-item release schedule.
- No search.
- No user accounts.
- No true network-layer anonymity guarantee.
These limitations are deliberate in many cases because each added feature increases complexity, metadata, and privacy risk.
- Add reverse-proxy authentication for the admin route.
- Add TLS termination at Caddy or Nginx.
- Add automated backups for
data/feedback.sqlite. - Add a private deployment runbook.
- Add optional export to Markdown or CSV.
- Add light abuse protection only if needed, while being careful not to introduce new tracking surfaces.
- Expand the test suite (e.g. helmet/CSP headers, Sunday-allowed unread path, rate limiting once added).
MIT License. Copyright (c) 2026 Christopher Johnson.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files to deal in the Software without restriction, subject to the license terms in LICENSE.