patx/micropie
update url shortener example. add click tracking. add api sub app.
Commit c517868 · patx · 2026-01-04T05:13:20-05:00
Comments
No comments yet.
Diff
diff --git a/examples/url_shortener/Procfile b/examples/url_shortener/Procfile
new file mode 100644
index 0000000..278ec11
--- /dev/null
+++ b/examples/url_shortener/Procfile
@@ -0,0 +1,2 @@
+web: uvicorn main:app --host=0.0.0.0 --port=${PORT}
+
diff --git a/examples/url_shortener/main.py b/examples/url_shortener/main.py
index 2d7aef2..06ceb43 100644
--- a/examples/url_shortener/main.py
+++ b/examples/url_shortener/main.py
@@ -1,77 +1,229 @@
+"""
+URL Shortener using MicroPie and PyMongo. Live at https://erd.sh/
+
+
+Copyright 2025 Harrison Erd
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice,
+this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+contributors may be used to endorse or promote products derived from this
+software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
+IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
+OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
+OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
+EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+"""
+
from string import ascii_letters, digits
from secrets import choice
+from datetime import datetime
from micropie import App
-from mongokv import Mkv
+from pymongo import AsyncMongoClient, ReturnDocument
-# Import middlewares and session backends
from middlewares.rate_limit import MongoRateLimitMiddleware
from middlewares.csrf import CSRFMiddleware
+from middlewares.sub_app import SubAppMiddleware
from sessions.mongo_session import MkvSessionBackend
-# EXAMPLE KEYS/URI, in production use/generate your own and save it as an
-# environment variables, do not hard code them like these demos HINT: You
-# can use `secrets.token_urlsafe(64)` to generate your CSRF secret key
-URL_ROOT = "http://localhost:8000/"
+URL_ROOT = "https://localhost:8000/"
MONGO_URI = "mongodb://localhost:27017"
DB_NAME = "shorty"
-CSRF_KEY = "wzWf0CsZr3LfrgPVc9RqHFVUmyXsYT-k8hnGt41bMGU"
-
-# Create an mongoKV instance using our URI
-db = Mkv(MONGO_URI, db_name=DB_NAME, collection_name="urls")
+COLLECTION = "urls"
+CSRF_KEY = "wzDf0CcZr3LgrgPVc2RqHFVUmyXsYT-k8kjGt41bMGU"
+mongo = AsyncMongoClient(MONGO_URI)
+urls = mongo[DB_NAME][COLLECTION]
-# Our main app class
+def _generate_id(length: int = 6) -> str:
+ return "".join(choice(ascii_letters + digits) for _ in range(length))
+
class Shorty(App):
- def _generate_id(self, length: int = 8) -> str:
- return "".join(choice(ascii_letters + digits) for _ in range(length))
-
async def index(self, url_str: str | None = None):
if url_str:
if self.request.method == "POST":
- while True:
- short_id = self._generate_id()
- try:
- await db.get(short_id)
- except KeyError:
- break
-
- await db.set(short_id, url_str)
- return await self._render_template(
- "success.html",
- url_id=url_str,
- short_id=short_id,
- url_root=URL_ROOT,
- )
-
- real_url = await db.get(url_str, "/")
- if not isinstance(real_url, str) or not real_url.startswith(("http://", "https://")):
- return self._redirect("/")
- return self._redirect(real_url)
+ return await self._create_short_link(url_str)
+
+ if url_str.endswith("+"):
+ return await self._stats_page(url_str)
+
+ return await self._redirect_link(url_str)
return await self._render_template("index.html", request=self.request)
+ async def _create_short_link(self, url_str: str):
+ if not isinstance(url_str, str) or not url_str.startswith(("http://", "https://")):
+ return self._redirect("/")
+
+ while True:
+ short_id = _generate_id()
+ exists = await urls.find_one({"_id": short_id})
+ if not exists:
+ break
+
+ await urls.insert_one({
+ "_id": short_id,
+ "url": url_str,
+ "clicks": 0,
+ "created_at": datetime.utcnow(),
+ "last_clicked_at": None,
+ })
+
+ return await self._render_template(
+ "success.html",
+ url_id=url_str,
+ short_id=short_id,
+ url_root=URL_ROOT,
+ )
+
+
+ async def _redirect_link(self, url_str: str):
+ doc = await urls.find_one_and_update(
+ {"_id": url_str},
+ {
+ "$inc": {"clicks": 1},
+ "$set": {"last_clicked_at": datetime.utcnow()},
+ },
+ return_document=ReturnDocument.AFTER,
+ )
+ if not doc:
+ return self._redirect("/")
+ return self._redirect(doc["url"])
+
+
+ async def _stats_page(self, url_str: str):
+ short_code = url_str[:-1]
+ doc = await urls.find_one({"_id": short_code})
+
+ if not doc:
+ return self._redirect("/")
+
+ return await self._render_template(
+ "stats.html",
+ short_code=short_code,
+ url=doc.get("url"),
+ clicks=int(doc.get("clicks", 0)),
+ created_at=doc.get("created_at"),
+ last_clicked_at=doc.get("last_clicked_at"),
+ url_root=URL_ROOT,
+ )
+
+
+class ApiApp(App):
+
+ async def index(self):
+ return await self._render_template("api.html")
-app = Shorty(session_backend=MkvSessionBackend(
- mongo_uri=MONGO_URI,
- db_name=DB_NAME
+ async def shorten(self):
+ if self.request.method != "POST":
+ return 405, {"error": "Method Not Allowed"}
+
+ data = self.request.get_json
+ url_str = data.get("url")
+
+ if not isinstance(url_str, str):
+ return 400, {"error": "Invalid JSON or missing 'url' key"}
+
+ if not url_str.startswith(("http://", "https://")):
+ return 400, {"error": "Invalid URL"}
+
+ while True:
+ short_id = _generate_id()
+ exists = await urls.find_one({"_id": short_id})
+ if not exists:
+ break
+
+ await urls.insert_one({
+ "_id": short_id,
+ "url": url_str,
+ "clicks": 0,
+ "created_at": datetime.utcnow(),
+ "last_clicked_at": None,
+ })
+
+ return {
+ "status": "success",
+ "long_url": url_str,
+ "short_id": short_id,
+ "short_url": f"{URL_ROOT}{short_id}",
+ }
+
+ async def stats(self, short_id: str):
+ if self.request.method != "GET":
+ return 405, {"error": "Method Not Allowed"}
+
+ if not isinstance(short_id, str) or not short_id:
+ return 400, {"error": "Missing short_id"}
+
+ doc = await urls.find_one({"_id": short_id})
+ if not doc:
+ return 404, {"error": "Not Found"}
+
+ created_at = doc.get("created_at")
+ last_clicked_at = doc.get("last_clicked_at")
+
+ return {
+ "status": "success",
+ "short_id": short_id,
+ "short_url": f"{URL_ROOT}{short_id}",
+ "long_url": doc.get("url"),
+ "clicks": int(doc.get("clicks", 0)),
+ "created_at": created_at.isoformat() + "Z" if created_at else None,
+ "last_clicked_at": last_clicked_at.isoformat() + "Z" if last_clicked_at else None,
+ }
+
+
+
+app = Shorty(
+ 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
+ allowed_hosts=None,
+ trust_proxy_headers=False,
require_cf_ray=False,
)
)
+
app.middlewares.append(
CSRFMiddleware(
app=app,
- secret_key=CSRF_KEY
+ secret_key=CSRF_KEY,
+ exempt_paths=[
+ "/api/v1/shorten",
+ "/api/v1/stats",
+ ],
)
)
+app.middlewares.append(
+ SubAppMiddleware(
+ mount_path="/api/v1",
+ subapp=ApiApp(),
+ )
+)
diff --git a/examples/url_shortener/middlewares/__init__.py b/examples/url_shortener/middlewares/__init__.py
index c5a3e14..deecfc7 100644
--- a/examples/url_shortener/middlewares/__init__.py
+++ b/examples/url_shortener/middlewares/__init__.py
@@ -1,4 +1,5 @@
from .rate_limit import MongoRateLimitMiddleware
from .csrf import CSRFMiddleware
+from .sub_app import SubAppMiddleware
-__all__ = ["MongoRateLimitMiddleware", "CSRFMiddleware"]
+__all__ = ["MongoRateLimitMiddleware", "CSRFMiddleware", "SubAppMiddlware"]
diff --git a/examples/url_shortener/middlewares/rate_limit.py b/examples/url_shortener/middlewares/rate_limit.py
index 29b93fd..4fd0fa6 100644
--- a/examples/url_shortener/middlewares/rate_limit.py
+++ b/examples/url_shortener/middlewares/rate_limit.py
@@ -2,32 +2,98 @@ from __future__ import annotations
import ipaddress
from datetime import datetime, timedelta
-from typing import Set
+from typing import Set, Iterable
from pymongo import AsyncMongoClient, ReturnDocument
from micropie import HttpMiddleware
+# ---------------------------------------------------------------------------
+# IP helpers
+# ---------------------------------------------------------------------------
+
def _valid_ip(value: str | None) -> str | None:
+ """Parse and normalize an IP string, returning canonical string form or None."""
try:
+ if not value:
+ return None
return str(ipaddress.ip_address(value.strip()))
except Exception:
return None
+# Cloudflare IPv4 + IPv6 ranges
+# Source: https://www.cloudflare.com/ips/
+_CLOUDFLARE_IP_RANGES: list[str] = [
+ # IPv4
+ "173.245.48.0/20",
+ "103.21.244.0/22",
+ "103.22.200.0/22",
+ "103.31.4.0/22",
+ "141.101.64.0/18",
+ "108.162.192.0/18",
+ "190.93.240.0/20",
+ "188.114.96.0/20",
+ "197.234.240.0/22",
+ "198.41.128.0/17",
+ "162.158.0.0/15",
+ "104.16.0.0/13",
+ "104.24.0.0/14",
+ "172.64.0.0/13",
+ "131.0.72.0/22",
+ # IPv6
+ "2400:cb00::/32",
+ "2606:4700::/32",
+ "2803:f800::/32",
+ "2405:b500::/32",
+ "2405:8100::/32",
+ "2a06:98c0::/29",
+ "2c0f:f248::/32",
+]
+
+_CLOUDFLARE_NETWORKS = [ipaddress.ip_network(n) for n in _CLOUDFLARE_IP_RANGES]
+
+
+def _is_cloudflare_socket_ip(socket_ip: str | None) -> bool:
+ """
+ Returns True if the connecting socket IP belongs to Cloudflare's published ranges.
+ This is the key anti-spoof check before trusting CF/XFF headers.
+ """
+ try:
+ if not socket_ip:
+ return False
+ addr = ipaddress.ip_address(socket_ip)
+ return any(addr in net for net in _CLOUDFLARE_NETWORKS)
+ except Exception:
+ return False
+
+
+# ---------------------------------------------------------------------------
+# Middleware
+# ---------------------------------------------------------------------------
+
class MongoRateLimitMiddleware(HttpMiddleware):
"""
- Global MongoDB-based rate limiter (Heroku + Cloudflare safe).
+ Global MongoDB-based rate limiter with Cloudflare anti-spoofing.
- - One document per client IP
+ - One document per client key (IP, optionally IP+route bucket)
- 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)
+ Security:
+ - Only trusts CF/XFF headers if:
+ 1) trust_proxy_headers=True
+ 2) (optionally) CF-Ray is present when require_cf_ray=True
+ 3) the connecting socket IP (ASGI scope['client'][0]) is in Cloudflare IP ranges
+ Otherwise, proxy headers are ignored.
+
+ Notes:
+ - Host allow-list is a sanity check, NOT sufficient to prevent origin bypass if the
+ origin is publicly reachable.
+ - Best defense: make origin only reachable from Cloudflare (Tunnel/firewall).
"""
# --- rate config ---
@@ -49,36 +115,56 @@ class MongoRateLimitMiddleware(HttpMiddleware):
allowed_hosts: Set[str] | None = None,
trust_proxy_headers: bool = True,
require_cf_ray: bool = True,
+ # Optional: include METHOD+PATH in key (lets you set different limits by route)
+ bucket_by_route: bool = False,
+ # If we can't reliably identify the client, return 403 (recommended)
+ fail_closed: 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.allowed_hosts = set(h.lower() for h in (allowed_hosts or set()))
self.trust_proxy_headers = trust_proxy_headers
self.require_cf_ray = require_cf_ray
+ self.bucket_by_route = bucket_by_route
+ self.fail_closed = fail_closed
+
# ---------------------------------------------------------
- # Real client IP resolution (for Cloudflare + Heroku or similar setups)
+ # Client identification
# ---------------------------------------------------------
- def _client_ip(self, request) -> str:
+ def _host_allowed(self, headers: dict) -> bool:
+ if not self.allowed_hosts:
+ return True
+ host = (headers.get("host") or "").split(":", 1)[0].lower()
+ return bool(host) and host in self.allowed_hosts
+
+ def _socket_ip(self, request) -> str | None:
+ client = (request.scope or {}).get("client") or (None, 0)
+ return _valid_ip(client[0])
+
+ def _can_trust_proxy_headers(self, headers: dict, socket_ip: str | None) -> bool:
+ if not self.trust_proxy_headers:
+ return False
+ if self.require_cf_ray and not headers.get("cf-ray"):
+ return False
+ # Critical anti-spoof: only trust if the peer socket IP is Cloudflare
+ return _is_cloudflare_socket_ip(socket_ip)
+
+ def _client_ip(self, request) -> str | None:
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"
+ # Optional host sanity check
+ if not self._host_allowed(headers):
+ return None
- # 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"))
+ socket_ip = self._socket_ip(request)
+ can_trust = self._can_trust_proxy_headers(headers, socket_ip)
if can_trust:
- # Cloudflare headers (best)
+ # Cloudflare headers (preferred)
for h in ("cf-connecting-ip", "true-client-ip"):
ip = _valid_ip(headers.get(h))
if ip:
@@ -96,9 +182,15 @@ class MongoRateLimitMiddleware(HttpMiddleware):
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"
+ # Untrusted path: fall back to socket peer IP (Heroku router, etc.)
+ return socket_ip
+
+ def _key(self, client_ip: str, request) -> str:
+ if not self.bucket_by_route:
+ return client_ip
+ method = (getattr(request, "method", None) or (request.scope or {}).get("method") or "GET").upper()
+ path = (request.scope or {}).get("path") or "/"
+ return f"{client_ip}|{method}|{path}"
# ---------------------------------------------------------
# Middleware hook
@@ -106,12 +198,19 @@ class MongoRateLimitMiddleware(HttpMiddleware):
async def before_request(self, request):
client_ip = self._client_ip(request)
+
+ # Avoid collapsing unknowns into a shared key (DoS vector)
+ if not client_ip:
+ if self.fail_closed:
+ return {"status_code": 403, "body": "Forbidden.", "headers": []}
+ return None # fail open (not recommended)
+
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
+ key = self._key(client_ip, request)
doc = await self.collection.find_one_and_update(
{"_id": key},
@@ -161,7 +260,7 @@ class MongoRateLimitMiddleware(HttpMiddleware):
}
},
- # 4) Update window/count atomically (only if not blocked)
+ # 4) Update window/count (only if not blocked)
{
"$set": {
"_window_expired": {
diff --git a/examples/url_shortener/middlewares/sub_app.py b/examples/url_shortener/middlewares/sub_app.py
new file mode 100644
index 0000000..d7c1008
--- /dev/null
+++ b/examples/url_shortener/middlewares/sub_app.py
@@ -0,0 +1,18 @@
+from micropie import HttpMiddleware
+
+class SubAppMiddleware(HttpMiddleware):
+ def __init__(self, mount_path: str, subapp):
+ self.mount_path = mount_path.lstrip("/")
+ self.subapp = subapp
+
+ async def before_request(self, request):
+ path = request.scope["path"].lstrip("/")
+ if path.startswith(self.mount_path):
+ request._subapp = self.subapp
+ request._subapp_path = path[len(self.mount_path):].lstrip("/") or "/"
+ request._subapp_mount_path = self.mount_path
+ return None
+
+ async def after_request(self, request, status_code, response_body, extra_headers):
+ return None
+
diff --git a/examples/url_shortener/requirements.txt b/examples/url_shortener/requirements.txt
new file mode 100644
index 0000000..a19382a
--- /dev/null
+++ b/examples/url_shortener/requirements.txt
@@ -0,0 +1,4 @@
+micropie[all]
+mongokv
+itsdangerous
+
diff --git a/examples/url_shortener/templates/api.html b/examples/url_shortener/templates/api.html
new file mode 100644
index 0000000..7521c7f
--- /dev/null
+++ b/examples/url_shortener/templates/api.html
@@ -0,0 +1,143 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>API Documentation</title>
+
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
+
+<style>
+ :root{
+ color-scheme: dark;
+ --bg: #070a0f;
+ --card: rgba(255,255,255,.06);
+ --border: rgba(255,255,255,.10);
+ --text: rgba(255,255,255,.92);
+ --muted: rgba(255,255,255,.60);
+ --focus: rgba(255,255,255,.22);
+ --shadow: 0 18px 55px rgba(0,0,0,.55);
+ --radius: 16px;
+ }
+
+ *{ box-sizing:border-box; margin:0; padding:0; }
+
+ body{
+ min-height:100vh;
+ display:grid;
+ place-items:center;
+ font-family:"JetBrains Mono", ui-monospace, monospace;
+ background:
+ radial-gradient(900px 500px at 20% 15%, rgba(255,255,255,.05), transparent 55%),
+ radial-gradient(800px 500px at 80% 85%, rgba(255,255,255,.04), transparent 55%),
+ var(--bg);
+ color:var(--text);
+ padding:24px;
+ }
+
+ .card{
+ width:100%;
+ max-width:680px;
+ padding:22px;
+ }
+
+ h1{
+ font-size:1.05rem;
+ font-weight:600;
+ margin-bottom:12px;
+ }
+
+ .section{
+ margin-top:18px;
+ }
+
+ .label{
+ font-size:.75rem;
+ color:var(--muted);
+ margin-bottom:6px;
+ }
+
+ pre{
+ background:rgba(0,0,0,.35);
+ border:1px solid var(--border);
+ border-radius:12px;
+ padding:14px;
+ font-size:.85rem;
+ overflow-x:auto;
+ line-height:1.5;
+ }
+
+ .note{
+ font-size:.75rem;
+ color:var(--muted);
+ margin-top:10px;
+ }
+
+ a{
+ color:var(--muted);
+ text-decoration:none;
+ }
+
+ a:hover{
+ color:var(--text);
+ }
+</style>
+</head>
+
+<body>
+ <main class="card">
+ <h1>Create short link</h1>
+
+ <div class="section">
+ <pre>
+POST /api/v1/shorten
+Content-Type: application/json
+
+{
+ "url": "https://google.com"
+}
+ </pre>
+ </div>
+
+ <div class="section">
+ <div class="label">Response</div>
+ <pre>
+{
+ "status": "success",
+ "long_url": "https://harrisonerd.com",
+ "short_id": "Ab3kQ9",
+ "short_url": "https://erd.sh/Ab3kQ9"
+}
+ </pre>
+ </div>
+
+ <h1 style="margin-top:48px;">Link stats</h1>
+ <div class="section">
+ <pre>GET /api/v1/stats/Ab3kQ9</pre>
+ </div>
+
+ <div class="section">
+ <div class="label">Response</div>
+ <pre>
+{
+ "status": "success",
+ "short_id": "Ab3kQ9",
+ "short_url": "https://erd.sh/Ab3kQ9",
+ "long_url": "https://harrisonerd.com",
+ "clicks": 12,
+ "created_at": "2026-01-04T08:41:12Z",
+ "last_clicked_at": "2026-01-04T09:02:55Z"
+}
+ </pre>
+ </div>
+
+ <div class="note" style="margin-top:48px;">
+ <a href="/">← Back</a>
+ </div>
+
+ </main>
+</body>
+</html>
+
diff --git a/examples/url_shortener/templates/index.html b/examples/url_shortener/templates/index.html
index 9cb2d1a..c3a7bbf 100644
--- a/examples/url_shortener/templates/index.html
+++ b/examples/url_shortener/templates/index.html
@@ -1,64 +1,158 @@
<!DOCTYPE html>
<html lang="en">
<head>
-<meta charset="UTF-8">
-<meta name="viewport" content="width=device-width, initial-scale=1.0">
-<title>Shorten URL - Shorty</title>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>Shorten URL</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
<style>
-* {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
-}
+ :root{
+ color-scheme: dark;
+ --bg: #070a0f;
+ --card: rgba(255,255,255,.06);
+ --card2: rgba(255,255,255,.04);
+ --border: rgba(255,255,255,.10);
+ --text: rgba(255,255,255,.92);
+ --muted: rgba(255,255,255,.60);
+ --focus: rgba(255,255,255,.22);
+ --shadow: 0 18px 55px rgba(0,0,0,.55);
+ --radius: 16px;
+ }
-body {
- height: 100vh;
- display: flex;
- align-items: center;
- justify-content: center;
- font-family: system-ui, -apple-system, sans-serif;
- background:
- radial-gradient(1200px 600px at 15% 20%, rgba(99,102,241,.25), transparent 60%),
- radial-gradient(900px 500px at 85% 75%, rgba(34,197,94,.18), transparent 55%),
- linear-gradient(180deg, #070b14, #0b1220 35%, #070b14);
-}
+ * { box-sizing: border-box; margin: 0; padding: 0; }
-form {
- width: 90%;
- max-width: 600px;
- display: flex;
- flex-direction: column;
- gap: 1rem;
-}
+ body{
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ background:
+ radial-gradient(900px 500px at 20% 15%, rgba(255,255,255,.05), transparent 55%),
+ radial-gradient(800px 500px at 80% 85%, rgba(255,255,255,.04), transparent 55%),
+ var(--bg);
+ color: var(--text);
+ padding: 24px;
+ }
-input {
- padding: 1rem;
- font-size: 1.1rem;
- border: 2px solid #ddd;
- border-radius: 8px;
-}
+ .card{
+ width: 100%;
+ max-width: 640px;
+ padding: 22px;
+ }
-button {
- padding: 1rem;
- font-size: 1.1rem;
- background: linear-gradient(135deg, rgba(99,102,241,.95), rgba(34,197,94,.75));
- color: #fff;
- border: none;
- border-radius: 8px;
- cursor: pointer;
-}
+ h1{
+ font-size: 1.05rem;
+ letter-spacing: .2px;
+ font-weight: 600;
+ margin-bottom: 6px;
+ }
+
+ p{
+ font-size: .95rem;
+ color: var(--muted);
+ line-height: 1.35;
+ }
+
+ form{
+ display: flex;
+ gap: 10px;
+ margin-top: 16px;
+ }
+
+ .field{
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ input[type="url"]{
+ width: 100%;
+ padding: 12px 14px;
+ font-size: 1rem;
+ color: var(--text);
+ background: rgba(0,0,0,.30);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ outline: none;
+ transition: border-color .15s ease, box-shadow .15s ease;
+ font-family: inherit;
+ }
-button:hover{
- filter: brightness(1.06);
- box-shadow: 0 16px 42px rgba(0,0,0,.42);
+ input[type="url"]::placeholder{
+ color: rgba(255,255,255,.38);
+ }
+
+ input[type="url"]:focus{
+ border-color: var(--focus);
+ box-shadow: 0 0 0 4px rgba(255,255,255,.06);
+ }
+
+ button{
+ flex: 0 0 auto;
+ padding: 12px 16px;
+ font-size: 0.98rem;
+ font-weight: 600;
+ color: var(--text);
+ background: rgba(255,255,255,.10);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ cursor: pointer;
+ transition: transform .08s ease, background .15s ease, border-color .15s ease;
+ user-select: none;
+ white-space: nowrap;
+ font-family: inherit;
+ }
+
+ button:hover{
+ background: rgba(255,255,255,.14);
+ border-color: rgba(255,255,255,.14);
+ }
+
+ button:active{
+ transform: scale(.98);
+ }
+
+ .below{
+ position: fixed;
+ bottom: 18px;
+ left: 50%;
+ transform: translateX(-50%);
+ text-align: center;
}
+
+ .back-link{
+ display: inline-block;
+ font-size: .9rem;
+ color: var(--muted);
+ text-decoration: none;
+ }
+
+ .back-link:hover{
+ color: var(--text);
+ }
+
+ /* mobile: stack */
+ @media (max-width: 560px){
+ form{ flex-direction: column; }
+ button{ width: 100%; }
+ }
</style>
</head>
+
<body>
-<form method="POST" action="/">
- <input type="url" name="url_str" placeholder="https://yourlongurl.com/here" required>
- <input type="hidden" name="csrf_token" value="{{ request.session.csrf_token }}">
- <button type="submit">Shorten</button>
-</form>
+ <main class="card">
+ <form method="POST" action="/">
+ <input id="url_str" type="url" name="url_str" placeholder="https://example.com/something/long" required autocomplete="off" autofocus/>
+ <input type="hidden" name="csrf_token" value="{{ request.session.csrf_token }}">
+ <button type="submit">Shorten</button>
+ </form>
+ <div class="below">
+ <a href="https://harrisonerd.com" target="_blank" class="back-link">erd.sh</a> · <a href="/api/v1" class="back-link">API Docs (v1)</a>
+ </div>
+ </main>
</body>
</html>
+
diff --git a/examples/url_shortener/templates/stats.html b/examples/url_shortener/templates/stats.html
new file mode 100644
index 0000000..86c16f4
--- /dev/null
+++ b/examples/url_shortener/templates/stats.html
@@ -0,0 +1,190 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>{{ url }}</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
+<style>
+ :root{
+ color-scheme: dark;
+ --bg: #070a0f;
+ --card: rgba(255,255,255,.06);
+ --card2: rgba(255,255,255,.04);
+ --border: rgba(255,255,255,.10);
+ --text: rgba(255,255,255,.92);
+ --muted: rgba(255,255,255,.60);
+ --focus: rgba(255,255,255,.22);
+ --shadow: 0 18px 55px rgba(0,0,0,.55);
+ --radius: 16px;
+ }
+
+ * { box-sizing: border-box; margin: 0; padding: 0; }
+
+ body{
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ background:
+ radial-gradient(900px 500px at 20% 15%, rgba(255,255,255,.05), transparent 55%),
+ radial-gradient(800px 500px at 80% 85%, rgba(255,255,255,.04), transparent 55%),
+ var(--bg);
+ color: var(--text);
+ padding: 24px;
+ }
+
+ .card{
+ width: 100%;
+ max-width: 640px;
+ padding: 22px;
+ }
+
+ form{
+ display: flex;
+ gap: 10px;
+ margin-top: 0;
+ }
+
+ input[type="text"]{
+ width: 100%;
+ padding: 12px 14px;
+ font-size: 1rem;
+ color: var(--text);
+ background: rgba(0,0,0,.30);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ outline: none;
+ transition: border-color .15s ease, box-shadow .15s ease;
+ font-family: inherit;
+ }
+
+ input[type="text"]::placeholder{
+ color: rgba(255,255,255,.38);
+ }
+
+ input[type="text"]:focus{
+ border-color: var(--focus);
+ box-shadow: 0 0 0 4px rgba(255,255,255,.06);
+ }
+
+ button{
+ flex: 0 0 auto;
+ padding: 12px 16px;
+ font-size: 0.98rem;
+ font-weight: 600;
+ color: var(--text);
+ background: rgba(255,255,255,.10);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ cursor: pointer;
+ transition: transform .08s ease, background .15s ease, border-color .15s ease;
+ user-select: none;
+ white-space: nowrap;
+ font-family: inherit;
+ }
+
+ button:hover{
+ background: rgba(255,255,255,.14);
+ border-color: rgba(255,255,255,.14);
+ }
+
+ button:active{
+ transform: scale(.98);
+ }
+
+ .below{
+ margin-top: 14px;
+ text-align: center;
+ }
+
+ .back-link{
+ display: inline-block;
+ font-size: .9rem;
+ color: var(--muted);
+ text-decoration: none;
+ max-width: 100%;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+ white-space: normal;
+ }
+
+ .clicks{
+ display: inline-block;
+ font-size: .9rem;
+ }
+
+ .back-link:hover{
+ color: var(--text);
+ }
+
+ /* mobile: stack */
+ @media (max-width: 560px){
+ form{ flex-direction: column; }
+ button{ width: 100%; }
+ }
+</style>
+</head>
+
+<body>
+ <main class="card">
+ <form onsubmit="return false;">
+ <input
+ id="shortUrl"
+ type="text"
+ value="{{ url_root }}{{ short_code }}"
+ readonly
+ aria-label="Short URL"
+ />
+ <button type="button" id="copyBtn" onclick="copyUrl()">Copy</button>
+ </form>
+ <br>
+ <a href="{{ url }}" class="back-link" target="_blank" rel="noreferrer">{{ url }}</a>
+ <div class="below">
+ <a href="/" class="back-link">← Shorten another</a> · <span class="clicks">{{ clicks }} clicks</span>
+ </div>
+ </main>
+
+<script>
+function copyUrl(){
+ const input = document.getElementById('shortUrl');
+ const btn = document.getElementById('copyBtn');
+
+ input.focus();
+ input.select();
+
+ const text = input.value;
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard.writeText(text).then(() => {
+ btn.textContent = 'Copied';
+ setTimeout(() => btn.textContent = 'Copy', 1800);
+ }).catch(() => fallbackCopy(text, btn));
+ } else {
+ fallbackCopy(text, btn);
+ }
+}
+
+function fallbackCopy(text, btn){
+ try{
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.setAttribute('readonly', '');
+ ta.style.position = 'absolute';
+ ta.style.left = '-9999px';
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+
+ btn.textContent = 'Copied';
+ setTimeout(() => btn.textContent = 'Copy', 1800);
+ } catch(e){
+ btn.textContent = 'Copy failed';
+ setTimeout(() => btn.textContent = 'Copy', 1800);
+ }
+}
+</script>
+</body>
+</html>
diff --git a/examples/url_shortener/templates/success.html b/examples/url_shortener/templates/success.html
index 393c336..90d3236 100644
--- a/examples/url_shortener/templates/success.html
+++ b/examples/url_shortener/templates/success.html
@@ -1,98 +1,181 @@
<!DOCTYPE html>
<html lang="en">
<head>
-<meta charset="UTF-8">
-<meta name="viewport" content="width=device-width, initial-scale=1.0">
-<title>URL Shortened - Shorty</title>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>URL Shortened</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
<style>
-* {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
-}
+ :root{
+ color-scheme: dark;
+ --bg: #070a0f;
+ --card: rgba(255,255,255,.06);
+ --card2: rgba(255,255,255,.04);
+ --border: rgba(255,255,255,.10);
+ --text: rgba(255,255,255,.92);
+ --muted: rgba(255,255,255,.60);
+ --focus: rgba(255,255,255,.22);
+ --shadow: 0 18px 55px rgba(0,0,0,.55);
+ --radius: 16px;
+ }
-body {
- height: 100vh;
- display: flex;
- align-items: center;
- justify-content: center;
- font-family: system-ui, -apple-system, sans-serif;
- background:
- radial-gradient(1200px 600px at 15% 20%, rgba(99,102,241,.25), transparent 60%),
- radial-gradient(900px 500px at 85% 75%, rgba(34,197,94,.18), transparent 55%),
- linear-gradient(180deg, #070b14, #0b1220 35%, #070b14);
-}
+ * { box-sizing: border-box; margin: 0; padding: 0; }
-.container {
- width: 90%;
- max-width: 600px;
- text-align: center;
-}
+ body{
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ background:
+ radial-gradient(900px 500px at 20% 15%, rgba(255,255,255,.05), transparent 55%),
+ radial-gradient(800px 500px at 80% 85%, rgba(255,255,255,.04), transparent 55%),
+ var(--bg);
+ color: var(--text);
+ padding: 24px;
+ }
-h1 {
- font-size: 2rem;
- margin-bottom: 2rem;
-}
+ .card{
+ width: 100%;
+ max-width: 640px;
+ padding: 22px;
+ }
-.url-box {
- padding: 1.5rem;
- background: #f5f5f5;
- border-radius: 8px;
- margin-bottom: 1rem;
-}
+ form{
+ display: flex;
+ gap: 10px;
+ margin-top: 0;
+ }
-.short-url {
- font-size: 1.3rem;
- font-weight: bold;
- word-break: break-all;
-}
+ input[type="text"]{
+ width: 100%;
+ padding: 12px 14px;
+ font-size: 1rem;
+ color: var(--text);
+ background: rgba(0,0,0,.30);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ outline: none;
+ transition: border-color .15s ease, box-shadow .15s ease;
+ font-family: inherit;
+ }
-button {
- padding: 1rem;
- font-size: 1.1rem;
- background: linear-gradient(135deg, rgba(99,102,241,.95), rgba(34,197,94,.75));
- color: #fff;
- border: none;
- border-radius: 8px;
- cursor: pointer;
-}
+ input[type="text"]::placeholder{
+ color: rgba(255,255,255,.38);
+ }
-button:hover{
- filter: brightness(1.06);
- box-shadow: 0 16px 42px rgba(0,0,0,.42);
-}
+ input[type="text"]:focus{
+ border-color: var(--focus);
+ box-shadow: 0 0 0 4px rgba(255,255,255,.06);
+ }
-.back-link {
- display: inline-block;
- margin-top: 2rem;
- color: cyan;
- text-decoration: none;
-}
+ button{
+ flex: 0 0 auto;
+ padding: 12px 16px;
+ font-size: 0.98rem;
+ font-weight: 600;
+ color: var(--text);
+ background: rgba(255,255,255,.10);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ cursor: pointer;
+ transition: transform .08s ease, background .15s ease, border-color .15s ease;
+ user-select: none;
+ white-space: nowrap;
+ font-family: inherit;
+ }
-.back-link:hover {
- color: #1E90FF;
-}
+ button:hover{
+ background: rgba(255,255,255,.14);
+ border-color: rgba(255,255,255,.14);
+ }
+
+ button:active{
+ transform: scale(.98);
+ }
+
+ .below{
+ margin-top: 14px;
+ text-align: center;
+ }
+
+ .back-link{
+ display: inline-block;
+ font-size: .9rem;
+ color: var(--muted);
+ text-decoration: none;
+ }
+
+ .back-link:hover{
+ color: var(--text);
+ }
+
+ /* mobile: stack */
+ @media (max-width: 560px){
+ form{ flex-direction: column; }
+ button{ width: 100%; }
+ }
</style>
</head>
+
<body>
-<div class="container">
- <div class="url-box">
- <div class="short-url" id="shortUrl">{{ url_root }}{{ short_id }}</div>
- </div>
- <button class="copy-btn" onclick="copyUrl()">Copy Link</button>
- <br>
- <a href="/" class="back-link">← Shorten another</a>
-</div>
+ <main class="card">
+ <form onsubmit="return false;">
+ <input
+ id="shortUrl"
+ type="text"
+ value="{{ url_root }}{{ short_id }}"
+ readonly
+ aria-label="Short URL"
+ />
+ <button type="button" id="copyBtn" onclick="copyUrl()">Copy</button>
+ </form>
+
+ <div class="below">
+ <a href="/" class="back-link">← Shorten another</a> · <a href="/{{ short_id}}+" class="back-link">Analytics</a>
+ </div>
+ </main>
<script>
-function copyUrl() {
- const url = document.getElementById('shortUrl').textContent;
- navigator.clipboard.writeText(url).then(() => {
- const btn = document.querySelector('.copy-btn');
- btn.textContent = 'Copied!';
- setTimeout(() => btn.textContent = 'Copy Link', 2000);
- });
+function copyUrl(){
+ const input = document.getElementById('shortUrl');
+ const btn = document.getElementById('copyBtn');
+
+ input.focus();
+ input.select();
+
+ const text = input.value;
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard.writeText(text).then(() => {
+ btn.textContent = 'Copied';
+ setTimeout(() => btn.textContent = 'Copy', 1800);
+ }).catch(() => fallbackCopy(text, btn));
+ } else {
+ fallbackCopy(text, btn);
+ }
+}
+
+function fallbackCopy(text, btn){
+ try{
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.setAttribute('readonly', '');
+ ta.style.position = 'absolute';
+ ta.style.left = '-9999px';
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+
+ btn.textContent = 'Copied';
+ setTimeout(() => btn.textContent = 'Copy', 1800);
+ } catch(e){
+ btn.textContent = 'Copy failed';
+ setTimeout(() => btn.textContent = 'Copy', 1800);
+ }
}
</script>
</body>
</html>
+