app.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  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. config.UPLOAD_FOLDER / "editor_uploads",
  41. ]:
  42. dir_path.mkdir(parents=True, exist_ok=True)
  43. # 如果索引文件不存在,创建空列表
  44. if not config.INDEX_FILE.exists():
  45. with open(config.INDEX_FILE, "w", encoding="utf-8") as f:
  46. json.dump([], f, ensure_ascii=False, indent=2)
  47. @app.context_processor
  48. def inject_user_status():
  49. """向所有模板注入登录状态"""
  50. return {"logged_in": "user" in session}
  51. # ==================== 登录 / 登出相关路由 ====================
  52. @app.route("/login", methods=["GET", "POST"])
  53. def login():
  54. """密码登录(无需用户名)"""
  55. if request.method == "GET":
  56. return render_template("login.html")
  57. password = request.form.get("password", "")
  58. if password == config.PASSWORD:
  59. session["user"] = True
  60. flash("登录成功", "success")
  61. return redirect(url_for("index"))
  62. else:
  63. flash("密码错误", "danger")
  64. return render_template("login.html")
  65. @app.route("/logout")
  66. def logout():
  67. session.pop("user", None)
  68. flash("已登出", "info")
  69. return redirect(url_for("index"))
  70. # ==================== 编辑器相关路由 ====================
  71. @app.route("/editor", methods=["GET", "POST"])
  72. def editor():
  73. """Markdown 编辑器页面:创建新文章(需要登录)"""
  74. if "user" not in session:
  75. flash("请先登录", "warning")
  76. return redirect(url_for("login"))
  77. if request.method == "GET":
  78. return render_template("editor.html")
  79. # POST:保存文章
  80. title = request.form.get("title", "").strip()
  81. if not title:
  82. flash("标题不能为空", "danger")
  83. return redirect(url_for("editor"))
  84. md_content = request.form.get("content", "")
  85. if not md_content.strip():
  86. flash("内容不能为空", "danger")
  87. return redirect(url_for("editor"))
  88. # 生成唯一文章 ID
  89. post_id = str(int(time.time() * 1000))
  90. # 确保文章数据目录存在
  91. posts_data_dir = config.POSTS_DATA_FOLDER / post_id
  92. posts_data_dir.mkdir(parents=True, exist_ok=True)
  93. # 保存 Markdown 文件
  94. md_path = posts_data_dir / "content.md"
  95. with open(md_path, "w", encoding="utf-8") as f:
  96. f.write(md_content)
  97. # 修正图片路径(允许 http、/ 开头路径保持原样)
  98. fixed_md = fix_image_paths(md_content, post_id)
  99. # 渲染 HTML
  100. html_body = render_markdown(fixed_md)
  101. # 提取摘要与缩略图
  102. summary = extract_summary(html_body)
  103. thumbnail = extract_thumbnail(html_body)
  104. # 生成日期(精确到分钟)
  105. date_iso = datetime.now(timezone.utc).replace(second=0, microsecond=0).isoformat()
  106. # 生成静态页面
  107. generate_static_page(post_id, title, html_body, date_iso, thumbnail)
  108. # 更新索引
  109. index = load_index()
  110. index.append(
  111. {
  112. "id": post_id,
  113. "title": title,
  114. "date": date_iso,
  115. "summary": summary,
  116. "thumbnail": thumbnail,
  117. }
  118. )
  119. index.sort(key=lambda p: p.get("date", ""), reverse=True)
  120. save_index(index)
  121. flash("文章发布成功", "success")
  122. return redirect(url_for("index"))
  123. @app.route("/editor/upload-image", methods=["POST"])
  124. def editor_upload_image():
  125. """处理编辑器中的图片上传,返回 Markdown 图片代码"""
  126. if "user" not in session:
  127. return jsonify({"success": False, "error": "需要登录"}), 403
  128. if "image" not in request.files:
  129. return jsonify({"success": False, "error": "没有选择图片"}), 400
  130. file = request.files["image"]
  131. if file.filename == "":
  132. return jsonify({"success": False, "error": "文件名为空"}), 400
  133. # 只允许图片类型
  134. if not allowed_file(file.filename):
  135. return jsonify({"success": False, "error": "不支持的文件类型"}), 400
  136. # 保存到 editor_uploads 目录
  137. editor_uploads_dir = config.UPLOAD_FOLDER / "editor_uploads"
  138. editor_uploads_dir.mkdir(parents=True, exist_ok=True)
  139. safe_name = secure_filename(file.filename)
  140. timestamp = str(int(time.time() * 1000))
  141. new_name = f"{timestamp}_{safe_name}"
  142. file_path = editor_uploads_dir / new_name
  143. file.save(str(file_path))
  144. # 生成可访问 URL 和 Markdown 片段
  145. image_url = f"/static/uploads/posts/editor_uploads/{new_name}"
  146. markdown_code = f"![{safe_name}]({image_url})"
  147. return jsonify(
  148. {
  149. "success": True,
  150. "url": image_url,
  151. "markdown": markdown_code,
  152. }
  153. )
  154. # ==================== 路由 ====================
  155. @app.route("/")
  156. def index():
  157. """首页:瀑布流文章列表(展示完整博文)"""
  158. posts = load_index()
  159. # 按日期倒序排列
  160. posts.sort(key=lambda p: p.get("date", ""), reverse=True)
  161. # 为每篇文章获取内容
  162. for post in posts:
  163. post_id = post.get("id", "")
  164. content = ""
  165. # 优先从原始 Markdown 文件渲染内容(即使 generated 文件不存在也能显示)
  166. md_path = config.POSTS_DATA_FOLDER / post_id / "content.md"
  167. if md_path.exists():
  168. try:
  169. with open(md_path, "r", encoding="utf-8") as f:
  170. md_content = f.read()
  171. fixed_md = fix_image_paths(md_content, post_id)
  172. html_body = render_markdown(fixed_md)
  173. content = html_body
  174. except Exception:
  175. content = ""
  176. else:
  177. # 回退到生成的静态 HTML 文件
  178. generated_path = config.GENERATED_FOLDER / f"{post_id}.html"
  179. if generated_path.exists():
  180. try:
  181. with open(generated_path, "r", encoding="utf-8") as f:
  182. html_content = f.read()
  183. soup = BeautifulSoup(html_content, "html.parser")
  184. card_summary = soup.find("div", class_="card-summary")
  185. if card_summary:
  186. content = card_summary.decode_contents()
  187. except Exception:
  188. content = ""
  189. post["content"] = content
  190. return render_template("index.html", posts=posts)
  191. @app.route("/upload", methods=["GET", "POST"])
  192. def upload():
  193. """上传文章(原始文件上传,需要登录)"""
  194. # 登录检查
  195. if "user" not in session:
  196. flash("请先登录", "warning")
  197. return redirect(url_for("login"))
  198. if request.method == "GET":
  199. return render_template("upload.html")
  200. # POST 处理
  201. title = request.form.get("title", "").strip()
  202. if not title:
  203. return "标题不能为空", 400
  204. markdown_file = request.files.get("markdown_file")
  205. if not markdown_file or markdown_file.filename == "":
  206. return "请选择 Markdown 文件", 400
  207. if not allowed_file(markdown_file.filename):
  208. return "不支持的文件类型,请上传 .md 文件", 400
  209. # 生成唯一 ID
  210. post_id = str(int(time.time() * 1000))
  211. # 创建目录
  212. upload_dir = config.UPLOAD_FOLDER / post_id
  213. upload_dir.mkdir(parents=True, exist_ok=True)
  214. posts_data_dir = config.POSTS_DATA_FOLDER / post_id
  215. posts_data_dir.mkdir(parents=True, exist_ok=True)
  216. # 保存 Markdown 文件
  217. md_path = posts_data_dir / "content.md"
  218. markdown_file.save(str(md_path))
  219. # 处理图片上传
  220. images = request.files.getlist("images")
  221. for img in images:
  222. if img and img.filename:
  223. filename = secure_filename(img.filename)
  224. if filename:
  225. img.save(str(upload_dir / filename))
  226. # 读取 Markdown 内容
  227. with open(md_path, "r", encoding="utf-8") as f:
  228. md_content = f.read()
  229. # 修正图片路径
  230. fixed_md = fix_image_paths(md_content, post_id)
  231. # 渲染 HTML
  232. html_body = render_markdown(fixed_md)
  233. # 提取摘要
  234. summary = extract_summary(html_body)
  235. # 提取缩略图
  236. thumbnail = extract_thumbnail(html_body)
  237. # 生成日期(精确到分钟)
  238. date_iso = datetime.now(timezone.utc).replace(second=0, microsecond=0).isoformat()
  239. # 生成静态页面
  240. generate_static_page(post_id, title, html_body, date_iso, thumbnail)
  241. # 更新索引
  242. index = load_index()
  243. index.append(
  244. {
  245. "id": post_id,
  246. "title": title,
  247. "date": date_iso,
  248. "summary": summary,
  249. "thumbnail": thumbnail,
  250. }
  251. )
  252. # 按日期倒序排序
  253. index.sort(key=lambda p: p.get("date", ""), reverse=True)
  254. save_index(index)
  255. return redirect(url_for("index"))
  256. @app.route("/post/<post_id>")
  257. def view_post(post_id: str):
  258. """查看文章详情"""
  259. # 安全验证:只允许字母数字和下划线
  260. if not re.match(r"^[a-zA-Z0-9_]+$", post_id):
  261. abort(404)
  262. try:
  263. return send_from_directory(
  264. config.GENERATED_FOLDER,
  265. f"{post_id}.html",
  266. )
  267. except FileNotFoundError:
  268. abort(404)
  269. @app.route("/admin")
  270. def admin():
  271. """管理页面"""
  272. posts = load_index()
  273. return render_template("admin.html", posts=posts)
  274. @app.route("/admin/delete/<post_id>", methods=["POST"])
  275. def delete_post(post_id: str):
  276. """删除文章"""
  277. # 安全验证
  278. if not re.match(r"^[a-zA-Z0-9_]+$", post_id):
  279. abort(404)
  280. # 删除生成的静态文件
  281. generated_file = config.GENERATED_FOLDER / f"{post_id}.html"
  282. if generated_file.exists():
  283. generated_file.unlink()
  284. # 删除 posts_data 目录
  285. posts_data_dir = config.POSTS_DATA_FOLDER / post_id
  286. if posts_data_dir.exists():
  287. shutil.rmtree(posts_data_dir)
  288. # 删除上传的图片目录
  289. upload_dir = config.UPLOAD_FOLDER / post_id
  290. if upload_dir.exists():
  291. shutil.rmtree(upload_dir)
  292. # 从索引中移除
  293. index = load_index()
  294. index = [p for p in index if p["id"] != post_id]
  295. save_index(index)
  296. return redirect(url_for("admin"))
  297. @app.errorhandler(404)
  298. def not_found(e):
  299. return render_template("404.html"), 404
  300. if __name__ == "__main__":
  301. app.run(host="0.0.0.0", port=5000, debug=True)