From e404764ea9ad4984bae18b8d33747078783b4ea2 Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Fri, 14 Nov 2025 22:58:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=93=8D=E5=BA=94=E7=94=9F=E6=88=90=E6=96=B9?= =?UTF-8?q?=E6=B3=95=EF=BC=8C=E6=94=AF=E6=8C=81=E4=B8=8D=E5=90=8C=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E5=90=8E=E7=AB=AF=E7=9A=84=E4=B8=8B=E8=BD=BD=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- handlers/routes.py | 93 +++++++++++----------------------------------- storages/base.py | 26 +++++++++++++ storages/github.py | 64 ++++++++++++++++++++++++------- 3 files changed, 97 insertions(+), 86 deletions(-) diff --git a/handlers/routes.py b/handlers/routes.py index 85be711..7542ea5 100644 --- a/handlers/routes.py +++ b/handlers/routes.py @@ -3,15 +3,7 @@ import os from datetime import datetime from typing import Any, Dict, List -from flask import ( - Blueprint, - Response, - abort, - jsonify, - redirect, - render_template, - request, -) +from flask import Blueprint, Response, abort, jsonify, redirect, render_template, request from storages.factory import StorageFactory @@ -77,18 +69,12 @@ def build_file_entry(obj: Dict[str, Any], prefix: str) -> Dict[str, Any] | None: return entry -def build_directory_entry( - prefix_value: str | None, current_prefix: str -) -> Dict[str, Any] | None: +def build_directory_entry(prefix_value: str | None, current_prefix: str) -> Dict[str, Any] | None: """根据前缀构建目录条目。""" if not prefix_value: return None - rel = ( - prefix_value[len(current_prefix) :].rstrip("/") - if current_prefix - else prefix_value.rstrip("/") - ) + rel = prefix_value[len(current_prefix) :].rstrip("/") if current_prefix else prefix_value.rstrip("/") return {"name": rel, "key": prefix_value, "is_dir": True} @@ -198,7 +184,7 @@ def serve_file(file_path): @main_route.route("/download/") def download_file(file_path): - """为 GitHub 存储提供下载支持,添加 Content-Disposition 头以强制下载""" + """下载文件,支持所有存储类型""" try: # 验证文件存在 try: @@ -206,56 +192,23 @@ def download_file(file_path): except Exception: abort(404) - # 获取存储类型 - storage_type = type(storage).__name__ + # 使用存储后端的统一接口生成下载响应 + download_response = storage.generate_download_response(file_path) - # GitHub 存储:通过服务器中继以添加 Content-Disposition 头 - if storage_type == "GitHubStorage": - try: - file_obj = storage.get_object(file_path) - file_name = file_path.split("/")[-1] if "/" in file_path else file_path + if not download_response: + abort(403) - # 获取完整内容用于返回 - body = file_obj.get("Body") - if hasattr(body, "read"): - content = body.read() - elif hasattr(body, "data"): - content = body.data - else: - content = body - - # 使用 RFC 5987 编码处理文件名中的特殊字符 - from urllib.parse import quote - - encoded_filename = quote(file_name.encode("utf-8"), safe="") - - headers = { - "Content-Type": file_obj.get( - "ContentType", "application/octet-stream" - ), - "Content-Disposition": f"attachment; filename=\"{file_name}\"; filename*=UTF-8''{encoded_filename}", - "Cache-Control": "public, max-age=86400", - } - - return Response( - content, - headers=headers, - mimetype=file_obj.get("ContentType", "application/octet-stream"), - ) - except Exception as e: - print(f"GitHub download error: {e}") - abort(404) - - # R2 和其他存储:直接重定向 - presigned = storage.generate_presigned_url(file_path) - if presigned: - return redirect(presigned) - - public_url = storage.get_public_url(file_path) - if public_url: - return redirect(public_url) - - abort(403) + # 根据响应类型处理 + if download_response["type"] == "redirect": + return redirect(download_response["url"]) + elif download_response["type"] == "content": + return Response( + download_response["content"], + headers=download_response["headers"], + mimetype=download_response["mimetype"], + ) + else: + abort(500) except Exception as e: print(f"Download error: {e}") @@ -459,9 +412,7 @@ def copy_item(): if not source or not destination: return ( - jsonify( - {"success": False, "error": "Source or destination not provided"} - ), + jsonify({"success": False, "error": "Source or destination not provided"}), 400, ) @@ -493,9 +444,7 @@ def move_item(): if not source or not destination: return ( - jsonify( - {"success": False, "error": "Source or destination not provided"} - ), + jsonify({"success": False, "error": "Source or destination not provided"}), 400, ) diff --git a/storages/base.py b/storages/base.py index f7b0b72..0d273e8 100644 --- a/storages/base.py +++ b/storages/base.py @@ -208,3 +208,29 @@ class BaseStorage(ABC): 创建成功返回 True,失败返回 False """ pass + + def generate_download_response(self, key: str) -> Dict[str, Any]: + """ + 生成文件下载响应 + + Args: + key: 对象键名(文件路径) + + Returns: + 包含下载信息的字典,包括: + - type: "redirect" 或 "content" + - url: 重定向URL(当type为redirect时) + - content: 文件内容(当type为content时) + - headers: HTTP响应头 + - mimetype: MIME类型 + """ + # 默认实现:返回重定向URL + presigned = self.generate_presigned_url(key) + if presigned: + return {"type": "redirect", "url": presigned} + + public_url = self.get_public_url(key) + if public_url: + return {"type": "redirect", "url": public_url} + + return None diff --git a/storages/github.py b/storages/github.py index 1fa6a25..fdc17d6 100644 --- a/storages/github.py +++ b/storages/github.py @@ -59,13 +59,9 @@ class GitHubStorage(BaseStorage): self.raw_proxy_url = os.getenv("GITHUB_RAW_PROXY_URL", "").rstrip("/") if not all([self.repo_owner, self.repo_name, self.access_token]): - raise RuntimeError( - "GITHUB_REPO_OWNER, GITHUB_REPO_NAME, GITHUB_ACCESS_TOKEN must be set" - ) + raise RuntimeError("GITHUB_REPO_OWNER, GITHUB_REPO_NAME, GITHUB_ACCESS_TOKEN must be set") - self.api_base_url = ( - f"https://api.github.com/repos/{self.repo_owner}/{self.repo_name}" - ) + self.api_base_url = f"https://api.github.com/repos/{self.repo_owner}/{self.repo_name}" # 如果配置了代理 URL,则使用代理 URL;否则使用官方 raw.githubusercontent.com if self.raw_proxy_url: @@ -121,11 +117,7 @@ class GitHubStorage(BaseStorage): try: # 移除末尾的 / 以保持 GitHub API 的一致性 prefix = prefix.rstrip("/") if prefix else "" - url = ( - f"{self.api_base_url}/contents/{prefix}" - if prefix - else f"{self.api_base_url}/contents" - ) + url = f"{self.api_base_url}/contents/{prefix}" if prefix else f"{self.api_base_url}/contents" response = requests.get(url, headers=self._headers()) response.raise_for_status() @@ -212,9 +204,7 @@ class GitHubStorage(BaseStorage): return { "Body": StreamWrapper(content), "ContentLength": len(content), - "ContentType": response.headers.get( - "Content-Type", "application/octet-stream" - ), + "ContentType": response.headers.get("Content-Type", "application/octet-stream"), } except Exception as e: raise RuntimeError(f"Failed to get object: {str(e)}") from e @@ -498,3 +488,49 @@ class GitHubStorage(BaseStorage): except Exception as e: print(f"Create folder failed: {str(e)}") return False + + def generate_download_response(self, key: str) -> Dict[str, Any]: + """ + 生成文件下载响应(GitHub 特有实现) + + GitHub 存储需要通过服务器中继以添加 Content-Disposition 头 + + Args: + key: 对象键名(文件路径) + + Returns: + 包含下载信息的字典 + """ + try: + file_obj = self.get_object(key) + file_name = key.split("/")[-1] if "/" in key else key + + # 获取完整内容 + body = file_obj.get("Body") + if hasattr(body, "read"): + content = body.read() + elif hasattr(body, "data"): + content = body.data + else: + content = body + + # 使用 RFC 5987 编码处理文件名中的特殊字符 + from urllib.parse import quote + + encoded_filename = quote(file_name.encode("utf-8"), safe="") + + headers = { + "Content-Type": file_obj.get("ContentType", "application/octet-stream"), + "Content-Disposition": f"attachment; filename=\"{file_name}\"; filename*=UTF-8''{encoded_filename}", + "Cache-Control": "public, max-age=86400", + } + + return { + "type": "content", + "content": content, + "headers": headers, + "mimetype": file_obj.get("ContentType", "application/octet-stream"), + } + except Exception as e: + print(f"GitHub download response generation failed: {str(e)}") + return None