返回 · {{ article.published_at }}
""" 单文件博客:FastAPI + SQLite + Jinja2,Markdown 预渲染为 HTML。 安装依赖: bash scripts/install.sh # 或手动: 编译 vendor/marknb/libmarknb.so 后 pip install -r requirements.txt 运行: python blog.py # 默认监听 127.0.0.1:8765,数据目录 ./data/(可用 BLOG_DATA_DIR 覆盖根路径) 环境变量: BLOG_HOST 默认 127.0.0.1 BLOG_PORT 默认 8765 BLOG_DATA_DIR 默认 data(其下使用 blog.sqlite3) BLOG_SITE_TITLE 站点标题(可选) BLOG_ADMIN_PASSWORD 管理员密码;未设置且数据库无哈希时,启动时生成随机密码并打印一次 BLOG_SECRET_KEY 会话签名密钥;未设置则从 SQLite settings 读取或生成并持久化 BLOG_ICP_TEXT / BLOG_ICP_URL 页脚备案文案与链接(可选) 图片: 正文 Markdown 仅支持外链图床 URL,不提供本地上传。 数学公式: 使用 KaTeX auto-render,支持 $$..$$、$..$、\\(..\\)、\\[..\\](见 base 模板内 delimiters)。 """ from __future__ import annotations import asyncio import ctypes import hashlib import os import re import secrets import sqlite3 import sys from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional from urllib.parse import quote from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi.responses import HTMLResponse, RedirectResponse from jinja2 import BaseLoader, Environment, TemplateNotFound from starlette.middleware.sessions import SessionMiddleware from uvicorn import Config, Server from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware # ----------------------------------------------------------------------------- # Config # ----------------------------------------------------------------------------- DATA_DIR = Path(os.environ.get("BLOG_DATA_DIR", "data")) DB_PATH = DATA_DIR / "blog.sqlite3" BLOG_HOST = os.environ.get("BLOG_HOST", "127.0.0.1") BLOG_PORT = int(os.environ.get("BLOG_PORT", "8765")) BLOG_ICP_TEXT = os.environ.get("BLOG_ICP_TEXT", "").strip() BLOG_ICP_URL = os.environ.get("BLOG_ICP_URL", "").strip() PBKDF2_ITERATIONS = 390_000 SALT_LEN = 16 # ----------------------------------------------------------------------------- # Password (stdlib pbkdf2_hmac, no extra deps) # ----------------------------------------------------------------------------- def hash_password(password: str) -> str: salt = secrets.token_bytes(SALT_LEN) dk = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt, PBKDF2_ITERATIONS ) return f"{PBKDF2_ITERATIONS}${salt.hex()}${dk.hex()}" def verify_password(password: str, stored: str) -> bool: try: it_s, salt_hex, dk_hex = stored.split("$", 2) iterations = int(it_s) salt = bytes.fromhex(salt_hex) expected = bytes.fromhex(dk_hex) dk = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt, iterations ) return secrets.compare_digest(dk, expected) except Exception: return False # ----------------------------------------------------------------------------- # DB # ----------------------------------------------------------------------------- def _utc_now_iso() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat() def init_db() -> None: DATA_DIR.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(DB_PATH) try: conn.executescript( """ PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER REFERENCES groups(id) ON DELETE RESTRICT, name TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE RESTRICT, title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, body_md TEXT NOT NULL, body_html TEXT NOT NULL, published_at TEXT NOT NULL, display_order INTEGER, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_articles_group ON articles(group_id); CREATE INDEX IF NOT EXISTS idx_groups_parent ON groups(parent_id); """ ) conn.commit() finally: conn.close() @contextmanager def get_db(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") try: yield conn conn.commit() except Exception: conn.rollback() raise finally: conn.close() def get_setting(conn: sqlite3.Connection, key: str) -> Optional[str]: row = conn.execute( "SELECT value FROM settings WHERE key = ?", (key,) ).fetchone() return row[0] if row else None def set_setting(conn: sqlite3.Connection, key: str, value: str) -> None: conn.execute( "INSERT INTO settings(key, value) VALUES(?, ?) " "ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value), ) def bootstrap_secrets() -> str: """返回 SessionMiddleware 使用的 secret;副作用:初始化管理员密码与密钥。""" with get_db() as conn: # Session secret env_sk = os.environ.get("BLOG_SECRET_KEY") if env_sk: session_secret = env_sk else: sk = get_setting(conn, "secret_key") if not sk: sk = secrets.token_hex(32) set_setting(conn, "secret_key", sk) session_secret = sk # Admin password hash env_pw = os.environ.get("BLOG_ADMIN_PASSWORD") if env_pw: set_setting(conn, "admin_password_hash", hash_password(env_pw)) elif not get_setting(conn, "admin_password_hash"): pwd = secrets.token_urlsafe(14) print("\n" + "=" * 64) print(" 首次启动:管理员密码(请立即保存,不会再次打印)") print(" ", pwd) print("=" * 64 + "\n", flush=True) set_setting(conn, "admin_password_hash", hash_password(pwd)) return session_secret # ----------------------------------------------------------------------------- # Markdown → HTML (marknb: https://cnb.cool/whcself/marknb) # ----------------------------------------------------------------------------- _MARKNB_DEFAULT = 65536 def _load_marknb() -> ctypes.CDLL: lib_path = os.environ.get("MARKNB_LIB") if not lib_path: here = Path(__file__).resolve().parent for candidate in ( here / "vendor" / "marknb" / "libmarknb.so", Path("/usr/local/lib/libmarknb.so"), ): if candidate.is_file(): lib_path = str(candidate) break if not lib_path: print( "找不到 marknb 动态库。请先运行: bash scripts/install.sh(升级: bash scripts/upgrade.sh)", file=sys.stderr, ) sys.exit(1) lib = ctypes.CDLL(lib_path) lib.marknb_parse.argtypes = [ ctypes.c_char_p, ctypes.c_size_t, ctypes.c_char_p, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t), ] lib.marknb_parse.restype = ctypes.c_int return lib _MARKNB = _load_marknb() def render_markdown(md_text: str) -> str: md = md_text.encode("utf-8") cap = max(len(md) * 4 + 256, _MARKNB_DEFAULT) limit = len(md) * 64 + 4096 while cap <= limit: buf = ctypes.create_string_buffer(cap) out_len = ctypes.c_size_t(0) if _MARKNB.marknb_parse(md, len(md), buf, cap, ctypes.byref(out_len)) == 0: return buf.raw[: out_len.value].decode("utf-8") cap *= 2 raise RuntimeError("marknb: output buffer exhausted") # ----------------------------------------------------------------------------- # Slug # ----------------------------------------------------------------------------- _slug_re = re.compile(r"[^a-z0-9]+") def slugify(title: str) -> str: s = title.strip().lower() s = _slug_re.sub("-", s).strip("-") return s or "post" def unique_slug(conn: sqlite3.Connection, base: str, exclude_id: Optional[int]) -> str: slug = base n = 2 while True: row = conn.execute( "SELECT id FROM articles WHERE slug = ?", (slug,) ).fetchone() if not row or (exclude_id is not None and row[0] == exclude_id): return slug slug = f"{base}-{n}" n += 1 # ----------------------------------------------------------------------------- # Jinja (string templates) # ----------------------------------------------------------------------------- TEMPLATES: dict[str, str] = {} TEMPLATES["base.html"] = r"""
分组按名称字典序排列;文章:有「显示顺序」时按该顺序,否则按发布时间倒序。
返回 · {{ article.published_at }}
{{ error }}
{% endif %} {% endblock %} """ TEMPLATES["admin/home.html"] = """ {% extends "base.html" %} {% block title %}管理 — {{ site_title }}{% endblock %} {% block content %}{{ msg }}
{% endif %} {% if err %}{{ err }}
{% endif %}子分组按名称排序(不区分大小写)。删除分组要求无子分组且无文章引用。
{% endblock %} """ TEMPLATES["admin/articles.html"] = """ {% extends "base.html" %} {% block title %}文章 — {{ site_title }}{% endblock %} {% block content %}{{ msg }}
{% endif %} {% if err %}{{ err }}
{% endif %}| 标题 | Slug | 分组 | 发布 | 顺序 | 操作 |
|---|---|---|---|---|---|
| {{ a.title }} | {{ a.slug }} | {{ a.group_name }} | {{ a.published_at }} | {{ a.display_order if a.display_order is not none else '' }} | 编辑 |
{{ err }}
{% endif %} {% endblock %} """ class DictLoader(BaseLoader): def get_source(self, environment, template): if template in TEMPLATES: return TEMPLATES[template], f"string:{template}", lambda: True raise TemplateNotFound(template) jinja_env = Environment(loader=DictLoader(), autoescape=True) def render_template(name: str, **ctx: Any) -> str: tpl = jinja_env.get_template(name) return tpl.render(**ctx) # ----------------------------------------------------------------------------- # Group tree helpers # ----------------------------------------------------------------------------- def fetch_groups(conn: sqlite3.Connection) -> list[sqlite3.Row]: return list( conn.execute( "SELECT id, parent_id, name, created_at FROM groups ORDER BY id" ).fetchall() ) def children_by_parent( groups: list[sqlite3.Row], ) -> dict[Optional[int], list[sqlite3.Row]]: buckets: dict[Optional[int], list[sqlite3.Row]] = {} for g in groups: pid = g["parent_id"] buckets.setdefault(pid, []).append(g) for pid in buckets: buckets[pid].sort(key=lambda r: (r["name"] or "").lower()) return buckets def articles_for_group(conn: sqlite3.Connection, group_id: int) -> list[sqlite3.Row]: return list( conn.execute( """ SELECT id, title, slug, published_at, display_order FROM articles WHERE group_id = ? ORDER BY CASE WHEN display_order IS NULL THEN 1 ELSE 0 END, display_order ASC, published_at DESC """, (group_id,), ).fetchall() ) def build_public_tree_html(conn: sqlite3.Connection) -> str: groups = fetch_groups(conn) by_parent = children_by_parent(groups) def node_html(gid: int) -> str: g = next((x for x in groups if x["id"] == gid), None) if not g: return "" arts = articles_for_group(conn, gid) kids = by_parent.get(gid, []) parts = [f'暂无分组与文章。请登录后台添加。
' inner = "".join(node_html(r["id"]) for r in roots) return f"请先创建分组。
", status_code=400 ) article = { "title": "", "slug": "", "body_md": "", "published_at_input": dt_local_value(_utc_now_iso()), "display_order": None, } return HTMLResponse( render_template( "admin/article_form.html", **tpl_ctx( request, csrf_token=tok, form_title="新建文章", article=article, group_options=opts, err=request.query_params.get("err"), ), ) ) @app.post("/admin/articles/new") async def admin_article_new_post( request: Request, csrf_token: str = Form(...), title: str = Form(...), slug: str = Form(""), group_id: int = Form(...), published_at: str = Form(...), display_order: str = Form(""), body_md: str = Form(""), file: Optional[UploadFile] = File(None), ): if not request.session.get("admin"): return RedirectResponse("/admin/login", status_code=302) verify_csrf(request, csrf_token) body = body_md or "" if file and file.filename: raw = await file.read() body = raw.decode("utf-8", errors="replace") title = title.strip() slug = slug.strip() disp: Optional[int] if str(display_order).strip() == "": disp = None else: try: disp = int(str(display_order).strip()) except ValueError: return RedirectResponse( "/admin/articles/new?err=" + quote("显示顺序必须是整数"), status_code=302, ) try: pub_iso = parse_published_at(published_at) except Exception: return RedirectResponse( "/admin/articles/new?err=" + quote("发布时间无效"), status_code=302 ) with get_db() as conn: g = conn.execute( "SELECT id FROM groups WHERE id = ?", (group_id,) ).fetchone() if not g: return RedirectResponse("/admin/articles/new?err=分组不存在", status_code=302) base = slugify(slug or title) slug_f = unique_slug(conn, base, None) html = render_markdown(body) now = _utc_now_iso() conn.execute( """ INSERT INTO articles(group_id, title, slug, body_md, body_html, published_at, display_order, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?) """, ( group_id, title, slug_f, body, html, pub_iso, disp, now, now, ), ) return RedirectResponse("/admin/articles?msg=已创建", status_code=302) @app.get("/admin/articles/{aid}/edit", response_class=HTMLResponse) def admin_article_edit_get(request: Request, aid: int): if not request.session.get("admin"): return RedirectResponse("/admin/login", status_code=302) tok = request.session.get("csrf") or new_csrf() request.session["csrf"] = tok with get_db() as conn: row = conn.execute( "SELECT id, group_id, title, slug, body_md, published_at, display_order FROM articles WHERE id = ?", (aid,), ).fetchone() if not row: raise HTTPException(404) article = dict(row) article["published_at_input"] = dt_local_value(article["published_at"]) opts = group_option_html(conn, article["group_id"]) return HTMLResponse( render_template( "admin/article_form.html", **tpl_ctx( request, csrf_token=tok, form_title="编辑文章", article=article, group_options=opts, err=request.query_params.get("err"), ), ) ) @app.post("/admin/articles/{aid}/edit") async def admin_article_edit_post( request: Request, aid: int, csrf_token: str = Form(...), title: str = Form(...), slug: str = Form(""), group_id: int = Form(...), published_at: str = Form(...), display_order: str = Form(""), body_md: str = Form(""), file: Optional[UploadFile] = File(None), ): if not request.session.get("admin"): return RedirectResponse("/admin/login", status_code=302) verify_csrf(request, csrf_token) body = body_md or "" if file and file.filename: raw = await file.read() body = raw.decode("utf-8", errors="replace") title = title.strip() slug_in = slug.strip() if str(display_order).strip() == "": disp = None else: try: disp = int(str(display_order).strip()) except ValueError: return RedirectResponse( f"/admin/articles/{aid}/edit?err=" + quote("显示顺序必须是整数"), status_code=302, ) try: pub_iso = parse_published_at(published_at) except Exception: return RedirectResponse( f"/admin/articles/{aid}/edit?err=" + quote("发布时间无效"), status_code=302, ) with get_db() as conn: row = conn.execute("SELECT id FROM articles WHERE id = ?", (aid,)).fetchone() if not row: raise HTTPException(404) g = conn.execute( "SELECT id FROM groups WHERE id = ?", (group_id,) ).fetchone() if not g: return RedirectResponse( f"/admin/articles/{aid}/edit?err=分组不存在", status_code=302 ) base = slugify(slug_in or title) slug_f = unique_slug(conn, base, aid) html = render_markdown(body) now = _utc_now_iso() conn.execute( """ UPDATE articles SET group_id=?, title=?, slug=?, body_md=?, body_html=?, published_at=?, display_order=?, updated_at=? WHERE id=? """, ( group_id, title, slug_f, body, html, pub_iso, disp, now, aid, ), ) return RedirectResponse("/admin/articles?msg=已保存", status_code=302) @app.post("/admin/articles/{aid}/delete") async def admin_article_delete( request: Request, aid: int, csrf_token: str = Form(...) ): if not request.session.get("admin"): return RedirectResponse("/admin/login", status_code=302) verify_csrf(request, csrf_token) with get_db() as conn: conn.execute("DELETE FROM articles WHERE id = ?", (aid,)) return RedirectResponse("/admin/articles?msg=已删除", status_code=302) @app.post("/admin/articles/{aid}/rerender") async def admin_article_rerender( request: Request, aid: int, csrf_token: str = Form(...) ): if not request.session.get("admin"): return RedirectResponse("/admin/login", status_code=302) verify_csrf(request, csrf_token) with get_db() as conn: row = conn.execute( "SELECT body_md FROM articles WHERE id = ?", (aid,) ).fetchone() if not row: raise HTTPException(404) html = render_markdown(row["body_md"]) conn.execute( "UPDATE articles SET body_html = ?, updated_at = ? WHERE id = ?", (html, _utc_now_iso(), aid), ) return RedirectResponse("/admin/articles?msg=已重新渲染", status_code=302) return app app = create_app() def main(): init_db() cfg = Config( app, host=BLOG_HOST, port=BLOG_PORT, log_level="info", ) server = Server(cfg) asyncio.run(server.serve()) if __name__ == "__main__": main()