I built a small static host (plopino.com) with a boring
premise: drag a file onto a page, get a link. No account for the basic flow.
The feature list is short. What took the time was a handful of specific
problems. Here they are, in case they save you the same afternoon.
1. Path-based hosting means every relative link is your problem
Most drop-style hosts give each project a subdomain:
my-project.host.com. I went the other way. Every board lives under a path:
https://plopino.com/b/<boardId>/...
One route serves it. One TLS certificate. No per-board DNS, no wildcard cert
renewal, no waiting on propagation — the whole routing and TLS story stays
simple.
The bill comes due on relative links. A board is arbitrary user HTML, so a page
at /b/<id>/sub/page.html can contain anything:
<a href="../../other.html">
<img src="//cdn.example.com/x.png">
<a href="/absolute/path">
<a href="javascript:void(0)">
I need to know which of those stay inside the board and which don't, because
that decision drives both storage accounting and whether a board can reach
outside its own tree. The rule I landed on: data:, javascript:, mailto:,
tel:, blob:, protocol-relative (//x) and root-relative (/x) URLs are all
"not board-internal". Everything else resolves against the board root.
Root-relative is the one that trips people up. /style.css inside a board looks
like it should work, and it "works" in the sense that the browser requests
https://plopino.com/style.css — which is my site's path space, not the
board's. Treating it as external is the only safe reading.
2. Streaming the upload, and streaming the zip
The upload path uses Busboy with a couple of settings that matter:
Busboy({ headers: req.headers, preservePath: true, defParamCharset: 'latin1' })
preservePath is what makes folder drags work. When you drag a directory into a
browser, the multipart parts carry relative paths (site/css/main.css), and if
you let the parser flatten them to basenames you have thrown away the structure
before you ever see it.
Each file part streams straight to a temp file. Nothing is buffered in memory —
uploads are large enough that buffering a zip only to decompress it afterwards
would double peak memory for no benefit.
Zip extraction goes through yauzl in lazy mode:
yauzl.open(zipPath, { lazyEntries: true, decodeStrings: false }, ...)
lazyEntries keeps one entry in flight at a time instead of materialising the
central directory as an array of objects, which matters when someone uploads a
zip with a few thousand files. decodeStrings: false is the interesting one —
see below.
3. Filename encoding is still a mess in 2026
This is the part I underestimated.
Zip entries don't have a mandated encoding for filenames. The spec says CP437
unless a UTF-8 flag is set, but in practice you receive whatever the producing
tool felt like writing — and on Chinese Windows systems that frequently means
GB18030.
Meanwhile multipart filename parameters are specified as Latin-1-ish, with
RFC 5987's filename* as the escape hatch for anything else. Browsers are
inconsistent about which they send.
So there are two decode paths that have to agree:
-
filename*(RFC 5987) — Busboy already decodes this to Unicode correctly, use it as-is. - Plain
filename— Busboy preserves the raw bytes as Latin-1, so I take those bytes and try UTF-8 first, falling back to GB18030 detection.
If you skip this, a user with a Chinese-locale machine uploads a folder and
every filename comes out as mojibake. From their side it looks like the site
corrupted their files.
4. Persistent workers, not per-request processes
Board thumbnails and Office previews both need a real renderer. The obvious
implementation — spawn one per request — is also the one that makes previews
feel slow and pins the CPU.
Both run in long-lived pools instead:
- Office (docx / xlsx / doc) previews render in a persistent worker pool and are cached, so nothing blocks the main thread.
- Thumbnails use one lazily-started headless Chromium, one page per task, at a fixed viewport, with a single-flight queue so a burst of new boards doesn't start a browser per request.
The thumbnail step also needed a bit of judgement that isn't obvious from the
outside: before screenshotting, it hides the preview page's own chrome (the
wordmark and download button) and collapses left-hand navigation. Otherwise
every card thumbnail has a tiny "Download" button burned into the corner,
and documentation-style boards show nothing but a sidebar.
5. A test that enforces translation key order
The UI ships in 20 languages including RTL Arabic and Persian.
The test that keeps this honest compares every dictionary against the English
one on three axes: the key set, the key order, and the placeholder names
inside each string.
assert.deepEqual(Object.keys(dict), EN_KEYS); // set AND order
assert.equal(placeholders(dict[key]), placeholders(EN[key]));
Key order is the unusual one. Nothing forces a JS object literal to keep the
same ordering as another, and nothing breaks at runtime if it drifts — but a
dictionary that has drifted is a dictionary someone edited by hand and then
stopped maintaining. Asserting order means the file layout itself is part of the
contract, so a missing translation fails the suite instead of silently rendering
an English string into a Persian page.
The test also validates BCP-47 tag shape, direction (ltr/rtl), and that the
default language sorts first.
One more thing that follows from having 20 languages: the server injects
<title>, description, og:* and hreflang per language. Crawlers and social
scrapers don't run JavaScript, so a client-side i18n layer is invisible to them.
The page declares data-i18n-doctitle / data-i18n-docdesc on <html>, and the
server renders the real head from the dictionary.
6. Quotas, and moderation that runs on reports
Anonymous uploads are rate-limited per IP per day, and the counters are
persisted — a deploy does not hand everyone a fresh allowance. Signed-in users
get a larger allowance.
Moderation is report-driven: an abuse contact, a takedown path, and reports get
acted on. I'm spelling that out because it is the most predictable question
about a service that hosts arbitrary HTML from strangers.
A related decision that took longer than it should have: are uploaded boards
indexable? Blocking them entirely is the safe-feeling default, but it's wrong in
a subtle way — robots.txt-blocked URLs can't be crawled, so a private board's
URL can linger in search results as a bare link with no context. The site now
serves X-Robots-Tag: noindex on /b/ by default and removes it for public,
undeleted boards. Public boards are linkable-and-crawlable when someone shares
them; private, deleted and expired ones stay out. The sitemap lists zero boards
either way — I'm not turning my sitemap into a directory of user content.
What I'd tell someone starting this
- Decide the URL shape on day one. Path vs subdomain changes the link-resolution code, the TLS story and the SEO story, and retrofitting it is miserable.
- Treat filenames as hostile input from at least three encodings.
- If you render anything user-supplied, keep the renderer warm and put a queue in front of it.
- Write the i18n test before the fourth language, not after the tenth.
The service is at plopino.com if you want to try the
actual thing. It's not open source right now — mostly because I don't want to
hand out deployable copies while the abuse story is still unfinished.
Top comments (0)