patx/micropie
add simple blog example
Commit fec9688 · patx · 2025-12-29T01:08:54-05:00
Comments
No comments yet.
Diff
diff --git a/examples/blog/Procfile b/examples/blog/Procfile
new file mode 100644
index 0000000..eba0a67
--- /dev/null
+++ b/examples/blog/Procfile
@@ -0,0 +1,2 @@
+web: uvicorn app:app --host=0.0.0.0 --port=${PORT}
+
diff --git a/examples/blog/app.py b/examples/blog/app.py
new file mode 100644
index 0000000..702719e
--- /dev/null
+++ b/examples/blog/app.py
@@ -0,0 +1,375 @@
+from typing import Any, Dict, List, Optional
+from datetime import datetime, timedelta
+
+import markdown
+from micropie import App
+from bson import ObjectId
+from motor.motor_asyncio import AsyncIOMotorClient
+
+from middlewares.rate_limit import MongoRateLimitMiddleware
+from sessions.mongo_session import MkvSessionBackend
+
+
+MONGO_URI = "mongodb://localhost:27017"
+DB_NAME = "blogdb"
+COLLECTION_POSTS = "posts"
+COLLECTION_USERS = "users"
+USERNAME = "demo"
+FULLNAME = "John Smith"
+
+def serialize_post(doc: Dict[str, Any]) -> Dict[str, Any]:
+ """Convert a Mongo document into a JSON-friendly dict."""
+ created_at = doc.get("created_at")
+ if isinstance(created_at, datetime):
+ created_at_str = created_at.replace(microsecond=0).isoformat() + "Z"
+ else:
+ created_at_str = str(created_at) if created_at is not None else ""
+
+ return {
+ "id": str(doc.get("_id")),
+ "title": doc.get("title", ""),
+ "content": doc.get("content", ""),
+ "created_at": created_at_str,
+ "author_username": doc.get("author_username"),
+ }
+
+
+# ---------- Startup / shutdown ----------
+
+async def init_db():
+ try:
+ print("[init_db] starting init")
+ app.mongo_client = AsyncIOMotorClient(MONGO_URI)
+ app.db = app.mongo_client[DB_NAME]
+ app.posts = app.db[COLLECTION_POSTS]
+ app.users = app.db[COLLECTION_USERS]
+
+ # sanity ping
+ await app.db.command("ping")
+ print("[init_db] mongo ping OK")
+
+ await app.posts.create_index([("created_at", -1)])
+ await app.users.create_index("username", unique=True)
+
+ existing = await app.users.find_one({"username": USERNAME})
+ if not existing:
+ await app.users.insert_one(
+ {"username": "demo", "password": "demo"}
+ )
+
+ print("[init_db] finished without error")
+
+ except Exception as e:
+ import traceback
+ print("[init_db] ERROR!", repr(e))
+ traceback.print_exc()
+ raise
+
+async def close_db():
+ """
+ ASGI shutdown handler: close Mongo client.
+ """
+ app.mongo_client.close()
+
+
+class BlogApp(App):
+ # ---------- Helpers ----------
+
+ def _current_user_id(self) -> Optional[str]:
+ """
+ Return current user_id from session, or None.
+ """
+ sess = getattr(self.request, "session", None)
+ if not sess:
+ return None
+ return sess.get("user_id")
+
+ async def _get_current_user(self) -> Optional[Dict[str, Any]]:
+ """
+ Look up user document for current session user_id.
+ """
+ user_id = self._current_user_id()
+ if not user_id:
+ return None
+
+ try:
+ oid = ObjectId(user_id)
+ except Exception:
+ return None
+
+ return await self.users.find_one({"_id": oid})
+
+ def _require_login_redirect(self, next_path: str = "/"):
+ """
+ For HTML endpoints: if not logged in, redirect to /login?next=...
+ """
+ if not self._current_user_id():
+ return self._redirect(f"/login?next={next_path}")
+ return None
+
+ def _require_login_json(self):
+ """
+ For JSON endpoints: return (status, body) 401 if not logged in.
+ """
+ if not self._current_user_id():
+ return 401, {"error": "Authentication required."}
+ return None
+
+ # ---------- HTML HANDLERS ----------
+
+ async def index(self):
+ """
+ HTML: List all posts on the home page (/).
+ """
+ posts_cursor = self.posts.find(
+ {},
+ projection={"title": 1, "created_at": 1},
+ ).sort("created_at", -1)
+
+ posts: List[Dict[str, Any]] = []
+ async for doc in posts_cursor:
+ p = serialize_post(doc)
+ posts.append(
+ {
+ "id": p["id"],
+ "title": p["title"],
+ "created_at": p["created_at"],
+ }
+ )
+
+ user = await self._get_current_user()
+ return await self._render_template(
+ "index.html",
+ title="My MicroPie Blog",
+ posts=posts,
+ current_user=user,
+ request=self.request,
+ nav_active="index",
+ )
+
+ async def post(self, id):
+ """
+ HTML: Show a single post at /post/<id>.
+ """
+ try:
+ oid = ObjectId(id)
+ except Exception:
+ return 404, "Post not found"
+
+ doc = await self.posts.find_one({"_id": oid})
+ if not doc:
+ return 404, "Post not found"
+
+ post = serialize_post(doc)
+ user = await self._get_current_user()
+
+ html = markdown.markdown(post["content"])
+ return await self._render_template(
+ "post.html",
+ title=post["title"],
+ post=post,
+ post_html=html,
+ current_user=user,
+ request=self.request,
+ nav_active="index",
+ )
+
+ async def new(self):
+ """
+ HTML: Create new post at /new.
+ - GET → show form (only if logged in)
+ """
+ guard = self._require_login_redirect(next_path="/new")
+ if guard is not None:
+ return guard # redirect to /login
+
+ if self.request.method == "GET":
+ user = await self._get_current_user()
+ return await self._render_template(
+ "new.html",
+ title="New Post",
+ error=None,
+ current_user=user,
+ request=self.request,
+ nav_active="new",
+ )
+
+ async def login(self):
+ """
+ HTML: /login
+
+ GET → show login form
+ POST → authenticate and set session, then redirect
+ """
+ next_path = self.request.query_params.get("next", ["/"])[0]
+
+ if self.request.method == "GET":
+ return await self._render_template(
+ "login.html",
+ title="Login",
+ error=None,
+ next_path=next_path,
+ request=self.request,
+ nav_active="login",
+ )
+
+ username = self.request.body_params.get("username", [""])[0].strip()
+ password = self.request.body_params.get("password", [""])[0].strip()
+
+ user = await self.users.find_one({"username": username})
+ if not user or user.get("password") != password:
+ return await self._render_template(
+ "login.html",
+ title="Login",
+ error="Invalid username or password.",
+ next_path=next_path,
+ request=self.request,
+ nav_active="login",
+ )
+
+ self.request.session["user_id"] = str(user["_id"])
+ return self._redirect(next_path)
+
+ async def logout(self):
+ """
+ HTML: /logout — clear session and redirect home.
+ """
+ if hasattr(self.request, "session"):
+ self.request.session.clear()
+ return self._redirect("/")
+
+
+ # ---------- JSON API HANDLERS ----------
+
+ async def api_posts(self):
+ """
+ JSON: /api_posts
+
+ - GET → list posts
+ - POST → create post (requires login)
+ """
+ if self.request.method == "GET":
+ cursor = self.posts.find({}).sort("created_at", -1)
+ posts = [serialize_post(doc) async for doc in cursor]
+ return {"posts": posts}
+
+ if self.request.method == "POST":
+ guard = self._require_login_json()
+ if guard is not None:
+ return guard
+
+ try:
+ data = self.request.get_json
+ except Exception:
+ return 400, {"error": "Invalid JSON payload."}
+
+ title = str(data.get("title", "")).strip()
+ content = str(data.get("content", "")).strip()
+
+ if not title or not content:
+ return 400, {"error": "title and content are required."}
+
+ user = await self._get_current_user()
+ user_id = str(user["_id"]) if user else None
+ username = user.get("username") if user else None
+
+ doc = {
+ "title": title,
+ "content": content,
+ "created_at": datetime.utcnow(),
+ "author_id": user_id,
+ "author_username": username,
+ }
+ result = await self.posts.insert_one(doc)
+ created = await self.posts.find_one({"_id": result.inserted_id})
+
+ return 201, serialize_post(created)
+
+ return 405, {"error": "Method not allowed on /api_posts."}
+
+ async def api_post(self, id):
+ """
+ JSON: /api_post/<id>
+
+ - GET → fetch single post
+ - PATCH → partially update post (requires login)
+ - PUT → update post (same semantics here as PATCH)
+ - DELETE → delete post (requires login)
+ """
+ try:
+ oid = ObjectId(id)
+ except Exception:
+ return 400, {"error": "Invalid post id."}
+
+ doc = await self.posts.find_one({"_id": oid})
+ if not doc:
+ return 404, {"error": "Post not found."}
+
+ if self.request.method == "GET":
+ post = serialize_post(doc)
+ post["html"] = markdown.markdown(post["content"])
+ return post
+
+ if self.request.method in ("PATCH", "PUT"):
+ guard = self._require_login_json()
+ if guard is not None:
+ return guard
+
+ try:
+ data = self.request.get_json
+ except Exception:
+ return 400, {"error": "Invalid JSON payload."}
+
+ updates: Dict[str, Any] = {}
+
+ if "title" in data:
+ title = str(data["title"]).strip()
+ if not title:
+ return 400, {"error": "title cannot be empty."}
+ updates["title"] = title
+
+ if "content" in data:
+ content = str(data["content"]).strip()
+ if not content:
+ return 400, {"error": "content cannot be empty."}
+ updates["content"] = content
+
+ if not updates:
+ return 400, {"error": "Nothing to update."}
+
+ updates["updated_at"] = datetime.utcnow()
+
+ await self.posts.update_one({"_id": oid}, {"$set": updates})
+ updated = await self.posts.find_one({"_id": oid})
+ post = serialize_post(updated)
+ post["html"] = markdown.markdown(post["content"])
+ return post
+
+ if self.request.method == "DELETE":
+ guard = self._require_login_json()
+ if guard is not None:
+ return guard
+
+ await self.posts.delete_one({"_id": oid})
+ return {"ok": True}
+
+ return 405, {"error": "Method not allowed on /api_post/<id>."}
+
+
+
+app = BlogApp(session_backend=MkvSessionBackend(
+ mongo_uri=MONGO_URI,
+ db_name=DB_NAME
+ )
+)
+app.middlewares.append(
+ MongoRateLimitMiddleware(
+ mongo_uri=MONGO_URI,
+ db_name=DB_NAME,
+ allowed_hosts=None, # don't enforce host allowlist, change in prod
+ trust_proxy_headers=False, # change in prod
+ require_cf_ray=False,
+ )
+)
+app.startup_handlers.append(init_db)
+app.shutdown_handlers.append(close_db)
diff --git a/examples/blog/middlewares/__init__.py b/examples/blog/middlewares/__init__.py
new file mode 100644
index 0000000..6a0ca3c
--- /dev/null
+++ b/examples/blog/middlewares/__init__.py
@@ -0,0 +1,3 @@
+from .rate_limit import MongoRateLimitMiddleware
+
+__all__ = ["MongoRateLimitMiddleware"]
diff --git a/examples/blog/middlewares/rate_limit.py b/examples/blog/middlewares/rate_limit.py
new file mode 100644
index 0000000..29b93fd
--- /dev/null
+++ b/examples/blog/middlewares/rate_limit.py
@@ -0,0 +1,311 @@
+from __future__ import annotations
+
+import ipaddress
+from datetime import datetime, timedelta
+from typing import Set
+
+from pymongo import AsyncMongoClient, ReturnDocument
+from micropie import HttpMiddleware
+
+
+def _valid_ip(value: str | None) -> str | None:
+ try:
+ return str(ipaddress.ip_address(value.strip()))
+ except Exception:
+ return None
+
+
+class MongoRateLimitMiddleware(HttpMiddleware):
+ """
+ Global MongoDB-based rate limiter (Heroku + Cloudflare safe).
+
+ - One document per client IP
+ - Fixed window counter
+ - Escalating temporary blocks
+ - Permanent block based on 24h violation history
+ - Fully atomic (single DB op per request)
+ - PyMongo Async API (no Motor)
+
+ Requires:
+ - MongoDB 4.2+ (aggregation pipeline updates)
+ """
+
+ # --- rate config ---
+ MAX_REQUESTS = 50
+ WINDOW_SECONDS = 60
+
+ BLOCK_AFTER_VIOLATIONS = 3
+ BLOCK_FOR_SECONDS = 900
+
+ PERMA_WINDOW_HOURS = 24
+ PERMA_BLOCK_AFTER = 10
+
+ def __init__(
+ self,
+ mongo_uri: str,
+ db_name: str,
+ collection_name: str = "rate_limits_global",
+ *,
+ allowed_hosts: Set[str] | None = None,
+ trust_proxy_headers: bool = True,
+ require_cf_ray: bool = True,
+ ):
+ self.client = AsyncMongoClient(mongo_uri)
+ self.db = self.client[db_name]
+ self.collection = self.db[collection_name]
+
+ # Security / proxy config
+ self.allowed_hosts = allowed_hosts or set()
+ self.trust_proxy_headers = trust_proxy_headers
+ self.require_cf_ray = require_cf_ray
+
+ # ---------------------------------------------------------
+ # Real client IP resolution (for Cloudflare + Heroku or similar setups)
+ # ---------------------------------------------------------
+
+ def _client_ip(self, request) -> str:
+ headers = getattr(request, "headers", {}) or {}
+
+ # 1) Optional host allow-list (prevents origin bypass)
+ if self.allowed_hosts:
+ host = (headers.get("host") or "").split(":", 1)[0].lower()
+ if host and host not in self.allowed_hosts:
+ return "unknown"
+
+ # 2) Only trust proxy headers if allowed
+ can_trust = self.trust_proxy_headers
+ if can_trust and self.require_cf_ray:
+ can_trust = bool(headers.get("cf-ray"))
+
+ if can_trust:
+ # Cloudflare headers (best)
+ for h in ("cf-connecting-ip", "true-client-ip"):
+ ip = _valid_ip(headers.get(h))
+ if ip:
+ return ip
+
+ # Standard proxy chain
+ xff = headers.get("x-forwarded-for")
+ if isinstance(xff, str):
+ ip = _valid_ip(xff.split(",", 1)[0])
+ if ip:
+ return ip
+
+ # Fallback proxy header
+ ip = _valid_ip(headers.get("x-real-ip"))
+ if ip:
+ return ip
+
+ # 3) ASGI scope fallback (Heroku router)
+ client = request.scope.get("client") or ("unknown", 0)
+ return _valid_ip(client[0]) or "unknown"
+
+ # ---------------------------------------------------------
+ # Middleware hook
+ # ---------------------------------------------------------
+
+ async def before_request(self, request):
+ client_ip = self._client_ip(request)
+ now = datetime.utcnow()
+
+ window_start_cutoff = now - timedelta(seconds=self.WINDOW_SECONDS)
+ perma_window_cutoff = now - timedelta(hours=self.PERMA_WINDOW_HOURS)
+
+ key = client_ip
+
+ doc = await self.collection.find_one_and_update(
+ {"_id": key},
+ [
+ # 1) Baseline fields
+ {
+ "$set": {
+ "_id": key,
+ "ip": client_ip,
+ "count": {"$ifNull": ["$count", 0]},
+ "window_start": {"$ifNull": ["$window_start", now]},
+ "violations": {"$ifNull": ["$violations", 0]},
+ "blocked_until": {"$ifNull": ["$blocked_until", None]},
+ "permanent_blocked": {"$ifNull": ["$permanent_blocked", False]},
+ "permanent_blocked_at": {"$ifNull": ["$permanent_blocked_at", None]},
+ "violation_events": {"$ifNull": ["$violation_events", []]},
+ }
+ },
+
+ # 2) Prune old violation events
+ {
+ "$set": {
+ "violation_events": {
+ "$filter": {
+ "input": "$violation_events",
+ "as": "t",
+ "cond": {"$gte": ["$$t", perma_window_cutoff]},
+ }
+ }
+ }
+ },
+
+ # 3) Are we currently blocked?
+ {
+ "$set": {
+ "_blocked_now": {
+ "$or": [
+ "$permanent_blocked",
+ {
+ "$and": [
+ {"$ne": ["$blocked_until", None]},
+ {"$gt": ["$blocked_until", now]},
+ ]
+ },
+ ]
+ }
+ }
+ },
+
+ # 4) Update window/count atomically (only if not blocked)
+ {
+ "$set": {
+ "_window_expired": {
+ "$cond": [
+ "$_blocked_now",
+ False,
+ {"$lt": ["$window_start", window_start_cutoff]},
+ ]
+ }
+ }
+ },
+ {
+ "$set": {
+ "window_start": {
+ "$cond": [
+ "$_blocked_now",
+ "$window_start",
+ {"$cond": ["$_window_expired", now, "$window_start"]},
+ ]
+ },
+ "count": {
+ "$cond": [
+ "$_blocked_now",
+ "$count",
+ {"$cond": ["$_window_expired", 1, {"$add": ["$count", 1]}]},
+ ]
+ },
+ }
+ },
+
+ # 5) Over limit?
+ {
+ "$set": {
+ "_over_limit": {
+ "$and": [
+ {"$not": "$_blocked_now"},
+ {"$gt": ["$count", self.MAX_REQUESTS]},
+ ]
+ }
+ }
+ },
+
+ # 6) Record violation if over limit
+ {
+ "$set": {
+ "violations": {
+ "$cond": ["$_over_limit", {"$add": ["$violations", 1]}, "$violations"]
+ },
+ "violation_events": {
+ "$cond": [
+ "$_over_limit",
+ {"$concatArrays": ["$violation_events", [now]]},
+ "$violation_events",
+ ]
+ },
+ }
+ },
+
+ # 7) Temporary block escalation
+ {
+ "$set": {
+ "blocked_until": {
+ "$cond": [
+ {
+ "$and": [
+ "$_over_limit",
+ {"$gte": ["$violations", self.BLOCK_AFTER_VIOLATIONS]},
+ ]
+ },
+ now + timedelta(seconds=self.BLOCK_FOR_SECONDS),
+ "$blocked_until",
+ ]
+ }
+ }
+ },
+
+ # 8) Permanent block escalation
+ {"$set": {"_events_24h": {"$size": "$violation_events"}}},
+ {
+ "$set": {
+ "permanent_blocked": {
+ "$cond": [
+ {
+ "$and": [
+ "$_over_limit",
+ {"$gte": ["$_events_24h", self.PERMA_BLOCK_AFTER]},
+ ]
+ },
+ True,
+ "$permanent_blocked",
+ ]
+ },
+ "permanent_blocked_at": {
+ "$cond": [
+ {
+ "$and": [
+ "$_over_limit",
+ {"$gte": ["$_events_24h", self.PERMA_BLOCK_AFTER]},
+ {"$eq": ["$permanent_blocked_at", None]},
+ ]
+ },
+ now,
+ "$permanent_blocked_at",
+ ]
+ },
+ }
+ },
+
+ # 9) Cleanup temp fields
+ {"$unset": ["_blocked_now", "_window_expired", "_over_limit", "_events_24h"]},
+ ],
+ upsert=True,
+ return_document=ReturnDocument.AFTER,
+ projection={"count": 1, "blocked_until": 1, "permanent_blocked": 1},
+ )
+
+ doc = doc or {}
+
+ # --- responses ---
+ if doc.get("permanent_blocked"):
+ return {
+ "status_code": 403,
+ "body": f"Access permanently blocked for IP {client_ip}.",
+ "headers": [],
+ }
+
+ blocked_until = doc.get("blocked_until")
+ if isinstance(blocked_until, datetime) and now < blocked_until:
+ retry_after = max(0, int((blocked_until - now).total_seconds()))
+ return {
+ "status_code": 429,
+ "body": f"Too many requests from {client_ip}. Temporarily blocked.",
+ "headers": [("Retry-After", str(retry_after))],
+ }
+
+ if int(doc.get("count", 0)) > self.MAX_REQUESTS:
+ return {
+ "status_code": 429,
+ "body": f"Rate limit exceeded for IP {client_ip}.",
+ "headers": [],
+ }
+
+ return None
+
+ async def after_request(self, request, status_code, response_body, extra_headers):
+ return None
+
diff --git a/examples/blog/requirements.txt b/examples/blog/requirements.txt
new file mode 100644
index 0000000..d9ce338
--- /dev/null
+++ b/examples/blog/requirements.txt
@@ -0,0 +1,4 @@
+micropie[all]
+motor
+mongokv
+markdown
diff --git a/examples/blog/sessions/__init__.py b/examples/blog/sessions/__init__.py
new file mode 100644
index 0000000..ea3f22e
--- /dev/null
+++ b/examples/blog/sessions/__init__.py
@@ -0,0 +1,3 @@
+from .mongo_session import MkvSessionBackend
+
+__all__ = ["MkvSessionBackend"]
diff --git a/examples/blog/sessions/mongo_session.py b/examples/blog/sessions/mongo_session.py
new file mode 100644
index 0000000..a97e863
--- /dev/null
+++ b/examples/blog/sessions/mongo_session.py
@@ -0,0 +1,94 @@
+import time
+from typing import Any, Dict, Optional
+
+from mongokv import Mkv
+from micropie import SessionBackend
+
+
+class MkvSessionBackend(SessionBackend):
+ """
+ Session backend backed by mongokv.Mkv.
+
+ Storage schema (per session_id):
+ key = session_id
+ value = {
+ "data": { ...session dict... },
+ "expires_at": <unix_epoch_seconds>
+ }
+
+ Notes:
+ - Expiration is enforced on load (lazy cleanup).
+ - save(..., {}, 0) deletes (matches MicroPie logout behavior).
+ """
+
+ def __init__(
+ self,
+ mongo_uri: str,
+ db_name: str,
+ collection_name: str = "sessions",
+ *,
+ key_prefix: str = "sess:",
+ ) -> None:
+ self.store = Mkv(mongo_uri, db_name, collection_name)
+ self.key_prefix = key_prefix
+
+ def _k(self, session_id: str) -> str:
+ return f"{self.key_prefix}{session_id}"
+
+ async def load(self, session_id: str) -> Dict[str, Any]:
+ if not session_id:
+ return {}
+
+ key = self._k(session_id)
+
+ try:
+ payload = await self.store.get(key)
+ except KeyError:
+ return {}
+ except Exception:
+ # If you prefer, log this instead of swallowing.
+ return {}
+
+ if not isinstance(payload, dict):
+ # Corrupt/unexpected; treat as empty and delete
+ try:
+ await self.store.remove(key)
+ except Exception:
+ pass
+ return {}
+
+ expires_at = payload.get("expires_at")
+ if isinstance(expires_at, (int, float)) and time.time() > float(expires_at):
+ # Expired: delete and return empty
+ try:
+ await self.store.remove(key)
+ except Exception:
+ pass
+ return {}
+
+ data = payload.get("data", {})
+ return data if isinstance(data, dict) else {}
+
+ async def save(self, session_id: str, data: Dict[str, Any], timeout: int) -> None:
+ if not session_id:
+ return
+
+ key = self._k(session_id)
+
+ # MicroPie uses save(session_id, {}, 0) for logout/delete
+ if not data or timeout <= 0:
+ try:
+ await self.store.remove(key)
+ except Exception:
+ pass
+ return
+
+ expires_at = time.time() + int(timeout)
+
+ payload = {
+ "data": data,
+ "expires_at": expires_at,
+ }
+
+ await self.store.set(key, payload)
+
diff --git a/examples/blog/templates/base.html b/examples/blog/templates/base.html
new file mode 100644
index 0000000..2f5b3da
--- /dev/null
+++ b/examples/blog/templates/base.html
@@ -0,0 +1,297 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>MicroPie // My Thoughts</title>
+
+ <link href="https://fonts.googleapis.com/css2?family=Ubuntu:wght@300;400;500;700&display=swap" rel="stylesheet">
+ <link rel="icon" href="https://harrisonerd.com/favicon.ico" />
+
+
+ <!-- Highlight.js (code highlighting) -->
+ <link rel="stylesheet"
+ href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
+ <script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
+ <script>
+ document.addEventListener("DOMContentLoaded", function () {
+ if (window.hljs) {
+ hljs.highlightAll();
+ }
+ });
+ </script>
+
+ <style>
+ :root {
+ --bg: #fafafa;
+ --text: #111;
+ --card-bg: #ffffff;
+ --border-subtle: rgba(0, 0, 0, 0.06);
+ --shadow-soft: 0 10px 30px rgba(0, 0, 0, 0.06);
+ --accent: #111;
+ --accent-muted: rgba(0, 0, 0, 0.08);
+ }
+
+ * {
+ box-sizing: border-box;
+ }
+
+ body {
+ margin: 0;
+ padding: 0;
+ font-family: "Ubuntu", sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ min-height: 100vh;
+ text-align: center;
+
+ /* fade in */
+ opacity: 0;
+ animation: fadeIn 1.5s ease forwards;
+ }
+
+ @keyframes fadeIn {
+ from { opacity: 0; transform: translateY(12px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+
+ .page-shell {
+ width: 100%;
+ max-width: 900px;
+ padding: 24px 16px 40px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 20px;
+ }
+
+ /* condensed header: avatar + text in a row */
+ .header {
+ width: 100%;
+ max-width: 720px;
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 14px;
+ animation: subtleFloat 6s ease-in-out infinite;
+ text-align: left;
+ }
+
+ @keyframes subtleFloat {
+ 0%, 100% { transform: translateY(0px); }
+ 50% { transform: translateY(-4px); }
+ }
+
+ .avatar {
+ width: 72px;
+ height: 72px;
+ border-radius: 25%;
+ object-fit: cover;
+ box-shadow: 0 3px 8px rgba(0,0,0,0.10);
+ animation: breathe 6s ease-in-out infinite;
+ background: #e0e0e0;
+ flex-shrink: 0;
+ }
+
+ @keyframes breathe {
+ 0%, 100% { box-shadow: 0 3px 8px rgba(0,0,0,0.10); }
+ 50% { box-shadow: 0 5px 14px rgba(0,0,0,0.14); }
+ }
+
+ .header-text {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+
+ .name {
+ font-size: 26px;
+ font-weight: 700;
+ letter-spacing: -0.3px;
+ margin: 0;
+ animation: fadeSlide 1.4s ease forwards;
+ }
+
+ @keyframes fadeSlide {
+ 0% { opacity: 0; transform: translateY(6px); }
+ 100% { opacity: 1; transform: translateY(0); }
+ }
+
+ .subtitle {
+ font-size: 14px;
+ color: #555;
+ max-width: 460px;
+ line-height: 1.4;
+ margin: 0;
+ }
+
+ .content-shell {
+ width: 100%;
+ max-width: 720px;
+ margin-top: 4px;
+ }
+
+ .card {
+ background: var(--card-bg);
+ border-radius: 20px;
+ padding: 20px 22px;
+ box-shadow: var(--shadow-soft);
+ border: 1px solid var(--border-subtle);
+ text-align: left;
+ }
+
+ .card + .card {
+ margin-top: 14px;
+ }
+
+ .page-title-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 14px;
+ }
+
+ .page-title {
+ margin: 0;
+ font-size: 22px;
+ font-weight: 600;
+ }
+
+ .page-subtle {
+ font-size: 13px;
+ color: #777;
+ margin: 0 0 14px;
+ }
+
+ .btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 8px 14px;
+ border-radius: 999px;
+ border: 1px solid var(--accent-muted);
+ background: #fff;
+ text-decoration: none;
+ color: #111;
+ font-size: 13px;
+ cursor: pointer;
+ transition: background 0.15s ease, transform 0.12s ease, box-shadow 0.15s ease;
+ }
+
+ .btn:hover {
+ background: rgba(0,0,0,0.04);
+ transform: translateY(-1px);
+ box-shadow: 0 6px 14px rgba(0,0,0,0.08);
+ }
+
+ .btn-primary {
+ border-color: #111;
+ background: #111;
+ color: #fff;
+ }
+
+ .btn-primary:hover {
+ background: #000;
+ }
+
+ .meta {
+ font-size: 12px;
+ color: #777;
+ }
+
+ a {
+ color: inherit;
+ }
+
+ a.inline-link {
+ color: #111;
+ text-decoration: none;
+ border-bottom: 1px solid rgba(0,0,0,0.18);
+ padding-bottom: 1px;
+ }
+
+ a.inline-link:hover {
+ border-bottom-color: rgba(0,0,0,0.35);
+ }
+
+ input[type="text"],
+ input[type="password"],
+ textarea {
+ width: 100%;
+ padding: 8px 10px;
+ margin-top: 4px;
+ margin-bottom: 10px;
+ border-radius: 10px;
+ border: 1px solid rgba(0,0,0,0.16);
+ font: inherit;
+ resize: vertical;
+ min-height: 38px;
+ outline: none;
+ background: #fcfcfc;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
+ }
+
+ textarea {
+ min-height: 160px;
+ }
+
+ input:focus,
+ textarea:focus {
+ border-color: #111;
+ box-shadow: 0 0 0 1px #1111;
+ background: #ffffff;
+ }
+
+ label {
+ display: block;
+ font-size: 13px;
+ margin-top: 2px;
+ }
+
+ .error {
+ color: #b91c1c;
+ font-size: 13px;
+ margin-bottom: 10px;
+ }
+
+ /* responsive tweaks */
+ @media (max-width: 600px) {
+ .page-shell {
+ padding-top: 20px;
+ }
+
+ .header {
+ align-items: flex-start;
+ }
+
+ .name {
+ font-size: 22px;
+ }
+
+ .avatar {
+ width: 64px;
+ height: 64px;
+ }
+
+ .card {
+ padding: 18px 16px;
+ }
+ }
+ </style>
+
+</head>
+<body>
+ <div class="page-shell">
+ <!-- condensed header: avatar + text in one row -->
+
+ <main class="content-shell">
+ {% block content %}{% endblock %}
+ </main>
+ </div>
+</body>
+</html>
+
diff --git a/examples/blog/templates/index.html b/examples/blog/templates/index.html
new file mode 100644
index 0000000..0f8898f
--- /dev/null
+++ b/examples/blog/templates/index.html
@@ -0,0 +1,32 @@
+{% extends "base.html" %}
+
+{% block content %}
+ <section class="card">
+ <div class="page-title-row">
+ <h1 class="page-title">Things I’m working on or thinking about</h1>
+ {% if current_user %}
+ <a href="/new" class="btn btn-primary">Write</a>
+ {% endif %}
+ </div>
+
+ {% if posts %}
+ <div>
+ {% for post in posts %}
+ <article style="padding: 10px 0; border-top: {% if not loop.first %}1px solid rgba(0,0,0,0.05){% else %}none{% endif %};">
+ <h2 style="margin: 4px 0 2px; font-size: 17px; font-weight: 600;">
+ <a href="/post/{{ post.id }}" class="inline-link">{{ post.title }}</a>
+ </h2>
+ <p class="meta" style="margin: 3px 0 0;">
+ {{ post.created_at }}
+ </p>
+ </article>
+ {% endfor %}
+ </div>
+ {% else %}
+ <p style="margin: 0;">
+ Nothing here yet. {% if current_user %}<a href="/new" class="inline-link">Write the first one?</a>{% else %}Check back soon.{% endif %}
+ </p>
+ {% endif %}
+ </section>
+{% endblock %}
+
diff --git a/examples/blog/templates/login.html b/examples/blog/templates/login.html
new file mode 100644
index 0000000..4dd4fc7
--- /dev/null
+++ b/examples/blog/templates/login.html
@@ -0,0 +1,32 @@
+{% extends "base.html" %}
+
+{% block content %}
+ <section class="card">
+ <div class="page-title-row">
+ <h1 class="page-title">Login</h1>
+ </div>
+ <p class="page-subtle">
+ Right now this is just a tiny auth wall so only I can post.
+ </p>
+
+ {% if error %}
+ <div class="error">{{ error }}</div>
+ {% endif %}
+
+ <form method="post" action="/login" style="margin-top: 6px;">
+ <input type="hidden" name="next" value="{{ next_path or '/' }}">
+
+ <label for="username">Username</label>
+ <input id="username" name="username" type="text" required />
+
+ <label for="password">Password</label>
+ <input id="password" name="password" type="password" required />
+
+ <div style="margin-top: 10px; display:flex; gap:10px; flex-wrap:wrap;">
+ <button type="submit" class="btn btn-primary">Sign in</button>
+ <a href="/" class="btn">Back</a>
+ </div>
+ </form>
+ </section>
+{% endblock %}
+
diff --git a/examples/blog/templates/new.html b/examples/blog/templates/new.html
new file mode 100644
index 0000000..977246d
--- /dev/null
+++ b/examples/blog/templates/new.html
@@ -0,0 +1,110 @@
+{% extends "base.html" %}
+
+{% block content %}
+ <section class="card">
+ <div class="page-title-row">
+ <h1 class="page-title">New post</h1>
+ </div>
+ <p class="page-subtle">
+ Write something and hit publish.
+ </p>
+
+ {% if error %}
+ <div class="error" id="form-error">{{ error }}</div>
+ {% else %}
+ <div class="error" id="form-error" style="display:none;"></div>
+ {% endif %}
+
+ <form id="new-post-form" method="post" action="/new" style="margin-top: 6px;">
+ <label for="title">Title</label>
+ <input id="title" name="title" type="text" required />
+
+ <label for="content">Content</label>
+ <textarea id="content" name="content" required></textarea>
+
+ <div style="margin-top: 10px; display:flex; gap:10px; flex-wrap:wrap;">
+ <button type="submit" class="btn btn-primary" id="publish-btn">Publish</button>
+ <a href="/" class="btn">Cancel</a>
+ </div>
+ </form>
+ </section>
+
+ <script>
+ (function () {
+ const form = document.getElementById("new-post-form");
+ const titleInput = document.getElementById("title");
+ const contentEl = document.getElementById("content");
+ const errorEl = document.getElementById("form-error");
+ const publishBtn = document.getElementById("publish-btn");
+
+ if (!form) return;
+
+ form.addEventListener("submit", async function (e) {
+ e.preventDefault();
+
+ if (errorEl) {
+ errorEl.style.display = "none";
+ errorEl.textContent = "";
+ }
+
+ const title = titleInput.value.trim();
+ const content = contentEl.value.trim();
+
+ if (!title || !content) {
+ if (errorEl) {
+ errorEl.textContent = "Title and content are required.";
+ errorEl.style.display = "block";
+ }
+ return;
+ }
+
+ if (publishBtn) {
+ publishBtn.disabled = true;
+ publishBtn.textContent = "Publishing...";
+ }
+
+ try {
+ const res = await fetch("/api_posts", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ credentials: "include",
+ body: JSON.stringify({ title, content }),
+ });
+
+ const data = await res.json().catch(() => ({}));
+
+ if (!res.ok) {
+ if (errorEl) {
+ errorEl.textContent = data.error || "Failed to create post.";
+ errorEl.style.display = "block";
+ }
+ if (publishBtn) {
+ publishBtn.disabled = false;
+ publishBtn.textContent = "Publish";
+ }
+ return;
+ }
+
+ if (data.id) {
+ window.location.href = `/post/${data.id}`;
+ } else {
+ window.location.href = "/";
+ }
+ } catch (err) {
+ console.error(err);
+ if (errorEl) {
+ errorEl.textContent = "Network error while creating post.";
+ errorEl.style.display = "block";
+ }
+ if (publishBtn) {
+ publishBtn.disabled = false;
+ publishBtn.textContent = "Publish";
+ }
+ }
+ });
+ })();
+ </script>
+{% endblock %}
+
diff --git a/examples/blog/templates/post.html b/examples/blog/templates/post.html
new file mode 100644
index 0000000..fce3b6c
--- /dev/null
+++ b/examples/blog/templates/post.html
@@ -0,0 +1,196 @@
+{% extends "base.html" %}
+
+{% block content %}
+ <article class="card">
+ <h1 class="page-title" id="post-title" style="margin-bottom: 6px;">
+ {{ post.title }}
+ </h1>
+ <p class="meta" id="post-meta" style="margin: 0 0 16px;">
+ {{ post.created_at }}
+ {% if post.author_username %}
+ · by {{ post.author_username }}
+ {% endif %}
+ </p>
+
+ <!-- View mode -->
+ <div id="post-view" style="line-height: 1.6; font-size: 15px;">
+ {{ post_html | safe }}
+ </div>
+
+ <!-- Edit mode (hidden by default) -->
+ {% if current_user %}
+ <form id="edit-form" style="display:none; margin-top: 12px;">
+ <div style="margin-bottom: 8px;">
+ <label for="edit-title" style="display:block; font-weight:600; margin-bottom:4px;">
+ Title
+ </label>
+ <input
+ id="edit-title"
+ type="text"
+ value="{{ post.title }}"
+ style="width:100%; padding:8px; font-size:15px; box-sizing:border-box;"
+ />
+ </div>
+
+ <div style="margin-bottom: 8px;">
+ <label for="edit-content" style="display:block; font-weight:600; margin-bottom:4px;">
+ Content (Markdown)
+ </label>
+ <textarea
+ id="edit-content"
+ rows="12"
+ style="width:100%; padding:8px; font-size:14px; line-height:1.5; box-sizing:border-box;"
+ >{{ post.content }}</textarea>
+ </div>
+
+ <div style="display:flex; gap:8px; flex-wrap:wrap; margin-top:8px;">
+ <button type="submit" class="btn">Save changes</button>
+ <button type="button" id="cancel-edit" class="btn btn-secondary">Cancel</button>
+ </div>
+
+ <p id="edit-error" style="color:#b00020; font-size:13px; margin-top:8px; display:none;"></p>
+ </form>
+ {% endif %}
+
+ <div style="margin-top: 18px; display:flex; gap:10px; flex-wrap:wrap;">
+ <a href="/" class="btn">← Back to posts</a>
+
+ {% if current_user %}
+ <button type="button" id="edit-btn" class="btn btn-secondary">
+ ✏️ Edit post
+ </button>
+ <button type="button" id="delete-btn" class="btn btn-danger">
+ 🗑 Delete post
+ </button>
+ {% endif %}
+ </div>
+ </article>
+
+ {% if current_user %}
+ <script>
+ (function () {
+ const postId = "{{ post.id }}";
+ const titleEl = document.getElementById("post-title");
+ const metaEl = document.getElementById("post-meta");
+ const postView = document.getElementById("post-view");
+
+ const editBtn = document.getElementById("edit-btn");
+ const deleteBtn = document.getElementById("delete-btn");
+ const editForm = document.getElementById("edit-form");
+ const cancelEdit = document.getElementById("cancel-edit");
+ const titleInput = document.getElementById("edit-title");
+ const contentArea = document.getElementById("edit-content");
+ const errorEl = document.getElementById("edit-error");
+
+ function showEditMode() {
+ if (!editForm) return;
+ postView.style.display = "none";
+ editForm.style.display = "block";
+ errorEl.style.display = "none";
+ errorEl.textContent = "";
+ }
+
+ function hideEditMode() {
+ if (!editForm) return;
+ editForm.style.display = "none";
+ postView.style.display = "block";
+ errorEl.style.display = "none";
+ errorEl.textContent = "";
+ }
+
+ if (editBtn) {
+ editBtn.addEventListener("click", function () {
+ showEditMode();
+ });
+ }
+
+ if (cancelEdit) {
+ cancelEdit.addEventListener("click", function () {
+ hideEditMode();
+ });
+ }
+
+ if (editForm) {
+ editForm.addEventListener("submit", async function (e) {
+ e.preventDefault();
+ errorEl.style.display = "none";
+ errorEl.textContent = "";
+
+ const title = titleInput.value.trim();
+ const content = contentArea.value.trim();
+
+ if (!title || !content) {
+ errorEl.textContent = "Title and content cannot be empty.";
+ errorEl.style.display = "block";
+ return;
+ }
+
+ try {
+ const res = await fetch(`/api_post/${postId}`, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ credentials: "include",
+ body: JSON.stringify({ title, content }),
+ });
+
+ const data = await res.json().catch(() => ({}));
+
+ if (!res.ok) {
+ errorEl.textContent = data.error || "Failed to update post.";
+ errorEl.style.display = "block";
+ return;
+ }
+
+ // Update DOM from API response, no full page reload.
+ if (data.title) {
+ titleEl.textContent = data.title;
+ }
+
+ if (data.html) {
+ postView.innerHTML = data.html;
+ }
+
+ // Optionally tweak meta to show updated_at if you want:
+ // if (data.updated_at) { ... }
+
+ hideEditMode();
+ } catch (err) {
+ console.error(err);
+ errorEl.textContent = "Network error while saving changes.";
+ errorEl.style.display = "block";
+ }
+ });
+ }
+
+ if (deleteBtn) {
+ deleteBtn.addEventListener("click", async function () {
+ const sure = window.confirm("Are you sure you want to delete this post? This cannot be undone.");
+ if (!sure) return;
+
+ try {
+ const res = await fetch(`/api_post/${postId}`, {
+ method: "DELETE",
+ credentials: "include",
+ });
+
+ const data = await res.json().catch(() => ({}));
+
+ if (!res.ok) {
+ alert(data.error || "Failed to delete post.");
+ return;
+ }
+
+ window.location.href = "/";
+ } catch (err) {
+ console.error(err);
+ alert("Network error while deleting post.");
+ }
+ });
+ }
+ })();
+ </script>
+ {% endif %}
+{% endblock %}
+