pr0h0

Fail-closed publishing: how this portfolio keeps drafts private

Nothing on this site becomes public unless I explicitly publish it. Here is how the publishing model, media handling and tests enforce that by design.

Abdulah Proho 3 min read

A portfolio looks like the simplest kind of website. Mine turned out to have one hard requirement: nothing becomes public unless I explicitly publish it. That applies to private projects, imported GitHub metadata, half-written articles, screenshots and internal notes.

Most CMSes treat visibility as a flag that templates are expected to respect. One forgotten if, one sitemap generator that loads every row, or one image URL that is easy to guess, and a draft leaks. Since I work in application security, I wanted publication to fail closed by design rather than by discipline.

This is how the new pr0h0.me handles it.

The stack#

The site is a single Go service:

  • net/http from the standard library, with no web framework
  • templ for server-rendered, auto-escaped components
  • htmx for the admin panel and live search, plus a few lines of vanilla JavaScript
  • SQLite in WAL mode
  • Tailwind CSS, compiled at build time

There is no SPA, no client-side state and no runtime CDN. The Content Security Policy allows scripts only from the site itself, with no inline scripts and no eval.

The draft is the row, the public site reads snapshots#

Every publishable thing — projects, articles, updates, roles, certifications, even the homepage copy — follows the same rule:

  1. The database row is the draft. Saving changes the row and nothing else.
  2. Publish serialises the draft into a content_versions table inside one transaction and points the row at that version.
  3. The public site never reads draft columns. It only reads the snapshot that the row points to, and only while the row's status is published.

The query behind every public page looks roughly like this:

SELECT e.id, v.data
FROM projects e
JOIN content_versions v ON v.id = e.published_version_id
WHERE e.status = 'published'
  AND e.published_at <= :now

A few useful properties fall out of this:

  • Editing published content is safe. I can rewrite a project page over several days; the live version does not change until I publish again.
  • History and rollback come for free. Every save and every publish is a version. Restoring copies an old version back into the draft, and publishing stays a separate, explicit step.
  • Scheduling is a timestamp. A post with a future published_at simply is not returned yet.
  • New rows cannot start public. The status column defaults to draft, and a check constraint refuses published without a published version.

Allowlists, not filters#

Snapshots are decoded into dedicated public structs that contain only the fields meant for visitors. Internal notes never enter a snapshot in the first place. Source and demo URLs are copied into the public struct only when their visibility is set to public — a stored URL alone is not enough.

That matters for private repositories: I can keep a link in the admin panel without it ever reaching HTML, the sitemap, RSS, structured data or a generated image.

Media follows publication#

Uploaded images get random 128-bit identifiers and are re-encoded from pixels, which strips EXIF data and anything appended to the file. Whether an image is publicly served is decided on every request:

  • it is referenced by a currently published snapshot, or
  • it has been explicitly marked public (for example an avatar).

The reference list is rebuilt whenever something is published, unpublished or deleted. A screenshot attached to a draft project returns 404 to visitors and loads normally in the admin preview.

GitHub imports are suggestions#

The admin panel can fetch metadata for one explicitly entered public repository. The result is stored as a suggestion. Applying it copies only the fields I select into a project draft, and never changes publication status or link visibility. Private repositories are refused, and no background job can publish anything.

Caches have to forget#

Public pages, the generated CV PDF and Open Graph images are cached in memory. All of them are invalidated whenever publication state changes. Without that, unpublish would be a polite suggestion.

Testing the promise#

Unit tests are not enough for a property like "drafts never leak", so the integration suite creates a draft with a unique marker, private notes and a hidden source URL, then fetches every public surface: pages, search results, the sitemap, the RSS feed, OG images and the PDF. The marker must not appear anywhere until the draft is published, and must disappear again after it is unpublished.

The same suite covers unauthenticated access to every admin route (including htmx partials), CSRF on every mutation, Markdown XSS, dangerous URLs, upload bypass attempts, session expiry and login throttling.

Small, boring, predictable#

The whole thing runs as one container on a modest server, behind a Cloudflare tunnel. It has no queue, no cache server and no separate search service. It does exactly one job: show what I have chosen to publish, and nothing else.