# nexCMS: Building a CMS With Nothing But WSGI and Stubbornness ## What it is nexCMS is a single-file Python WSGI application that runs this site. No Django, no Flask, no Node — just `wsgi.py` talking directly to the WSGI spec, a folder of JSON files acting as the database, and a vanilla JavaScript frontend pulling from a handful of REST-ish endpoints. The whole content model is four kinds of objects: pages (the sticky notes), todos, links, and site settings. That's it. The honest reason to build it this way wasn't "no CMS does what I want." It's that I wanted to know what a CMS actually *is* under the hood — routing, sessions, auth, a data layer — instead of trusting that a framework handles those correctly for me. Which meant hitting, and fixing, all the ways they can go wrong myself. ## Architecture at a glance ``` Request comes in | v application(environ, start_response) <- single entry point, no router library | v Manual if/elif chain over PATH_INFO + REQUEST_METHOD | +--> Static files (/static/, /themes/, /img/, or by extension) +--> Public pages (/, /page/) +--> Admin (session-gated, serves admin.html) +--> API routes (/api/pages, /api/todos, /api/links, /api/settings, /api/login...) | v Data layer: flat JSON files under data/ | +--> data/pages/.json (one file per note) +--> data/todos.json +--> data/links.json +--> data/settings.json +--> data/sessions.json ``` There's no ORM, no template engine, no request router library. `application()` is one long chain of `if path == X and method == Y` checks. It's not elegant, but it's also not hiding anything — every route is a straight line from URL to handler, which made debugging genuinely easier than I expected once things started breaking. ## The data layer: JSON files as a database Every "note" on the board is its own JSON file in `data/pages/`, named by slug: ```python def _save_page(slug, data): if not slug or '..' in slug or '/' in slug: return False try: filepath = PAGES_DIR / f'{slug}.json' with open(filepath, 'w', encoding='utf-8') as f: json.dump(data, f, indent=2, ensure_ascii=False) return True except Exception as e: _log(f"❌ Failed to save {slug}: {e}") return False ``` Reading all pages is just globbing the directory and sorting by timestamp: ```python def _get_all_pages(): pages = [] for f in PAGES_DIR.glob('*.json'): data = _safe_json_load(f) if data: pages.append(data) pages.sort(key=lambda p: p.get('created_at',''), reverse=True) return pages ``` This is a genuinely fine approach for a single-user, low-write-volume site. It's also a real database with real database problems if you don't respect that: - **No transactions.** If the process dies mid-write, you get a truncated or empty JSON file for that page. `_safe_json_load` swallows the parse failure and returns `None`, so a corrupted file just silently disappears from the board rather than crashing anything — which is the right failure mode for content, but worth knowing. - **No concurrency control.** `sessions.json`, `todos.json`, and `links.json` are each a single file, read-modify-written as a whole. Two near-simultaneous writes (say, an admin session save racing a login token cleanup) can clobber each other — last write wins, silently. On a single-admin personal site this is a low-probability problem. It would not scale to concurrent editors without adding real locking. - **The slug is the primary key**, and it's derived from the title (`_slugify`), with collision handling by appending `-1`, `-2`, etc. Rename the title later and the slug doesn't follow — which is correct behavior (you don't want to break existing links to a page) but means slug and title can drift apart over time, silently. None of this is a criticism of the approach — it's the tradeoff you accept when you choose "understand every byte" over "let Postgres handle it." It's just worth naming honestly rather than pretending flat files are a database with no asterisks. ## Auth and sessions Login is username/password against a single credentials file, hashed with salted SHA-256: ```python def _verify_credentials(username, password): creds = json.loads(CREDENTIALS_FILE.read_text()) if username != creds.get('username'): return False salt = creds.get('salt', '') return hashlib.sha256((password + salt).encode()).hexdigest() == creds.get('password_hash') ``` This is worth being straight about: salted SHA-256 is *not* what you'd want for a password hash defending against an offline brute-force attempt if the credentials file ever leaked — SHA-256 is fast, which is exactly the wrong property for a password KDF. `bcrypt`, `scrypt`, or `argon2` are the correct tools here, deliberately slow to make brute-forcing expensive. For a single-admin personal site where the credentials file lives outside the webroot and isn't otherwise exposed, the practical risk is low — but if this pattern gets reused anywhere with more at stake, that's the first thing to swap out. Sessions are opaque tokens (`secrets.token_hex(32)` — this part's solid, cryptographically random) stored in a flat JSON dict mapping token → `{user, expires}`, set as an `HttpOnly`, `SameSite=Strict` cookie. Expired sessions get swept on next write. Reasonable for the scale this runs at. ## Routing: the dispatch table that isn't There's no route registry, no decorators, no pattern matching library. Every route is a literal string or prefix check, evaluated top to bottom: ```python if path in ('/','/index.html') and method == 'GET': ... if path.startswith(('/static/', '/themes/', '/img/')) or path.endswith(('.css', '.js', ...)): ... if path.startswith('/page/') and method == 'GET': ... ``` The tradeoff: adding a route means finding the right spot in a growing chain rather than declaring it somewhere clean. What you get in exchange is that there's no routing magic to debug — the entire request lifecycle for any URL is readable top to bottom in one file, which mattered a lot when I was chasing the silent-failure bugs described below. ## The frontend: no framework, no build step `app.js` is a single IIFE (`const CMS = (() => {...})()`) that fetches JSON from the API on load and renders everything client-side — the note board, calendar, todo list, links widget. No React, no bundler, no npm install. `` and that's the entire build pipeline. The markdown renderer inside a page's content is intentionally minimal — a handful of regex passes for bold, italic, inline code, headers, and images: ```javascript h = h.replace(/\*\*(.+?)\*\*/g, '$1'); h = h.replace(/\*(.+?)\*/g, '$1'); h = h.replace(/`(.+?)`/g, '$1'); h = h.replace(/^### (.+)$/gm, '

