5 MBmax upload size
20uploads per 30 days
30 daysfree tier retention
1 hourpresigned URL TTL
01
1Upload
Authenticate
& Receive
bodyLimit runs before auth — oversized payloads are rejected before bcrypt even runs, keeping costs low.
🛡️
Body size gate
Hono's bodyLimit middleware enforces 5 MB before any auth logic. Requests over the limit return 413 immediately.
🔑
API key verification
The first 12 chars (prefix) are used for a fast indexed DB lookup. The full key is then bcrypt.compare()'d against the stored hash — never stored in plaintext.
📊
Rolling rate limit
A COUNT(*) query checks active uploads in the past 30 days. Hard 429 at 20 — fire-and-forget last_used_at update so the check stays fast.
📥
Multipart parse
Hono's parseBody() extracts file, title, description, and tags from the form. Missing file → 400.
passes raw HTML to scanner
5 MBhard body limit
20uploads / 30 days
12-charprefix index lookup
02
2Process
Scan, Sanitize
& Store
A two-stage security pipeline strips XSS vectors and redacts leaked secrets before the attribution footer is stamped and the file is written to R2.
Stage 1 — DOMPurify
1
Forbidden tags stripped script, link, meta, base, object, embed, iframe, form, input…
STRIP
2
Event handlers blocked onerror, onload, onclick, onmouseover + 10 more attrs
STRIP
3
URI allowlist enforced Only https: and data:image/ pass — javascript: URIs are blocked
STRIP
Stage 2 — Regex scanner
Credential patterns AWS keys, GitHub PATs, Stripe secrets, PEM certs, Bearer JWTs — high-entropy matches are redacted to [REDACTED].
Exfil beacons fetch() calls to non-allowlisted domains and navigator.sendBeacon() are flagged.
Prompt injection "" and zero-width character evasion patterns are detected and removed.
sanitized HTML ready to store
Storage pipeline
🏷️
Attribution footer injected
Fixed footer inserted before </body> — baked in at write time, not at serve time
☁️
R2 upload
Key: {userId}/{slug}.html · ContentType: text/html · ContentLength explicit
🗄️
DB record inserted
slug (10-char nanoid), expires_at (+30 days), size_bytes, tier='free'
🔄
Slug collision retry
PostgreSQL error 23505 → rollback R2, generate new slug, retry up to 3×
✓ 201 Created
Returns url, slug, expires_at, and sanitized flag
✕ 502 / 500
R2 failure or DB error — R2 object rolled back before returning
03
3Serve
LIVE
Validate
& Redirect
The R2 bucket is fully private. Every view goes through a DB lookup before a 1-hour presigned URL is issued — raw bytes are never proxied.
🔍
DB slug lookup
SELECT by slug WHERE deleted_at IS NULL. Soft-deleted explainers return 404 — indistinguishable from never-existed.
⏱️
Expiry check
expires_at < now → 410 Gone with styled page. null expires_at = never-expiring (reserved for Pro tier).
👁️
View count increment
Fire-and-forget UPDATE sets view_count + 1. Errors are logged as warnings only — never block the response.
🔗
Presigned URL & redirect
AWS SDK generates a 1-hour signed GET URL for the private R2 object. Server returns 302 — the browser fetches directly from Cloudflare's edge.
browser fetches from R2 edge
Not found Unknown slug or deleted record → 404 with branded page, no-store cache.
Expired expires_at in the past → 410 Gone. Styled upgrade prompt is shown.
Valid & live 302 redirect to presigned URL. No HTML bytes cross the app server.
1 hrpresigned URL TTL
privateR2 bucket
no-store— error responses never cached