返回 · {{ article.published_at }}
+diff --git a/blog.py b/blog.py new file mode 100644 index 0000000..f4ec945 --- /dev/null +++ b/blog.py @@ -0,0 +1,1172 @@ +""" +单文件博客:FastAPI + SQLite + Jinja2,Markdown 预渲染为 HTML。 + +安装依赖: + pip install -r requirements.txt + # 或: pip install fastapi "uvicorn[standard]" jinja2 python-multipart markdown + +运行: + 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 hashlib +import os +import re +import secrets +import sqlite3 +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional +from urllib.parse import quote + +import markdown +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 +# ----------------------------------------------------------------------------- + +_MD_EXTENSIONS = [ + "markdown.extensions.fenced_code", + "markdown.extensions.tables", + "markdown.extensions.nl2br", + "markdown.extensions.sane_lists", +] + + +def render_markdown(md_text: str) -> str: + return markdown.markdown(md_text, extensions=_MD_EXTENSIONS) + + +# ----------------------------------------------------------------------------- +# 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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0e100f6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.110,<1 +uvicorn[standard]>=0.27,<1 +jinja2>=3.1,<4 +python-multipart>=0.0.9,<1 +markdown>=3.5,<4 +isdangerous \ No newline at end of file