Personal portfolio site with a file-based CMS, in-browser block editor, and S3-backed media/content persistence.
- Python 3.12+
- Node.js 20+
- ffmpeg (required for video processing)
- ImageMagick (optional; sprite generation falls back to ffmpeg)
uv sync
npm install# Build frontend bundles once
npm run build
# Start API/app server
uv run uvicorn main:app --reloadFor frontend changes during development, run npm run watch in a second terminal.
Core infra:
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=us-west-1
S3_BUCKET=billybjork.com
CLOUDFRONT_DOMAIN=d17y8p6t5eu2ht.cloudfront.net
STATIC_VERSION=Edit mode/auth:
EDIT_TOKEN=
COOKIE_SECRET=
LOCALHOST_EDIT_BYPASS=
CONTENT_STARTUP_SYNC_POLICY=alwaysEDIT_TOKEN: enables remote edit login at/edit/login.COOKIE_SECRET: required in production; used to signbb_editcookie.LOCALHOST_EDIT_BYPASS:- if
EDIT_TOKENis set, default isfalse - if
EDIT_TOKENis unset, default istrue(local-only workflow)
- if
CONTENT_STARTUP_SYNC_POLICY:always(default): always sync from S3 at startupguarded: sync from S3 only when canonical marker existsoff: skip startup S3 sync
- Local-only mode (no
EDIT_TOKEN): localhost editing works without login. - Remote mode (
EDIT_TOKENset): authenticate at/edit/login, logout at/edit/logout.
Admin APIs are gated server-side; UI visibility is controlled by server-auth state, not hostname checks.
Edits use optimistic locking with per-file revision hashes:
- Save request includes
base_revision - Server returns
409on mismatch - UI offers:
Keep mine(force save)Load theirs(reload server state)
Content files are still stored under content/, but are synchronized to S3:
- On save: write local file, then sync to S3
- On startup (all environments): hydrate
content/from S3, controlled byCONTENT_STARTUP_SYNC_POLICY - On delete: archive project markdown under
content-archive/in S3 before removal
This keeps a file-based workflow while surviving redeploys.
Recommended model: S3 is runtime source of truth, Git is backup/export.
Important: direct local edits under content/ are not canonical by themselves.
To publish those edits, run uv run python -m utils.content_sync seed (or save through the admin UI/API, which already writes to S3).
Before relying on startup S3 sync in a new environment, seed S3 explicitly:
uv run python -m utils.content_sync seedOptional strict seed that also removes stale keys:
uv run python -m utils.content_sync seed --delete-extraCheck marker/status:
uv run python -m utils.content_sync statusThe seed command uploads all content/ files and writes content/.s3-canonical.json in S3.
With default CONTENT_STARTUP_SYNC_POLICY=always, startup sync always uses S3 content in both localhost and production.
content/
├── about.md
├── assets.json
├── settings.json
└── projects/
└── {slug}.md
---
name: Project Title
slug: project-slug
date: 2024-01-15
draft: false
pinned: false
og_image: https://cdn.example.com/images/custom-og.webp # optional override
video:
hls: https://cdn.example.com/videos/slug/master.m3u8
thumbnail: https://cdn.example.com/videos/slug/thumb.webp # hero poster (always frame 0)
spriteSheet: https://cdn.example.com/videos/slug/sprite.jpg
youtube: https://youtube.com/watch?v=...
---
Markdown content here...All media is processed server-side and uploaded to S3/CloudFront.
| Type | Processing | Output |
|---|---|---|
| Images | Resize (max 2000px), convert | WebP @ 80% |
| Content videos | Compress | MP4 @ 720p, crf 28 |
| Hero videos | Full pipeline | HLS adaptive + sprite sheet + first-frame poster |
Canonical S3 prefixes used by edit mode:
images/project-content/images/misc/images/sprite-sheets/images/thumbnails/videos/{slug}/videos_mp4/
video.thumbnailis the hero poster and is always generated from frame0.og_imageis optional and independent from hero poster generation.- OG resolution priority is:
og_image(if set)video.thumbnailvideo.spriteSheet
To regenerate hero posters from frame 0 for all hero-video projects and clean up replaced orphaned assets:
uv run python tools/regenerate_hero_posters_first_frame.py --apply --cleanup-orphans/static/* includes a ?v= query param for cache busting.
- Set
STATIC_VERSIONin production (git SHA/deploy timestamp). - If unset, local file mtimes are used in development.
- Static responses use:
Cache-Control: public, max-age=31536000, immutable
For CloudFront/S3 assets, set equivalent long-lived cache headers and invalidate updated paths when needed.