feat: support announcement image upload

# Conflicts:
#	database.py
#	db/migrations.py
#	routes/admin_api/core.py
#	static/admin/.vite/manifest.json
#	static/admin/assets/AnnouncementsPage-Btl9JP7M.js
#	static/admin/assets/EmailPage-CwqlBGU2.js
#	static/admin/assets/FeedbacksPage-B_qDNL3q.js
#	static/admin/assets/LogsPage-DzdymdrQ.js
#	static/admin/assets/ReportPage-Bp26gOA-.js
#	static/admin/assets/SettingsPage-__r25pN8.js
#	static/admin/assets/SystemPage-C1OfxrU-.js
#	static/admin/assets/UsersPage-DhnABKcY.js
#	static/admin/assets/email-By53DCWv.js
#	static/admin/assets/email-ByiJ74rd.js
#	static/admin/assets/email-DkWacopQ.js
#	static/admin/assets/index-D5wU2pVd.js
#	static/admin/assets/tasks-1acmkoIX.js
#	static/admin/assets/update-DdQLVpC3.js
#	static/admin/assets/users-B1w166uc.js
#	static/admin/assets/users-CPJP5r-B.js
#	static/admin/assets/users-CnIyvFWm.js
#	static/admin/index.html
#	static/app/.vite/manifest.json
#	static/app/assets/AccountsPage-C48gJL8c.js
#	static/app/assets/AccountsPage-D387XNsv.js
#	static/app/assets/AccountsPage-DBJCAsJz.js
#	static/app/assets/LoginPage-BgK_Vl6X.js
#	static/app/assets/RegisterPage-CwADxWfe.js
#	static/app/assets/ResetPasswordPage-CVfZX_5z.js
#	static/app/assets/SchedulesPage-CWuZpJ5h.js
#	static/app/assets/SchedulesPage-Dw-mXbG5.js
#	static/app/assets/SchedulesPage-DwzGOBuc.js
#	static/app/assets/ScreenshotsPage-C6vX2U3V.js
#	static/app/assets/ScreenshotsPage-CreOSjVc.js
#	static/app/assets/ScreenshotsPage-DuTeRzLR.js
#	static/app/assets/VerifyResultPage-BzGlCgtE.js
#	static/app/assets/VerifyResultPage-CN_nr4V6.js
#	static/app/assets/VerifyResultPage-CNbQc83z.js
#	static/app/assets/accounts-BFaVMUve.js
#	static/app/assets/accounts-BYq3lLev.js
#	static/app/assets/accounts-Bc9j2moH.js
#	static/app/assets/auth-Dk_ApO4B.js
#	static/app/assets/index-BIng7uZJ.css
#	static/app/assets/index-CDxVo_1Z.js
#	static/app/index.html
This commit is contained in:
2026-01-06 12:15:16 +08:00
parent 82acc3470f
commit 4c492122dd
48 changed files with 450 additions and 121 deletions

View File

@@ -3,6 +3,8 @@
from __future__ import annotations
import os
import posixpath
import secrets
import threading
import time
from datetime import datetime
@@ -15,7 +17,9 @@ from app_logger import get_logger
from app_security import (
get_rate_limit_ip,
is_safe_outbound_url,
is_safe_path,
require_ip_not_locked,
sanitize_filename,
validate_email,
validate_password,
)
@@ -91,6 +95,24 @@ def _require_admin_reauth():
return jsonify({"error": "需要二次确认", "code": "reauth_required"}), 401
return None
def _get_upload_dir():
rel_dir = getattr(config, "ANNOUNCEMENT_IMAGE_DIR", "static/announcements")
if not is_safe_path(current_app.root_path, rel_dir):
rel_dir = "static/announcements"
abs_dir = os.path.join(current_app.root_path, rel_dir)
os.makedirs(abs_dir, exist_ok=True)
return abs_dir, rel_dir
def _get_file_size(file_storage):
try:
file_storage.stream.seek(0, os.SEEK_END)
size = file_storage.stream.tell()
file_storage.stream.seek(0)
return size
except Exception:
return None
@admin_api_bp.route("/debug-config", methods=["GET"])
@admin_required
@@ -229,6 +251,42 @@ def admin_reauth():
# ==================== 公告管理API管理员 ====================
@admin_api_bp.route("/announcements/upload_image", methods=["POST"])
@admin_required
def admin_upload_announcement_image():
"""上传公告图片返回可访问URL"""
file = request.files.get("file")
if not file or not file.filename:
return jsonify({"error": "请选择图片"}), 400
filename = sanitize_filename(file.filename)
ext = os.path.splitext(filename)[1].lower()
allowed_exts = getattr(config, "ALLOWED_ANNOUNCEMENT_IMAGE_EXTENSIONS", {".png", ".jpg", ".jpeg"})
if not ext or ext not in allowed_exts:
return jsonify({"error": "不支持的图片格式"}), 400
if file.mimetype and not str(file.mimetype).startswith("image/"):
return jsonify({"error": "文件类型无效"}), 400
size = _get_file_size(file)
max_size = int(getattr(config, "MAX_ANNOUNCEMENT_IMAGE_SIZE", 5 * 1024 * 1024))
if size is not None and size > max_size:
max_mb = max_size // 1024 // 1024
return jsonify({"error": f"图片大小不能超过{max_mb}MB"}), 400
abs_dir, rel_dir = _get_upload_dir()
token = secrets.token_hex(6)
name = f"announcement_{int(time.time())}_{token}{ext}"
save_path = os.path.join(abs_dir, name)
file.save(save_path)
static_root = os.path.join(current_app.root_path, "static")
rel_to_static = os.path.relpath(abs_dir, static_root)
if rel_to_static.startswith(".."):
rel_to_static = "announcements"
url_path = posixpath.join(rel_to_static.replace(os.sep, "/"), name)
return jsonify({"success": True, "url": url_for("serve_static", filename=url_path)})
@admin_api_bp.route("/announcements", methods=["GET"])
@admin_required
def admin_get_announcements():
@@ -251,9 +309,13 @@ def admin_create_announcement():
data = request.json or {}
title = (data.get("title") or "").strip()
content = (data.get("content") or "").strip()
image_url = (data.get("image_url") or "").strip()
is_active = bool(data.get("is_active", True))
announcement_id = database.create_announcement(title, content, is_active=is_active)
if image_url and len(image_url) > 1000:
return jsonify({"error": "图片地址过长"}), 400
announcement_id = database.create_announcement(title, content, image_url=image_url, is_active=is_active)
if not announcement_id:
return jsonify({"error": "标题和内容不能为空"}), 400

View File

@@ -35,6 +35,7 @@ def get_active_announcement():
"id": announcement.get("id"),
"title": announcement.get("title", ""),
"content": announcement.get("content", ""),
"image_url": announcement.get("image_url") or "",
"created_at": announcement.get("created_at"),
}
}