#!/usr/bin/env python3 # -*- coding: utf-8 -*- from __future__ import annotations import os from datetime import datetime from typing import Iterator import database from app_config import get_config from app_logger import get_logger from app_security import is_safe_path from flask import Blueprint, jsonify, request, send_from_directory from flask_login import current_user, login_required from PIL import Image, ImageOps from services.client_log import log_to_client from services.time_utils import BEIJING_TZ config = get_config() SCREENSHOTS_DIR = config.SCREENSHOTS_DIR _IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg") _THUMBNAIL_DIR = os.path.join(SCREENSHOTS_DIR, ".thumbs") _THUMBNAIL_MAX_SIZE = (480, 270) _THUMBNAIL_QUALITY = 80 try: _RESAMPLE_FILTER = Image.Resampling.LANCZOS except AttributeError: # Pillow<9 fallback _RESAMPLE_FILTER = Image.LANCZOS api_screenshots_bp = Blueprint("api_screenshots", __name__) logger = get_logger("app") def _get_user_prefix(user_id: int) -> str: return f"u{int(user_id)}" def _get_username(user_id: int) -> str: user_info = database.get_user_by_id(user_id) return str(user_info.get("username") or "") if user_info else "" def _list_all_usernames() -> list[str]: users = database.get_all_users() result = [] for row in users: username = str(row.get("username") or "").strip() if username: result.append(username) return result def _resolve_user_owned_prefix( filename: str, *, user_id: int, username: str, all_usernames: list[str] | None = None, ) -> str | None: lower_name = filename.lower() if not lower_name.endswith(_IMAGE_EXTENSIONS): return None # 新版命名:u{user_id}_... id_prefix = _get_user_prefix(user_id) if filename.startswith(id_prefix + "_"): return id_prefix # 兼容旧版命名:{username}_... username = str(username or "").strip() if not username: return None if all_usernames is None: all_usernames = _list_all_usernames() matched_usernames = [item for item in all_usernames if filename.startswith(item + "_")] if not matched_usernames: return None # 取“最长匹配用户名”,避免 foo 越权读取 foo_bar 的截图。 max_len = max(len(item) for item in matched_usernames) winners = [item for item in matched_usernames if len(item) == max_len] if len(winners) != 1: return None if winners[0] != username: return None return winners[0] def _iter_user_screenshot_entries(user_id: int, username: str, all_usernames: list[str]) -> Iterator[tuple[os.DirEntry, str]]: if not os.path.exists(SCREENSHOTS_DIR): return with os.scandir(SCREENSHOTS_DIR) as entries: for entry in entries: if not entry.is_file(): continue matched_prefix = _resolve_user_owned_prefix( entry.name, user_id=user_id, username=username, all_usernames=all_usernames, ) if not matched_prefix: continue yield entry, matched_prefix def _build_display_name(filename: str, owner_prefix: str) -> str: prefix = f"{owner_prefix}_" if filename.startswith(prefix): return filename[len(prefix) :] return filename def _thumbnail_name(filename: str) -> str: stem, _ = os.path.splitext(filename) return f"{stem}.thumb.jpg" def _thumbnail_path(filename: str) -> str: return os.path.join(_THUMBNAIL_DIR, _thumbnail_name(filename)) def _ensure_thumbnail(source_path: str, thumb_path: str) -> bool: if not os.path.exists(source_path): return False source_mtime = os.path.getmtime(source_path) if os.path.exists(thumb_path) and os.path.getmtime(thumb_path) >= source_mtime: return True os.makedirs(_THUMBNAIL_DIR, exist_ok=True) with Image.open(source_path) as image: image = ImageOps.exif_transpose(image) if image.mode != "RGB": image = image.convert("RGB") image.thumbnail(_THUMBNAIL_MAX_SIZE, _RESAMPLE_FILTER) image.save( thumb_path, format="JPEG", quality=_THUMBNAIL_QUALITY, optimize=True, progressive=True, ) os.utime(thumb_path, (source_mtime, source_mtime)) return True def _remove_thumbnail(filename: str) -> None: thumb_path = _thumbnail_path(filename) if os.path.exists(thumb_path): os.remove(thumb_path) def _parse_optional_pagination(default_limit: int = 24, *, max_limit: int = 100) -> tuple[int | None, int | None, bool]: limit_raw = request.args.get("limit") offset_raw = request.args.get("offset") if (limit_raw is None) and (offset_raw is None): return None, None, False try: limit = int(limit_raw if limit_raw is not None else default_limit) except (ValueError, TypeError): limit = default_limit limit = max(1, min(limit, max_limit)) try: offset = int(offset_raw if offset_raw is not None else 0) except (ValueError, TypeError): offset = 0 offset = max(0, offset) return limit, offset, True @api_screenshots_bp.route("/api/screenshots", methods=["GET"]) @login_required def get_screenshots(): """获取当前用户的截图列表""" user_id = current_user.id username = _get_username(user_id) try: screenshots = [] all_usernames = _list_all_usernames() for entry, matched_prefix in _iter_user_screenshot_entries(user_id, username, all_usernames): filename = entry.name stat = entry.stat() created_time = datetime.fromtimestamp(stat.st_mtime, tz=BEIJING_TZ) screenshots.append( { "filename": filename, "display_name": _build_display_name(filename, matched_prefix), "size": stat.st_size, "created": created_time.strftime("%Y-%m-%d %H:%M:%S"), "_created_ts": stat.st_mtime, } ) screenshots.sort(key=lambda item: item.get("_created_ts", 0), reverse=True) for item in screenshots: item.pop("_created_ts", None) limit, offset, paged = _parse_optional_pagination(default_limit=24, max_limit=100) if paged: total = len(screenshots) items = screenshots[offset : offset + limit] return jsonify({"items": items, "total": total, "limit": limit, "offset": offset}) return jsonify(screenshots) except Exception as e: logger.warning(f"[screenshots] 获取截图列表失败(user_id={user_id}): {e}") return jsonify({"error": "获取截图列表失败"}), 500 @api_screenshots_bp.route("/screenshots/") @login_required def serve_screenshot(filename): """提供原图文件访问""" user_id = current_user.id username = _get_username(user_id) if not _resolve_user_owned_prefix(filename, user_id=user_id, username=username): return jsonify({"error": "无权访问"}), 403 if not is_safe_path(SCREENSHOTS_DIR, filename): return jsonify({"error": "非法路径"}), 403 return send_from_directory(SCREENSHOTS_DIR, filename) @api_screenshots_bp.route("/screenshots/thumb/") @login_required def serve_screenshot_thumbnail(filename): """提供缩略图访问(失败时自动回退原图)""" user_id = current_user.id username = _get_username(user_id) if not _resolve_user_owned_prefix(filename, user_id=user_id, username=username): return jsonify({"error": "无权访问"}), 403 if not is_safe_path(SCREENSHOTS_DIR, filename): return jsonify({"error": "非法路径"}), 403 source_path = os.path.join(SCREENSHOTS_DIR, filename) if not os.path.exists(source_path): return jsonify({"error": "文件不存在"}), 404 thumb_path = _thumbnail_path(filename) try: if _ensure_thumbnail(source_path, thumb_path) and os.path.exists(thumb_path): return send_from_directory(_THUMBNAIL_DIR, os.path.basename(thumb_path), max_age=86400, conditional=True) except Exception: pass return send_from_directory(SCREENSHOTS_DIR, filename, max_age=3600, conditional=True) @api_screenshots_bp.route("/api/screenshots/", methods=["DELETE"]) @login_required def delete_screenshot(filename): """删除指定截图""" user_id = current_user.id username = _get_username(user_id) if not _resolve_user_owned_prefix(filename, user_id=user_id, username=username): return jsonify({"error": "无权删除"}), 403 if not is_safe_path(SCREENSHOTS_DIR, filename): return jsonify({"error": "非法路径"}), 403 try: filepath = os.path.join(SCREENSHOTS_DIR, filename) if os.path.exists(filepath): os.remove(filepath) _remove_thumbnail(filename) log_to_client(f"删除截图: {filename}", user_id) return jsonify({"success": True}) return jsonify({"error": "文件不存在"}), 404 except Exception as e: logger.warning(f"[screenshots] 删除截图失败(user_id={user_id}, filename={filename}): {e}") return jsonify({"error": "删除截图失败"}), 500 @api_screenshots_bp.route("/api/screenshots/clear", methods=["POST"]) @login_required def clear_all_screenshots(): """清空当前用户的所有截图""" user_id = current_user.id username = _get_username(user_id) try: deleted_count = 0 all_usernames = _list_all_usernames() for entry, _ in _iter_user_screenshot_entries(user_id, username, all_usernames): os.remove(entry.path) _remove_thumbnail(entry.name) deleted_count += 1 log_to_client(f"清理了 {deleted_count} 个截图文件", user_id) return jsonify({"success": True, "deleted": deleted_count}) except Exception as e: logger.warning(f"[screenshots] 清空截图失败(user_id={user_id}): {e}") return jsonify({"error": "清空截图失败"}), 500