add simple blog example

Commit fec9688 · patx · 2025-12-29T01:08:54-05:00

Changeset
fec9688577df230a2f5a7fa10745396c7583ef8e
Parents
d943cd34bacee7b9eaa43d1857b7fa770e4f7e2d

View source at this commit

Comments

No comments yet.

Log in to comment

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 %}
+