214 lines
7.9 KiB
Python
214 lines
7.9 KiB
Python
#!/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 驱动 · v2 自动发布</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")))
|