app.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. import re
  2. import json
  3. import time
  4. import shutil
  5. from datetime import datetime, timezone
  6. from pathlib import Path
  7. from flask import (
  8. Flask,
  9. render_template,
  10. request,
  11. redirect,
  12. url_for,
  13. send_from_directory,
  14. abort,
  15. jsonify,
  16. session,
  17. flash,
  18. )
  19. from werkzeug.utils import secure_filename
  20. from bs4 import BeautifulSoup
  21. import config
  22. from helpers import (
  23. allowed_file,
  24. load_index,
  25. save_index,
  26. fix_image_paths,
  27. extract_summary,
  28. extract_thumbnail,
  29. render_markdown,
  30. generate_static_page,
  31. )
  32. app = Flask(__name__)
  33. app.config.from_object(config)
  34. # 确保必要的目录存在
  35. for dir_path in [
  36. config.UPLOAD_FOLDER,
  37. config.GENERATED_FOLDER,
  38. config.POSTS_DATA_FOLDER,
  39. config.INDEX_FILE.parent,
  40. ]:
  41. dir_path.mkdir(parents=True, exist_ok=True)
  42. # 如果索引文件不存在,创建空列表
  43. if not config.INDEX_FILE.exists():
  44. with open(config.INDEX_FILE, "w", encoding="utf-8") as f:
  45. json.dump([], f, ensure_ascii=False, indent=2)
  46. @app.context_processor
  47. def inject_user_status():
  48. """向所有模板注入登录状态"""
  49. return {"logged_in": "user" in session}
  50. # ==================== 登录 / 登出相关路由 ====================
  51. @app.route("/login", methods=["GET", "POST"])
  52. def login():
  53. """密码登录(无需用户名)"""
  54. if request.method == "GET":
  55. return render_template("login.html")
  56. password = request.form.get("password", "")
  57. if password == config.PASSWORD:
  58. session["user"] = True
  59. flash("登录成功", "success")
  60. return redirect(url_for("index"))
  61. else:
  62. flash("密码错误", "danger")
  63. return render_template("login.html")
  64. @app.route("/logout")
  65. def logout():
  66. session.pop("user", None)
  67. flash("已登出", "info")
  68. return redirect(url_for("index"))
  69. # ==================== 路由 ====================
  70. @app.route("/")
  71. def index():
  72. """首页:瀑布流文章列表(展示完整博文)"""
  73. posts = load_index()
  74. # 按日期倒序排列
  75. posts.sort(key=lambda p: p.get("date", ""), reverse=True)
  76. # 为每篇文章获取内容
  77. for post in posts:
  78. post_id = post.get("id", "")
  79. content = ""
  80. # 优先从原始 Markdown 文件渲染内容(即使 generated 文件不存在也能显示)
  81. md_path = config.POSTS_DATA_FOLDER / post_id / "content.md"
  82. if md_path.exists():
  83. try:
  84. with open(md_path, "r", encoding="utf-8") as f:
  85. md_content = f.read()
  86. fixed_md = fix_image_paths(md_content, post_id)
  87. html_body = render_markdown(fixed_md)
  88. content = html_body
  89. except Exception:
  90. content = ""
  91. else:
  92. # 回退到生成的静态 HTML 文件
  93. generated_path = config.GENERATED_FOLDER / f"{post_id}.html"
  94. if generated_path.exists():
  95. try:
  96. with open(generated_path, "r", encoding="utf-8") as f:
  97. html_content = f.read()
  98. soup = BeautifulSoup(html_content, "html.parser")
  99. card_summary = soup.find("div", class_="card-summary")
  100. if card_summary:
  101. content = card_summary.decode_contents()
  102. except Exception:
  103. content = ""
  104. post["content"] = content
  105. return render_template("index.html", posts=posts)
  106. @app.route("/upload", methods=["GET", "POST"])
  107. def upload():
  108. """上传文章(需要登录)"""
  109. # 登录检查
  110. if "user" not in session:
  111. flash("请先登录", "warning")
  112. return redirect(url_for("login"))
  113. if request.method == "GET":
  114. return render_template("upload.html")
  115. # POST 处理
  116. title = request.form.get("title", "").strip()
  117. if not title:
  118. return "标题不能为空", 400
  119. markdown_file = request.files.get("markdown_file")
  120. if not markdown_file or markdown_file.filename == "":
  121. return "请选择 Markdown 文件", 400
  122. if not allowed_file(markdown_file.filename):
  123. return "不支持的文件类型,请上传 .md 文件", 400
  124. # 生成唯一 ID
  125. post_id = str(int(time.time() * 1000))
  126. # 创建目录
  127. upload_dir = config.UPLOAD_FOLDER / post_id
  128. upload_dir.mkdir(parents=True, exist_ok=True)
  129. posts_data_dir = config.POSTS_DATA_FOLDER / post_id
  130. posts_data_dir.mkdir(parents=True, exist_ok=True)
  131. # 保存 Markdown 文件
  132. md_path = posts_data_dir / "content.md"
  133. markdown_file.save(str(md_path))
  134. # 处理图片上传
  135. images = request.files.getlist("images")
  136. for img in images:
  137. if img and img.filename:
  138. filename = secure_filename(img.filename)
  139. if filename:
  140. img.save(str(upload_dir / filename))
  141. # 读取 Markdown 内容
  142. with open(md_path, "r", encoding="utf-8") as f:
  143. md_content = f.read()
  144. # 修正图片路径
  145. fixed_md = fix_image_paths(md_content, post_id)
  146. # 渲染 HTML
  147. html_body = render_markdown(fixed_md)
  148. # 提取摘要
  149. summary = extract_summary(html_body)
  150. # 提取缩略图
  151. thumbnail = extract_thumbnail(html_body)
  152. # 生成日期(精确到分钟)
  153. date_iso = datetime.now(timezone.utc).replace(second=0, microsecond=0).isoformat()
  154. # 生成静态页面
  155. generate_static_page(post_id, title, html_body, date_iso, thumbnail)
  156. # 更新索引
  157. index = load_index()
  158. index.append(
  159. {
  160. "id": post_id,
  161. "title": title,
  162. "date": date_iso,
  163. "summary": summary,
  164. "thumbnail": thumbnail,
  165. }
  166. )
  167. # 按日期倒序排序
  168. index.sort(key=lambda p: p.get("date", ""), reverse=True)
  169. save_index(index)
  170. return redirect(url_for("index"))
  171. @app.route("/post/<post_id>")
  172. def view_post(post_id: str):
  173. """查看文章详情"""
  174. # 安全验证:只允许字母数字和下划线
  175. if not re.match(r"^[a-zA-Z0-9_]+$", post_id):
  176. abort(404)
  177. try:
  178. return send_from_directory(
  179. config.GENERATED_FOLDER,
  180. f"{post_id}.html",
  181. )
  182. except FileNotFoundError:
  183. abort(404)
  184. @app.route("/admin")
  185. def admin():
  186. """管理页面"""
  187. posts = load_index()
  188. return render_template("admin.html", posts=posts)
  189. @app.route("/admin/delete/<post_id>", methods=["POST"])
  190. def delete_post(post_id: str):
  191. """删除文章"""
  192. # 安全验证
  193. if not re.match(r"^[a-zA-Z0-9_]+$", post_id):
  194. abort(404)
  195. # 删除生成的静态文件
  196. generated_file = config.GENERATED_FOLDER / f"{post_id}.html"
  197. if generated_file.exists():
  198. generated_file.unlink()
  199. # 删除 posts_data 目录
  200. posts_data_dir = config.POSTS_DATA_FOLDER / post_id
  201. if posts_data_dir.exists():
  202. shutil.rmtree(posts_data_dir)
  203. # 删除上传的图片目录
  204. upload_dir = config.UPLOAD_FOLDER / post_id
  205. if upload_dir.exists():
  206. shutil.rmtree(upload_dir)
  207. # 从索引中移除
  208. index = load_index()
  209. index = [p for p in index if p["id"] != post_id]
  210. save_index(index)
  211. return redirect(url_for("admin"))
  212. @app.errorhandler(404)
  213. def not_found(e):
  214. return render_template("404.html"), 404
  215. if __name__ == "__main__":
  216. app.run(host="0.0.0.0", port=5000, debug=True)