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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user