From e71734e2d6bcbb4a9181010ce2f60256bdde8c83 Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Sat, 8 Nov 2025 20:54:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20GitHub=20=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E6=94=AF=E6=8C=81=EF=BC=8C=E6=9B=B4=E6=96=B0=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E6=96=87=E6=A1=A3=E5=92=8C=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 20 +- README.md | 34 ++- docs/api.md | 39 +++- handlers/routes.py | 101 +++++++-- static/css/main.css | 35 ++- static/js/main.js | 91 ++++++-- storages/__init__.py | 2 +- storages/factory.py | 7 +- storages/github.py | 500 +++++++++++++++++++++++++++++++++++++++++++ templates/index.html | 26 ++- 10 files changed, 798 insertions(+), 57 deletions(-) create mode 100644 storages/github.py diff --git a/.env.example b/.env.example index 4c27163..045387d 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,10 @@ # 存储类型选择 ## Done # r2 | Cloudflare R2 +# github | GitHub Repository ## TODO LIST: # cnbcool | Tencent cnb.cool # s3 | Amazon S3 -# github-repo | GitHub Repo STORAGE_TYPE=r2 # ==================== Cloudflare R2 配置 ==================== @@ -27,6 +27,24 @@ R2_PUBLIC_URL=https://pub-.r2.dev # R2 预签名 URL 过期时间(秒,默认: 3600) R2_PRESIGN_EXPIRES=3600 +# ==================== GitHub 存储配置 ==================== +# GitHub 仓库所有者 +GITHUB_REPO_OWNER=your-username + +# GitHub 仓库名称 +GITHUB_REPO_NAME=your-repo + +# GitHub 访问令牌 (需要 repo 权限) +GITHUB_ACCESS_TOKEN=your-access-token + +# GitHub 分支名称 (默认: main) +GITHUB_BRANCH=main + +# GitHub Raw 文件反向代理 URL (可选,用于加速访问) +# 例如: https://raw.fastgit.org 或 https://ghproxy.com/https://raw.githubusercontent.com +# 留空则使用官方 raw.githubusercontent.com +GITHUB_RAW_PROXY_URL= + # ==================== 应用配置 ==================== # 缩略图缓存时间(秒,默认: 3600) THUMB_TTL_SECONDS=3600 diff --git a/README.md b/README.md index 9b42d37..c53e08c 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ ## TODO -- [ ] Github Repo 储存支持 +- [x] Github Repo 储存支持 - [ ] Github Release 储存支持 - [ ] 基于数据库的用户/权限管理 - [ ] 操作日志记录 @@ -31,7 +31,8 @@ - **Cloudflare R2** - Cloudflare 的对象存储服务(S3 兼容) - **Amazon S3** - Amazon S3 对象存储服务 - +- **GitHub Repository** - 基于 GitHub Repository 的存储服务 + ## 快速开始 @@ -97,9 +98,36 @@ R2_PUBLIC_URL=https://pub-your-bucket.r2.dev R2_PRESIGN_EXPIRES=3600 ``` +### GitHub Repository 配置 + +```env +STORAGE_TYPE=github + +# GitHub 仓库所有者(用户名或组织) +GITHUB_REPO_OWNER=your-username + +# GitHub 仓库名称 +GITHUB_REPO_NAME=your-repo + +# GitHub 个人访问令牌(需要 repo 权限) +# 获取方式:https://github.com/settings/tokens +GITHUB_ACCESS_TOKEN=ghp_your_token_here + +# GitHub 分支名称(可选,默认: main) +GITHUB_BRANCH=main + +# GitHub Raw 文件反向代理 URL(可选,用于加速访问) +# 常用反向代理: +# - https://raw.fastgit.org (推荐,速度快) +# - https://ghproxy.com/https://raw.githubusercontent.com (需要拼接路径) +# - https://raw.kgithub.com +# 留空则使用官方 raw.githubusercontent.com(国内可能较慢) +GITHUB_RAW_PROXY_URL=https://raw.fastgit.org +``` + ## 项目结构 -``` +```bash r2-index/ ├── app.py # Flask 应用主入口 ├── handlers/ diff --git a/docs/api.md b/docs/api.md index ff6ba9d..89219df 100644 --- a/docs/api.md +++ b/docs/api.md @@ -604,13 +604,46 @@ async function uploadMultipleFiles(files) { - 总文件大小: 最多 10GB - API 请求频率: 根据您的 R2 套餐 - +- Repository 总大小: 建议 < 1GB +- 提交数量: 无限制 + +## 存储配置 + +### Cloudflare R2 配置 + +```env +STORAGE_TYPE=r2 +ACCESS_KEY_ID=your-access-key +SECRET_ACCESS_KEY=your-secret-key +R2_BUCKET_NAME=your-bucket +R2_ENDPOINT_URL=https://your-endpoint.r2.cloudflarestorage.com +R2_REGION=auto +R2_PUBLIC_URL=https://pub-your-bucket.r2.dev +R2_PRESIGN_EXPIRES=3600 +``` + +### GitHub Repository 配置 + +```env +STORAGE_TYPE=github +GITHUB_REPO_OWNER=your-username +GITHUB_REPO_NAME=your-repo +GITHUB_ACCESS_TOKEN=your-personal-access-token +GITHUB_BRANCH=main +``` + +**获取 GitHub Access Token:** + +1. 访问 [GitHub Settings - Personal Access Tokens](https://github.com/settings/tokens) +2. 点击 "Generate new token" (Classic) +3. 选择 "repo" 权限范围 +4. 生成并复制 Token ## 安全建议 @@ -624,3 +657,5 @@ async function uploadMultipleFiles(files) { 8. **访问控制**: 根据用户身份实施细粒度的访问控制 9. **加密**: 对敏感数据进行加密存储和传输 10. **备份**: 定期备份存储中的重要数据 +11. **Token 安全**: 不要将 Access Token 提交到版本控制系统,使用 `.env` 文件管理 +12. **定期轮换**: 定期轮换 Access Token 和密钥 diff --git a/handlers/routes.py b/handlers/routes.py index 1b6b6b6..334baa2 100644 --- a/handlers/routes.py +++ b/handlers/routes.py @@ -171,40 +171,97 @@ def browse(prefix_path): @main_route.route("/file/") def serve_file(file_path): - """通过服务器提供文件访问""" + """重定向到原始存储 URL,节省服务器资源""" try: - # 获取文件的基本信息,验证文件存在并检查大小 + # 验证文件存在 try: - info = storage.get_object_info(file_path) + storage.get_object_info(file_path) except Exception: abort(404) - size = int(info.get("ContentLength", 0) or 0) - limit = 6 * 1024 * 1024 # 6 MB + # 尝试获取预签名 URL(用于私有存储或需要时间限制的 URL) + presigned = storage.generate_presigned_url(file_path) + if presigned: + return redirect(presigned) - # 如果文件较大,避免通过 Serverless 函数传输,返回预签名 URL 的重定向 - if size > limit: - presigned = storage.generate_presigned_url(file_path) - if presigned: - return redirect(presigned) - # 如果没有预签名 URL,则返回 413(Payload Too Large) - abort(413) + # 如果没有预签名 URL,尝试获取公共 URL + public_url = storage.get_public_url(file_path) + if public_url: + return redirect(public_url) - # 小文件:直接从存储获取并通过 Response 返回(流式) - file_obj = storage.get_object(file_path) - headers = { - "Content-Type": file_obj.get("ContentType", "application/octet-stream"), - "Content-Length": str(file_obj.get("ContentLength", 0)), - } - - return Response( - file_obj["Body"].iter_chunks(), headers=headers, direct_passthrough=True - ) + # 如果都没有可用的 URL,返回错误 + abort(403) except Exception: abort(500) +@main_route.route("/download/") +def download_file(file_path): + """为 GitHub 存储提供下载支持,添加 Content-Disposition 头以强制下载""" + try: + # 验证文件存在 + try: + storage.get_object_info(file_path) + except Exception: + abort(404) + + # 获取存储类型 + storage_type = type(storage).__name__ + + # 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 + + # 获取完整内容用于返回 + 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) + + except Exception as e: + print(f"Download error: {e}") + abort(500) + + @main_route.route("/thumb/") def thumb(file_path): """返回图片的缩略图,使用 Vercel Cache Headers 避免重复从 R2 拉取""" diff --git a/static/css/main.css b/static/css/main.css index 2a75231..aaa80e8 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -9,6 +9,7 @@ --folder-color: #ffc107; --file-color: #6c757d; --shadow-color: rgba(0, 0, 0, 0.1); + --icon-bg: #f0f0f0; } [data-theme="dark"] { @@ -22,6 +23,7 @@ --folder-color: #ffd54f; --file-color: #b0b0b0; --shadow-color: rgba(0, 0, 0, 0.3); + --icon-bg: #3a3a3a; } body { @@ -149,7 +151,38 @@ h1 { object-fit: cover; border-radius: 6px; margin-bottom: 8px; - background-color: #f0f0f0; + background-color: var(--icon-bg); + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.grid-thumb:hover { + transform: scale(1.02); + box-shadow: 0 2px 8px var(--shadow-color); +} + +/* 网格卡片中的图标容器 */ +.grid-icon { + width: 100%; + height: 100px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 8px; + border-radius: 6px; + background-color: var(--icon-bg); + cursor: pointer; + transition: transform 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease; +} + +.grid-icon:hover { + transform: scale(1.02); + background-color: var(--hover-bg); + box-shadow: 0 2px 8px var(--shadow-color); +} + +.grid-icon i { + font-size: 64px; } .grid-name { diff --git a/static/js/main.js b/static/js/main.js index 7ca8381..6e4f276 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -295,20 +295,35 @@ } } - function registerServiceWorker() { + function unregisterServiceWorker() { if (!("serviceWorker" in navigator)) { return; } window.addEventListener("load", () => { navigator.serviceWorker - .register("/static/sw.js") - .then((registration) => { - console.log("SW registered:", registration); + .getRegistrations() + .then((registrations) => { + registrations.forEach((registration) => { + registration.unregister().then(() => { + console.log("Service Worker unregistered"); + }); + }); }) .catch((error) => { - console.log("SW registration failed:", error); + console.log("Error unregistering Service Worker:", error); }); + + // 清理 Service Worker 相关的缓存 + if ("caches" in window) { + caches.keys().then((cacheNames) => { + cacheNames.forEach((cacheName) => { + caches.delete(cacheName).then(() => { + console.log("Cache deleted:", cacheName); + }); + }); + }); + } }); } @@ -527,24 +542,67 @@ updateSelectAllState(); } + function attachDownloadButtonListeners() { + const downloadButtons = document.querySelectorAll("[data-download-key]"); + downloadButtons.forEach((button) => { + if (!button.dataset.listenerAttached) { + button.addEventListener("click", () => { + const key = button.dataset.downloadKey; + const name = button.dataset.downloadName; + downloadFile(`/download/${key}`, name); + }); + button.dataset.listenerAttached = "true"; + } + }); + } + function downloadFile(url, filename) { if (!url) { updateStatus("✗ 无法下载:缺少下载链接", "error"); return; } - const link = document.createElement("a"); - link.href = url; - link.download = filename || ""; - link.target = "_blank"; - link.rel = "noopener"; + // 对于 /download/ 路径,使用 fetch 以更好地处理大文件和错误 + if (url.startsWith("/download/")) { + fetch(url) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.blob(); + }) + .then((blob) => { + const link = document.createElement("a"); + const blobUrl = URL.createObjectURL(blob); + link.href = blobUrl; + link.download = filename || "file"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(blobUrl); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + const statusDiv = updateStatus(`✓ 开始下载: ${filename || ""}`, "success"); + hideStatusLater(statusDiv); + }) + .catch((error) => { + console.error("Download error:", error); + updateStatus(`✗ 下载失败: ${error.message}`, "error"); + }); + } else { + // 对于外部 URL,使用传统方法 + const link = document.createElement("a"); + link.href = url; + link.download = filename || ""; + link.target = "_blank"; + link.rel = "noopener"; - const statusDiv = updateStatus(`✓ 开始下载: ${filename || ""}`, "success"); - hideStatusLater(statusDiv); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + const statusDiv = updateStatus(`✓ 开始下载: ${filename || ""}`, "success"); + hideStatusLater(statusDiv); + } } async function deleteSelectedEntries() { @@ -885,8 +943,9 @@ initDialog(); initThemeAndView(); registerModalHandlers(); - registerServiceWorker(); + unregisterServiceWorker(); attachEntryCheckboxListeners(); + attachDownloadButtonListeners(); }); window.uploadFiles = uploadFiles; diff --git a/storages/__init__.py b/storages/__init__.py index 0906a7e..daf1893 100644 --- a/storages/__init__.py +++ b/storages/__init__.py @@ -1,6 +1,6 @@ from .base import BaseStorage -from .cnbcool import CnbCoolStorage from .factory import StorageFactory +from .github import GitHubStorage from .r2 import R2Storage __all__ = ["BaseStorage", "R2Storage", "CnbCoolStorage", "StorageFactory"] diff --git a/storages/factory.py b/storages/factory.py index b9ad2c5..cf96792 100644 --- a/storages/factory.py +++ b/storages/factory.py @@ -6,6 +6,7 @@ import dotenv from .base import BaseStorage # from .cnbcool import CnbCoolStorage +from .github import GitHubStorage from .r2 import R2Storage dotenv.load_dotenv() @@ -36,14 +37,14 @@ class StorageFactory: if storage_type == "r2": cls._instance = R2Storage() - # elif storage_type == "github": - # cls._instance = GithubStorage() + elif storage_type == "github": + cls._instance = GitHubStorage() # elif storage_type == "cnbcool": # cls._instance = CnbCoolStorage() else: raise RuntimeError( f"Unsupported storage type: {storage_type}. " - f"Supported types: r2, cnbcool" + f"Supported types: r2, github, cnbcool" ) return cls._instance diff --git a/storages/github.py b/storages/github.py new file mode 100644 index 0000000..1fa6a25 --- /dev/null +++ b/storages/github.py @@ -0,0 +1,500 @@ +import base64 +import os +from datetime import datetime +from io import BytesIO +from typing import Any, Dict + +import dotenv +import requests +from PIL import Image + +from .base import BaseStorage + +dotenv.load_dotenv() + + +class StreamWrapper: + """为 BytesIO 包装器,使其支持 iter_chunks() 方法以兼容 R2 的流式响应""" + + def __init__(self, data: bytes, chunk_size: int = 8192): + self.data = data + self.chunk_size = chunk_size + self.position = 0 + + def iter_chunks(self, chunk_size: int = None): + """迭代返回数据块""" + chunk_size = chunk_size or self.chunk_size + offset = 0 + while offset < len(self.data): + yield self.data[offset : offset + chunk_size] + offset += chunk_size + + def read(self, size: int = -1): + """为了兼容性支持 read() 方法""" + if size == -1: + return self.data + result = self.data[self.position : self.position + size] + self.position += len(result) + return result + + def seek(self, offset: int): + """为了兼容性支持 seek() 方法""" + self.position = offset + + def tell(self): + """为了兼容性支持 tell() 方法""" + return self.position + + +class GitHubStorage(BaseStorage): + """基于 GitHub 仓库的存储实现""" + + def __init__(self): + self.repo_owner = os.getenv("GITHUB_REPO_OWNER") + self.repo_name = os.getenv("GITHUB_REPO_NAME") + self.access_token = os.getenv("GITHUB_ACCESS_TOKEN") + self.branch = os.getenv("GITHUB_BRANCH", "main") + # 反向代理 URL,用于加速 GitHub raw 文件访问 + # 例如:https://raw. githubusercontent.com/ 的反向代理 URL + 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" + ) + + 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: + self.raw_content_url = f"{self.raw_proxy_url}/https://raw.githubusercontent.com/{self.repo_owner}/{self.repo_name}/{self.branch}" + else: + self.raw_content_url = f"https://raw.githubusercontent.com/{self.repo_owner}/{self.repo_name}/{self.branch}" + + def _headers(self) -> Dict[str, str]: + """返回 API 请求的公共头部信息""" + return { + "Authorization": f"token {self.access_token}", + "Accept": "application/vnd.github.v3+json", + } + + def _get_file_sha(self, file_path: str) -> str: + """获取文件的 SHA 值用于更新或删除""" + try: + url = f"{self.api_base_url}/contents/{file_path}" + response = requests.get(url, headers=self._headers()) + if response.status_code == 200: + return response.json().get("sha") + except Exception: + pass + return None + + def _get_last_commit_time(self, file_path: str) -> datetime: + """获取文件的最后提交时间,返回 datetime 对象""" + try: + url = f"{self.api_base_url}/commits" + params = {"path": file_path, "per_page": 1} + response = requests.get(url, headers=self._headers(), params=params) + if response.status_code == 200: + commits = response.json() + if commits and len(commits) > 0: + time_str = commits[0]["commit"]["author"]["date"] + # 解析 ISO 格式时间字符串为 datetime 对象 + # 格式: "2025-11-08T10:55:26Z" + return datetime.fromisoformat(time_str.replace("Z", "+00:00")) + except Exception: + pass + return datetime.now() + + def list_objects(self, prefix: str = "") -> Dict[str, Any]: + """ + 列出存储桶中的对象 + + Args: + prefix: 对象前缀(用于目录浏览) + + Returns: + 包含对象列表的字典 + """ + 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" + ) + response = requests.get(url, headers=self._headers()) + response.raise_for_status() + + contents = response.json() + if not isinstance(contents, list): + contents = [contents] + + files = [] + folders = [] + + for item in contents: + if item["type"] == "file": + # 跳过 .gitkeep 文件 + if item["name"] == ".gitkeep": + continue + + # 获取最后提交时间 + last_modified = self._get_last_commit_time(item["path"]) + + files.append( + { + "Key": item["path"], + "Size": item["size"], + "LastModified": last_modified, + "ETag": item["sha"], + } + ) + elif item["type"] == "dir": + folders.append({"Prefix": item["path"] + "/"}) + + return { + "Contents": files, + "CommonPrefixes": folders, + "IsTruncated": False, + } + except Exception as e: + return {"Contents": [], "CommonPrefixes": [], "Error": str(e)} + + def get_object_info(self, key: str) -> Dict[str, Any]: + """ + 获取对象基本信息 + + Args: + key: 对象键名 + + Returns: + 对象元数据 + """ + try: + url = f"{self.api_base_url}/contents/{key}" + response = requests.get(url, headers=self._headers()) + response.raise_for_status() + + data = response.json() + last_modified = self._get_last_commit_time(key) + + return { + "Key": data["path"], + "Size": data["size"], + "ContentLength": data["size"], # 为了兼容路由代码 + "LastModified": last_modified, + "ETag": data["sha"], + "ContentType": "application/octet-stream", + } + except Exception as e: + raise RuntimeError(f"Failed to get object info: {str(e)}") from e + + def get_object(self, key: str) -> Dict[str, Any]: + """ + 获取对象内容 + + Args: + key: 对象键名 + + Returns: + 包含对象内容的字典,Body 支持 iter_chunks() 方法 + """ + try: + url = f"{self.raw_content_url}/{key}" + response = requests.get(url) + response.raise_for_status() + + content = response.content + return { + "Body": StreamWrapper(content), + "ContentLength": len(content), + "ContentType": response.headers.get( + "Content-Type", "application/octet-stream" + ), + } + except Exception as e: + raise RuntimeError(f"Failed to get object: {str(e)}") from e + + def generate_presigned_url(self, key: str, expires: int = None) -> str: + """ + 为指定对象生成预签名 URL + + Args: + key: 对象键名 + expires: 过期时间(秒) + + Returns: + 预签名 URL,失败返回 None + """ + try: + # GitHub raw 内容 URL - 直接返回文件内容 + # 这会被前端用于下载 + return f"{self.raw_content_url}/{key}" + except Exception: + return None + + def get_public_url(self, key: str) -> str: + """ + 生成对象的公共访问 URL + + Args: + key: 对象键名 + + Returns: + 公共 URL,未配置返回 None + """ + return f"{self.raw_content_url}/{key}" + + def generate_thumbnail(self, file_path: str) -> bytes: + """ + 生成图片缩略图 + + Args: + file_path: 文件路径 + + Returns: + 缩略图字节数据 + """ + try: + obj = self.get_object(file_path) + # StreamWrapper 需要转换为 BytesIO 以兼容 PIL + body = obj["Body"] + if isinstance(body, StreamWrapper): + image_data = body.read() + image_bytes = BytesIO(image_data) + else: + image_bytes = body + + img = Image.open(image_bytes) + img.thumbnail((200, 200)) + + thumbnail_io = BytesIO() + img.save(thumbnail_io, format="JPEG") + thumbnail_io.seek(0) + return thumbnail_io.read() + except Exception as e: + raise RuntimeError(f"Failed to generate thumbnail: {str(e)}") from e + + def upload_file(self, key: str, file_data: bytes, content_type: str = None) -> bool: + """ + 上传文件到存储 + + Args: + key: 对象键名(文件路径) + file_data: 文件二进制数据 + content_type: 文件类型(MIME type) + + Returns: + 上传成功返回 True,失败返回 False + """ + try: + url = f"{self.api_base_url}/contents/{key}" + encoded_content = base64.b64encode(file_data).decode("utf-8") + + # 检查文件是否已存在 + sha = self._get_file_sha(key) + + data = { + "message": f"Upload {key}", + "content": encoded_content, + "branch": self.branch, + } + + if sha: + data["sha"] = sha + + response = requests.put(url, json=data, headers=self._headers()) + response.raise_for_status() + return True + except Exception as e: + print(f"Upload failed: {str(e)}") + return False + + def delete_file(self, key: str) -> bool: + """ + 删除存储中的文件 + + Args: + key: 对象键名(文件路径) + + Returns: + 删除成功返回 True,失败返回 False + """ + try: + sha = self._get_file_sha(key) + if not sha: + return False + + url = f"{self.api_base_url}/contents/{key}" + data = { + "message": f"Delete {key}", + "sha": sha, + "branch": self.branch, + } + + response = requests.delete(url, json=data, headers=self._headers()) + response.raise_for_status() + return True + except Exception as e: + print(f"Delete failed: {str(e)}") + return False + + def rename_file(self, old_key: str, new_key: str) -> bool: + """ + 重命名存储中的文件 + + Args: + old_key: 旧的对象键名 + new_key: 新的对象键名 + + Returns: + 重命名成功返回 True,失败返回 False + """ + try: + # 获取原文件内容 + obj = self.get_object(old_key) + content = obj["Body"].read() + + # 上传到新位置 + if not self.upload_file(new_key, content): + return False + + # 删除原文件 + return self.delete_file(old_key) + except Exception as e: + print(f"Rename failed: {str(e)}") + return False + + def delete_folder(self, prefix: str) -> bool: + """ + 删除存储中的文件夹(前缀) + + Args: + prefix: 要删除的文件夹前缀 + + Returns: + 删除成功返回 True,失败返回 False + """ + try: + # 确保前缀以 / 结尾 + if prefix and not prefix.endswith("/"): + prefix = prefix + "/" + + contents = self.list_objects(prefix) + files = contents.get("Contents", []) + + # 删除所有文件 + for file_info in files: + file_key = file_info["Key"] + if not self.delete_file(file_key): + return False + + # 递归删除子文件夹 + folders = contents.get("CommonPrefixes", []) + for folder in folders: + folder_prefix = folder["Prefix"] + # 确保递归时也传入正确格式的前缀(带末尾 /) + if not self.delete_folder(folder_prefix): + return False + + return True + except Exception as e: + print(f"Delete folder failed: {str(e)}") + return False + + def rename_folder(self, old_prefix: str, new_prefix: str) -> bool: + """ + 重命名存储中的文件夹(前缀) + + Args: + old_prefix: 旧的文件夹前缀 + new_prefix: 新的文件夹前缀 + + Returns: + 重命名成功返回 True,失败返回 False + """ + try: + contents = self.list_objects(old_prefix) + files = contents.get("Contents", []) + + for file_info in files: + old_key = file_info["Key"] + new_key = old_key.replace(old_prefix, new_prefix, 1) + + if not self.rename_file(old_key, new_key): + return False + + return True + except Exception as e: + print(f"Rename folder failed: {str(e)}") + return False + + def copy_file(self, source_key: str, dest_key: str) -> bool: + """ + 复制存储中的文件 + + Args: + source_key: 源对象键名 + dest_key: 目标对象键名 + + Returns: + 复制成功返回 True,失败返回 False + """ + try: + obj = self.get_object(source_key) + content = obj["Body"].read() + return self.upload_file(dest_key, content) + except Exception as e: + print(f"Copy failed: {str(e)}") + return False + + def copy_folder(self, source_prefix: str, dest_prefix: str) -> bool: + """ + 复制存储中的文件夹(前缀) + + Args: + source_prefix: 源文件夹前缀 + dest_prefix: 目标文件夹前缀 + + Returns: + 复制成功返回 True,失败返回 False + """ + try: + contents = self.list_objects(source_prefix) + files = contents.get("Contents", []) + + for file_info in files: + source_key = file_info["Key"] + dest_key = source_key.replace(source_prefix, dest_prefix, 1) + + if not self.copy_file(source_key, dest_key): + return False + + return True + except Exception as e: + print(f"Copy folder failed: {str(e)}") + return False + + def create_folder(self, key: str) -> bool: + """ + 创建文件夹 + + Args: + key: 文件夹路径(以 / 结尾) + + Returns: + 创建成功返回 True,失败返回 False + """ + try: + # GitHub 不需要显式创建文件夹 + # 如果需要标记文件夹存在,可以创建 .gitkeep 文件 + # 但为了不显示 .gitkeep,我们在这里直接返回 True + # 实际的文件夹会在上传文件时自动创建 + return True + except Exception as e: + print(f"Create folder failed: {str(e)}") + return False diff --git a/templates/index.html b/templates/index.html index 1442d08..07a5eda 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,5 +1,5 @@ -{% extends 'base.html' %} {% block title %}Cloud Index{% endblock %} {% block body_attrs -%}data-current-prefix="{{ current_prefix }}"{% endblock %} {% block content %} +{% extends 'base.html' %} {% block title %}Cloud Index{% endblock %} {% block body_attrs %}data-current-prefix="{{ +current_prefix }}"{% endblock %} {% block content %}

Cloud Index @@ -109,7 +109,8 @@ @@ -148,7 +149,9 @@ />

{% if entry.is_dir %} - +
+ +
{{ entry.name }}
{% else %} {% if entry.name|fileicon == 'fas fa-image' %} -
- {{ entry.name }} +
+ {{ entry.name }}
{% else %} - +
+ +
{% endif %}