Files
blog/app.py
T

214 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""极简博客 —— 单文件 Flask 应用,文章以 JSON 存在 PVC 上。"""
import os
import json
import re
import html
import datetime
import markdown
from flask import Flask, request, redirect, abort, render_template_string
DATA_DIR = os.environ.get("DATA_DIR", "/data")
DB_FILE = os.path.join(DATA_DIR, "posts.json")
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "change-me")
SITE_TITLE = os.environ.get("SITE_TITLE", "我的博客")
app = Flask(__name__)
def load_posts():
if not os.path.exists(DB_FILE):
return []
try:
with open(DB_FILE, encoding="utf-8") as f:
posts = json.load(f)
return sorted(posts, key=lambda p: p.get("created", ""), reverse=True)
except (json.JSONDecodeError, OSError):
return []
def save_posts(posts):
os.makedirs(DATA_DIR, exist_ok=True)
tmp = DB_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(posts, f, ensure_ascii=False, indent=2)
os.replace(tmp, DB_FILE)
def slugify(text):
slug = re.sub(r"[^\w\u4e00-\u9fff-]+", "-", text.strip().lower()).strip("-")
return slug[:60] or "post"
BASE_CSS = """
<style>
:root{--fg:#1a1a1a;--muted:#666;--line:#e5e5e5;--accent:#0b6bcb}
*{box-sizing:border-box}
body{margin:0;font:16px/1.75 -apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
color:var(--fg);background:#fafafa}
.wrap{max-width:760px;margin:0 auto;padding:48px 24px 96px}
header.site{margin-bottom:48px;display:flex;justify-content:space-between;align-items:baseline;
border-bottom:1px solid var(--line);padding-bottom:16px}
header.site h1{font-size:1.6rem;margin:0;letter-spacing:-.02em}
header.site a{color:var(--muted);text-decoration:none;font-size:.9rem}
header.site a:hover{color:var(--accent)}
article.post{background:#fff;border:1px solid var(--line);border-radius:12px;
padding:24px 28px;margin-bottom:20px}
article.post h2{margin:0 0 8px;font-size:1.25rem;line-height:1.4}
article.post h2 a{color:var(--fg);text-decoration:none}
article.post h2 a:hover{color:var(--accent)}
.meta{color:var(--muted);font-size:.85rem}
.excerpt{color:#444;margin:10px 0 0}
.content{background:#fff;border:1px solid var(--line);border-radius:12px;padding:32px 36px}
.content h1{margin-top:0}
.content pre{background:#f5f5f5;padding:16px;border-radius:8px;overflow:auto}
.content code{background:#f5f5f5;padding:2px 6px;border-radius:4px;font-size:.9em}
.content pre code{background:none;padding:0}
.content img{max-width:100%;height:auto;border-radius:8px}
.content blockquote{border-left:3px solid var(--line);margin:0;padding-left:16px;color:var(--muted)}
form.new{background:#fff;border:1px solid var(--line);border-radius:12px;padding:24px}
form.new label{display:block;font-size:.85rem;color:var(--muted);margin:14px 0 6px}
form.new input,form.new textarea{width:100%;padding:10px 12px;border:1px solid var(--line);
border-radius:8px;font:inherit}
form.new textarea{min-height:320px;font-family:ui-monospace,Menlo,monospace;font-size:.92rem}
button{background:var(--accent);color:#fff;border:0;border-radius:8px;padding:10px 20px;
font:inherit;cursor:pointer;margin-top:18px}
button:hover{opacity:.9}
footer{margin-top:64px;color:var(--muted);font-size:.85rem;text-align:center}
</style>
"""
LIST_TPL = """
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ site }}</title>""" + BASE_CSS + """</head><body><div class="wrap">
<header class="site"><h1>{{ site }}</h1><a href="/new">写文章 →</a></header>
{% for p in posts %}
<article class="post">
<h2><a href="/post/{{ p.slug }}">{{ p.title }}</a></h2>
<div class="meta">{{ p.created }} · {{ p.views }} 次阅读</div>
<p class="excerpt">{{ p.excerpt }}</p>
</article>
{% else %}
<article class="post"><p class="meta">还没有文章。点右上角"写文章"开始。</p></article>
{% endfor %}
<footer>由 Flask + Kubernetes + Jenkins + Harbor 驱动</footer>
</div></body></html>
"""
POST_TPL = """
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ p.title }} · {{ site }}</title>""" + BASE_CSS + """</head><body><div class="wrap">
<header class="site"><h1><a href="/" style="color:inherit">{{ site }}</a></h1>
<a href="/">← 返回列表</a></header>
<div class="content">
<h1>{{ p.title }}</h1>
<div class="meta" style="margin-bottom:24px">{{ p.created }}</div>
{{ body|safe }}
</div>
<footer>阅读 {{ p.views }} 次</footer>
</div></body></html>
"""
NEW_TPL = """
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>写文章 · {{ site }}</title>""" + BASE_CSS + """</head><body><div class="wrap">
<header class="site"><h1><a href="/" style="color:inherit">{{ site }}</a></h1>
<a href="/">← 返回列表</a></header>
{% if error %}<div class="post" style="border-color:#e0b4b4;background:#fff5f5">
<p class="meta" style="color:#b00">{{ error }}</p></div>{% endif %}
<form class="new" method="post" action="/new">
<label>标题</label><input name="title" required maxlength="120" value="{{ title or '' }}">
<label>正文(支持 Markdown)</label><textarea name="body" required>{{ body or '' }}</textarea>
<label>管理口令</label><input name="token" type="password" required>
<button type="submit">发布</button>
</form>
</div></body></html>
"""
@app.get("/healthz")
def healthz():
return "ok", 200
@app.get("/")
def index():
posts = []
for p in load_posts():
posts.append({
"slug": p["slug"],
"title": p["title"],
"created": p["created"],
"views": p.get("views", 0),
"excerpt": p.get("excerpt", ""),
})
return render_template_string(LIST_TPL, site=SITE_TITLE, posts=posts)
@app.get("/post/<slug>")
def post_detail(slug):
posts = load_posts()
for p in posts:
if p["slug"] == slug:
p["views"] = p.get("views", 0) + 1
save_posts(posts)
body = markdown.markdown(p["body"], extensions=["fenced_code", "tables"])
return render_template_string(POST_TPL, site=SITE_TITLE, p=p, body=body)
abort(404)
@app.route("/new", methods=["GET", "POST"])
def new_post():
if request.method == "GET":
return render_template_string(NEW_TPL, site=SITE_TITLE, error=None, title="", body="")
title = (request.form.get("title") or "").strip()
body = (request.form.get("body") or "").strip()
token = request.form.get("token") or ""
if token != ADMIN_TOKEN:
return render_template_string(NEW_TPL, site=SITE_TITLE,
error="管理口令不正确", title=title, body=body), 403
if not title or not body:
return render_template_string(NEW_TPL, site=SITE_TITLE,
error="标题和正文都不能为空", title=title, body=body), 400
posts = load_posts()
base = slugify(title)
slug = base
n = 1
existing = {p["slug"] for p in posts}
while slug in existing:
n += 1
slug = f"{base}-{n}"
plain = re.sub(r"[#>*_`\[\]()!-]", "", body)
posts.append({
"slug": slug,
"title": title,
"body": body,
"excerpt": html.escape(plain[:120].replace("\n", " ")),
"created": datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
"views": 0,
})
save_posts(posts)
return redirect(f"/post/{slug}")
@app.post("/post/<slug>/delete")
def delete_post(slug):
token = request.form.get("token") or ""
if token != ADMIN_TOKEN:
abort(403)
posts = [p for p in load_posts() if p["slug"] != slug]
save_posts(posts)
return redirect("/")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "5000")))