$1

'); ``` The whole Regex experience has been quite defeating at times. Regex-based markdown is a known trap — it breaks on nested or overlapping syntax and has no real parse tree — but for content I'm writing myself, in a format I control, it's a reasonable 80/20 tradeoff against pulling in a full markdown library for a handful of formatting patterns. Output escaping happens consistently through one function: ```javascript function esc(t){return t?t.replace(/&/g,'&').replace(//g,'>'):''} ``` Every user-controlled string rendered into the DOM — titles, excerpts, tags, link titles — goes through `esc()` before insertion. That's the instinct: centralize escaping in one place instead of remembering to do it per-call-site. ## Theming Themes are separate CSS files (`/themes/professional.css`, `/themes/cyberpunk.css`, `/themes/tech.css`) swapped by changing a single `` tag's `href` and persisted to `localStorage`: ```javascript function applyTheme(theme) { const link = document.getElementById('theme-stylesheet'); if (link) link.href = `/themes/${theme}.css`; localStorage.setItem('nexuscms_theme', theme); } ``` This is exactly right for the problem — it means theming genuinely is trivial, as long as every visual property is expressed as a CSS custom property (`--bg`, `--accent`, `--border`, etc.) rather than hardcoded into `style.css` or inline in markup. The base stylesheet does mostly follow that discipline. Where it slips — a few inline `style="color: var(--text-muted)"` attributes directly in `index.html` — those still resolve correctly against whichever theme is active, since they're referencing the variable rather than a literal color, so it holds up in practice even where it's not the cleanest pattern. ## The bugs that were actually worth hitting A few real failures came out of this build that were more educational than the successful parts: - **Silent error swallowing.** Early versions of the save functions didn't return values or proper status codes — a failed write to disk (permissions, disk full, bad JSON) would fail quietly and the frontend would just... not update, with no error surfaced anywhere. Fixing this meant threading actual return values and `500` responses through every write path (`_save_page`, `_save_todos`, `_save_links`, `_save_settings` all now return `bool` and the caller checks it — see the `if not _save_page(...)` pattern throughout `application()`). The lesson wasn't really about WSGI — it was a reminder that "it didn't crash" and "it worked" are different claims, and code that swallows exceptions without surfacing them is actively hostile to future-you at 11pm trying to figure out why a save isn't sticking. - **The startup write-permission self-test.** Shared/managed hosting means you don't always control the process's effective permissions. `_write_permission_check()` runs on startup, writes and deletes a test file in `DATA_DIR`, and logs immediately if it can't — so a permissions problem shows up in the log at boot instead of as a mysterious 500 the first time someone tries to save a page hours later. - **The `pinned` field bug.** A real, specific bug: pinned status wasn't round-tripping correctly through the update path. Small in isolation, but it's the kind of bug that only shows up once you're exercising the full read-modify-write cycle on real data, not just testing creation once and calling it done. ## What I'd change Being honest about the current tradeoffs, if this ever needed to grow past "single admin, personal site": - Swap SHA-256 for a proper password KDF on the credentials file - Add file locking (or move to SQLite, which gets you transactions without giving up the "no external DB server" simplicity) for the shared JSON files — `sessions.json`, `todos.json`, `links.json` — since those are the ones under repeated read-modify-write cycles - A real markdown parser if content formatting needs grow past what the regex passes handle cleanly None of these are urgent at the current scale. They're the honest list of "here's what I knowingly traded away for simplicity," not a list of things currently broken. ## The actual takeaway Frustrating, yes — chasing a silent failure through a save function with no error path is a bad afternoon. But every one of those bad afternoons mapped directly onto something a framework normally does invisibly: routing, session lifecycle, write-path error handling, escaping discipline. Building it by hand doesn't make any of that easier. It makes it visible. That's the whole point of the lab philosophy applied to software instead of hardware — you don't actually understand a system until you've been responsible for the part that's currently broken.