app.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  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. # ---------- 图片缩放辅助函数 ----------
  48. def resize_image_if_large(filepath: str, max_width: int = 1200):
  49. """如果图片宽度超过 max_width,则等比缩放并覆盖原文件。
  50. 不处理 GIF 文件(保留动画),若 Pillow 未安装则静默跳过。"""
  51. try:
  52. from PIL import Image
  53. except ImportError:
  54. return # 不做任何处理,避免依赖问题
  55. try:
  56. img = Image.open(filepath)
  57. w, h = img.size
  58. if w <= max_width:
  59. return
  60. # 跳过 GIF(避免丢失动画)
  61. if img.format == "GIF":
  62. return
  63. new_h = int(h * max_width / w)
  64. img = img.resize((max_width, new_h), Image.LANCZOS)
  65. # 保存时尽量保留原格式和质量
  66. save_kwargs = {}
  67. if img.format == "JPEG":
  68. save_kwargs["quality"] = 85
  69. save_kwargs["optimize"] = True
  70. elif img.format == "PNG":
  71. save_kwargs["optimize"] = True
  72. img.save(filepath, format=img.format, **save_kwargs)
  73. except Exception:
  74. # 任何异常都静默跳过,不影响上传流程
  75. pass
  76. @app.context_processor
  77. def inject_user_status():
  78. """向所有模板注入登录状态"""
  79. return {"logged_in": "user" in session}
  80. # ==================== 登录 / 登出相关路由 ====================
  81. @app.route("/login", methods=["GET", "POST"])
  82. def login():
  83. """密码登录(无需用户名)"""
  84. if request.method == "GET":
  85. return render_template("login.html")
  86. password = request.form.get("password", "")
  87. if password == config.PASSWORD:
  88. session["user"] = True
  89. flash("登录成功", "success")
  90. return redirect(url_for("index"))
  91. else:
  92. flash("密码错误", "danger")
  93. return render_template("login.html")
  94. @app.route("/logout")
  95. def logout():
  96. session.pop("user", None)
  97. flash("已登出", "info")
  98. return redirect(url_for("index"))
  99. # ==================== 编辑器相关路由 ====================
  100. @app.route("/editor", methods=["GET", "POST"])
  101. def editor():
  102. """Markdown 编辑器页面:创建新文章(需要登录)"""
  103. if "user" not in session:
  104. flash("请先登录", "warning")
  105. return redirect(url_for("login"))
  106. if request.method == "GET":
  107. return render_template("editor.html")
  108. # POST:保存文章
  109. title = request.form.get("title", "").strip()
  110. if not title:
  111. flash("标题不能为空", "danger")
  112. return redirect(url_for("editor"))
  113. md_content = request.form.get("content", "")
  114. if not md_content.strip():
  115. flash("内容不能为空", "danger")
  116. return redirect(url_for("editor"))
  117. # 生成唯一文章 ID
  118. post_id = str(int(time.time() * 1000))
  119. # 确保文章数据目录存在
  120. posts_data_dir = config.POSTS_DATA_FOLDER / post_id
  121. posts_data_dir.mkdir(parents=True, exist_ok=True)
  122. # 保存 Markdown 文件
  123. md_path = posts_data_dir / "content.md"
  124. with open(md_path, "w", encoding="utf-8") as f:
  125. f.write(md_content)
  126. # 修正图片路径(允许 http、/ 开头路径保持原样)
  127. fixed_md = fix_image_paths(md_content, post_id)
  128. # 渲染 HTML
  129. html_body = render_markdown(fixed_md)
  130. # 提取摘要与缩略图
  131. summary = extract_summary(html_body)
  132. thumbnail = extract_thumbnail(html_body)
  133. # 生成日期(精确到分钟)
  134. date_iso = datetime.now(timezone.utc).replace(second=0, microsecond=0).isoformat()
  135. # 生成静态页面
  136. generate_static_page(post_id, title, html_body, date_iso, thumbnail)
  137. # 更新索引
  138. index = load_index()
  139. index.append(
  140. {
  141. "id": post_id,
  142. "title": title,
  143. "date": date_iso,
  144. "summary": summary,
  145. "thumbnail": thumbnail,
  146. }
  147. )
  148. index.sort(key=lambda p: p.get("date", ""), reverse=True)
  149. save_index(index)
  150. flash("文章发布成功", "success")
  151. return redirect(url_for("index"))
  152. @app.route("/editor/upload-image", methods=["POST"])
  153. def editor_upload_image():
  154. """处理编辑器中的图片上传,返回 Markdown 图片代码"""
  155. if "user" not in session:
  156. return jsonify({"success": False, "error": "需要登录"}), 403
  157. if "image" not in request.files:
  158. return jsonify({"success": False, "error": "没有选择图片"}), 400
  159. file = request.files["image"]
  160. if file.filename == "":
  161. return jsonify({"success": False, "error": "文件名为空"}), 400
  162. # 只允许图片类型
  163. if not allowed_file(file.filename):
  164. return jsonify({"success": False, "error": "不支持的文件类型"}), 400
  165. # 保存到 editor_uploads 目录
  166. editor_uploads_dir = config.UPLOAD_FOLDER / "editor_uploads"
  167. editor_uploads_dir.mkdir(parents=True, exist_ok=True)
  168. safe_name = secure_filename(file.filename)
  169. timestamp = str(int(time.time() * 1000))
  170. new_name = f"{timestamp}_{safe_name}"
  171. file_path = editor_uploads_dir / new_name
  172. file.save(str(file_path))
  173. # ---------- 对上传的图片进行缩放 ----------
  174. resize_image_if_large(str(file_path))
  175. # 生成可访问 URL 和 Markdown 片段
  176. image_url = f"/static/uploads/posts/editor_uploads/{new_name}"
  177. markdown_code = f"![{safe_name}]({image_url})"
  178. return jsonify(
  179. {
  180. "success": True,
  181. "url": image_url,
  182. "markdown": markdown_code,
  183. }
  184. )
  185. # ==================== 路由 ====================
  186. @app.route("/")
  187. def index():
  188. """首页:瀑布流文章列表(展示完整博文)"""
  189. posts = load_index()
  190. # 按日期倒序排列
  191. posts.sort(key=lambda p: p.get("date", ""), reverse=True)
  192. # 为每篇文章获取内容
  193. for post in posts:
  194. post_id = post.get("id", "")
  195. content = ""
  196. # 优先从原始 Markdown 文件渲染内容(即使 generated 文件不存在也能显示)
  197. md_path = config.POSTS_DATA_FOLDER / post_id / "content.md"
  198. if md_path.exists():
  199. try:
  200. with open(md_path, "r", encoding="utf-8") as f:
  201. md_content = f.read()
  202. fixed_md = fix_image_paths(md_content, post_id)
  203. html_body = render_markdown(fixed_md)
  204. content = html_body
  205. except Exception:
  206. content = ""
  207. else:
  208. # 回退到生成的静态 HTML 文件
  209. generated_path = config.GENERATED_FOLDER / f"{post_id}.html"
  210. if generated_path.exists():
  211. try:
  212. with open(generated_path, "r", encoding="utf-8") as f:
  213. html_content = f.read()
  214. soup = BeautifulSoup(html_content, "html.parser")
  215. card_summary = soup.find("div", class_="card-summary")
  216. if card_summary:
  217. content = card_summary.decode_contents()
  218. except Exception:
  219. content = ""
  220. post["content"] = content
  221. return render_template("index.html", posts=posts)
  222. @app.route("/upload", methods=["GET", "POST"])
  223. def upload():
  224. """上传文章(原始文件上传,需要登录)"""
  225. # 登录检查
  226. if "user" not in session:
  227. flash("请先登录", "warning")
  228. return redirect(url_for("login"))
  229. if request.method == "GET":
  230. return render_template("upload.html")
  231. # POST 处理
  232. title = request.form.get("title", "").strip()
  233. if not title:
  234. return "标题不能为空", 400
  235. markdown_file = request.files.get("markdown_file")
  236. if not markdown_file or markdown_file.filename == "":
  237. return "请选择 Markdown 文件", 400
  238. if not allowed_file(markdown_file.filename):
  239. return "不支持的文件类型,请上传 .md 文件", 400
  240. # 生成唯一 ID
  241. post_id = str(int(time.time() * 1000))
  242. # 创建目录
  243. upload_dir = config.UPLOAD_FOLDER / post_id
  244. upload_dir.mkdir(parents=True, exist_ok=True)
  245. posts_data_dir = config.POSTS_DATA_FOLDER / post_id
  246. posts_data_dir.mkdir(parents=True, exist_ok=True)
  247. # 保存 Markdown 文件
  248. md_path = posts_data_dir / "content.md"
  249. markdown_file.save(str(md_path))
  250. # 处理图片上传
  251. images = request.files.getlist("images")
  252. for img in images:
  253. if img and img.filename:
  254. filename = secure_filename(img.filename)
  255. if filename:
  256. file_path = upload_dir / filename
  257. img.save(str(file_path))
  258. # ---------- 对上传的图片进行缩放 ----------
  259. resize_image_if_large(str(file_path))
  260. # 读取 Markdown 内容
  261. with open(md_path, "r", encoding="utf-8") as f:
  262. md_content = f.read()
  263. # 修正图片路径
  264. fixed_md = fix_image_paths(md_content, post_id)
  265. # 渲染 HTML
  266. html_body = render_markdown(fixed_md)
  267. # 提取摘要
  268. summary = extract_summary(html_body)
  269. # 提取缩略图
  270. thumbnail = extract_thumbnail(html_body)
  271. # 生成日期(精确到分钟)
  272. date_iso = datetime.now(timezone.utc).replace(second=0, microsecond=0).isoformat()
  273. # 生成静态页面
  274. generate_static_page(post_id, title, html_body, date_iso, thumbnail)
  275. # 更新索引
  276. index = load_index()
  277. index.append(
  278. {
  279. "id": post_id,
  280. "title": title,
  281. "date": date_iso,
  282. "summary": summary,
  283. "thumbnail": thumbnail,
  284. }
  285. )
  286. # 按日期倒序排序
  287. index.sort(key=lambda p: p.get("date", ""), reverse=True)
  288. save_index(index)
  289. return redirect(url_for("index"))
  290. @app.route("/post/<post_id>")
  291. def view_post(post_id: str):
  292. """查看文章详情,支持登录后显示删除按钮"""
  293. if not re.match(r"^[a-zA-Z0-9_]+$", post_id):
  294. abort(404)
  295. posts = load_index()
  296. entry = next((p for p in posts if p["id"] == post_id), None)
  297. if not entry:
  298. abort(404)
  299. title = entry.get("title", "")
  300. date = entry.get("date", "")
  301. thumbnail = entry.get("thumbnail", "")
  302. content = ""
  303. md_path = config.POSTS_DATA_FOLDER / post_id / "content.md"
  304. if md_path.exists():
  305. try:
  306. with open(md_path, "r", encoding="utf-8") as f:
  307. md_content = f.read()
  308. fixed_md = fix_image_paths(md_content, post_id)
  309. html_body = render_markdown(fixed_md)
  310. content = html_body
  311. except Exception:
  312. content = ""
  313. if not content:
  314. generated_path = config.GENERATED_FOLDER / f"{post_id}.html"
  315. if generated_path.exists():
  316. try:
  317. with open(generated_path, "r", encoding="utf-8") as f:
  318. html_content = f.read()
  319. soup = BeautifulSoup(html_content, "html.parser")
  320. card_summary = soup.find("div", class_="card-summary")
  321. if card_summary:
  322. content = card_summary.decode_contents()
  323. except Exception:
  324. content = ""
  325. return render_template(
  326. "post_template.html",
  327. title=title,
  328. content=content,
  329. date=date,
  330. thumbnail=thumbnail,
  331. post_id=post_id,
  332. )
  333. @app.route("/admin")
  334. def admin():
  335. """管理页面"""
  336. posts = load_index()
  337. return render_template("admin.html", posts=posts)
  338. @app.route("/admin/delete/<post_id>", methods=["POST"])
  339. def delete_post(post_id: str):
  340. """删除文章(需要登录)"""
  341. if "user" not in session:
  342. flash("请先登录", "warning")
  343. return redirect(url_for("login"))
  344. # 安全验证
  345. if not re.match(r"^[a-zA-Z0-9_]+$", post_id):
  346. abort(404)
  347. # 删除生成的静态文件
  348. generated_file = config.GENERATED_FOLDER / f"{post_id}.html"
  349. if generated_file.exists():
  350. generated_file.unlink()
  351. # 删除 posts_data 目录
  352. posts_data_dir = config.POSTS_DATA_FOLDER / post_id
  353. if posts_data_dir.exists():
  354. shutil.rmtree(posts_data_dir)
  355. # 删除上传的图片目录
  356. upload_dir = config.UPLOAD_FOLDER / post_id
  357. if upload_dir.exists():
  358. shutil.rmtree(upload_dir)
  359. # 从索引中移除
  360. index = load_index()
  361. index = [p for p in index if p["id"] != post_id]
  362. save_index(index)
  363. flash("文章已删除", "success")
  364. return redirect(url_for("index"))
  365. @app.errorhandler(404)
  366. def not_found(e):
  367. return render_template("404.html"), 404
  368. if __name__ == "__main__":
  369. app.run(host="0.0.0.0", port=5000, debug=True)