feat: 添加 GitHub 存储支持,更新相关文档和配置

This commit is contained in:
2025-11-08 20:54:55 +08:00
parent 7c8bfae742
commit 7bbfee09e8
10 changed files with 798 additions and 57 deletions

View File

@@ -1,10 +1,10 @@
# 存储类型选择 # 存储类型选择
## Done ## Done
# r2 | Cloudflare R2 # r2 | Cloudflare R2
# github | GitHub Repository
## TODO LIST: ## TODO LIST:
# cnbcool | Tencent cnb.cool # cnbcool | Tencent cnb.cool
# s3 | Amazon S3 # s3 | Amazon S3
# github-repo | GitHub Repo
STORAGE_TYPE=r2 STORAGE_TYPE=r2
# ==================== Cloudflare R2 配置 ==================== # ==================== Cloudflare R2 配置 ====================
@@ -27,6 +27,24 @@ R2_PUBLIC_URL=https://pub-<bucket-name>.r2.dev
# R2 预签名 URL 过期时间(秒,默认: 3600 # R2 预签名 URL 过期时间(秒,默认: 3600
R2_PRESIGN_EXPIRES=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 # 缩略图缓存时间(秒,默认: 3600
THUMB_TTL_SECONDS=3600 THUMB_TTL_SECONDS=3600

View File

@@ -19,7 +19,7 @@
## TODO ## TODO
- [ ] Github Repo 储存支持 - [x] Github Repo 储存支持
- [ ] Github Release 储存支持 - [ ] Github Release 储存支持
- [ ] 基于数据库的用户/权限管理 - [ ] 基于数据库的用户/权限管理
- [ ] 操作日志记录 - [ ] 操作日志记录
@@ -31,7 +31,8 @@
- **Cloudflare R2** - Cloudflare 的对象存储服务S3 兼容) - **Cloudflare R2** - Cloudflare 的对象存储服务S3 兼容)
- **Amazon S3** - Amazon S3 对象存储服务 - **Amazon S3** - Amazon S3 对象存储服务
<!-- - **Github Repo** - 基于 GitHub Repository 的存储服务 --> - **GitHub Repository** - 基于 GitHub Repository 的存储服务
<!-- - **Github Release** - 基于 GitHub Release 的存储服务 -->
## 快速开始 ## 快速开始
@@ -97,9 +98,36 @@ R2_PUBLIC_URL=https://pub-your-bucket.r2.dev
R2_PRESIGN_EXPIRES=3600 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/ r2-index/
├── app.py # Flask 应用主入口 ├── app.py # Flask 应用主入口
├── handlers/ ├── handlers/

View File

@@ -604,13 +604,46 @@ async function uploadMultipleFiles(files) {
- 总文件大小: 最多 10GB - 总文件大小: 最多 10GB
- API 请求频率: 根据您的 R2 套餐 - API 请求频率: 根据您的 R2 套餐
<!-- ### Github ### GitHub Repository
- 单文件大小: 最大 100MB建议 < 50MB - 单文件大小: 最大 100MB建议 < 50MB
- API 请求频率: - API 请求频率:
- 有 Token: 5000 请求/小时 - 有 Token: 5000 请求/小时
- 无 Token: 60 请求/小时 - 无 Token: 60 请求/小时
- Repository 总大小: 建议 < 1GB --> - 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. **访问控制**: 根据用户身份实施细粒度的访问控制 8. **访问控制**: 根据用户身份实施细粒度的访问控制
9. **加密**: 对敏感数据进行加密存储和传输 9. **加密**: 对敏感数据进行加密存储和传输
10. **备份**: 定期备份存储中的重要数据 10. **备份**: 定期备份存储中的重要数据
11. **Token 安全**: 不要将 Access Token 提交到版本控制系统,使用 `.env` 文件管理
12. **定期轮换**: 定期轮换 Access Token 和密钥

View File

@@ -171,40 +171,97 @@ def browse(prefix_path):
@main_route.route("/file/<path:file_path>") @main_route.route("/file/<path:file_path>")
def serve_file(file_path): def serve_file(file_path):
"""通过服务器提供文件访问""" """重定向到原始存储 URL节省服务器资源"""
try: try:
# 获取文件的基本信息,验证文件存在并检查大小 # 验证文件存在
try: try:
info = storage.get_object_info(file_path) storage.get_object_info(file_path)
except Exception: except Exception:
abort(404) abort(404)
size = int(info.get("ContentLength", 0) or 0) # 尝试获取预签名 URL用于私有存储或需要时间限制的 URL
limit = 6 * 1024 * 1024 # 6 MB presigned = storage.generate_presigned_url(file_path)
if presigned:
return redirect(presigned)
# 如果文件较大,避免通过 Serverless 函数传输,返回预签名 URL 的重定向 # 如果没有预签名 URL尝试获取公共 URL
if size > limit: public_url = storage.get_public_url(file_path)
presigned = storage.generate_presigned_url(file_path) if public_url:
if presigned: return redirect(public_url)
return redirect(presigned)
# 如果没有预签名 URL则返回 413Payload Too Large
abort(413)
# 小文件:直接从存储获取并通过 Response 返回(流式) # 如果都没有可用的 URL返回错误
file_obj = storage.get_object(file_path) abort(403)
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
)
except Exception: except Exception:
abort(500) abort(500)
@main_route.route("/download/<path:file_path>")
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/<path:file_path>") @main_route.route("/thumb/<path:file_path>")
def thumb(file_path): def thumb(file_path):
"""返回图片的缩略图,使用 Vercel Cache Headers 避免重复从 R2 拉取""" """返回图片的缩略图,使用 Vercel Cache Headers 避免重复从 R2 拉取"""

View File

@@ -9,6 +9,7 @@
--folder-color: #ffc107; --folder-color: #ffc107;
--file-color: #6c757d; --file-color: #6c757d;
--shadow-color: rgba(0, 0, 0, 0.1); --shadow-color: rgba(0, 0, 0, 0.1);
--icon-bg: #f0f0f0;
} }
[data-theme="dark"] { [data-theme="dark"] {
@@ -22,6 +23,7 @@
--folder-color: #ffd54f; --folder-color: #ffd54f;
--file-color: #b0b0b0; --file-color: #b0b0b0;
--shadow-color: rgba(0, 0, 0, 0.3); --shadow-color: rgba(0, 0, 0, 0.3);
--icon-bg: #3a3a3a;
} }
body { body {
@@ -149,7 +151,38 @@ h1 {
object-fit: cover; object-fit: cover;
border-radius: 6px; border-radius: 6px;
margin-bottom: 8px; 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 { .grid-name {

View File

@@ -295,20 +295,35 @@
} }
} }
function registerServiceWorker() { function unregisterServiceWorker() {
if (!("serviceWorker" in navigator)) { if (!("serviceWorker" in navigator)) {
return; return;
} }
window.addEventListener("load", () => { window.addEventListener("load", () => {
navigator.serviceWorker navigator.serviceWorker
.register("/static/sw.js") .getRegistrations()
.then((registration) => { .then((registrations) => {
console.log("SW registered:", registration); registrations.forEach((registration) => {
registration.unregister().then(() => {
console.log("Service Worker unregistered");
});
});
}) })
.catch((error) => { .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(); 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) { function downloadFile(url, filename) {
if (!url) { if (!url) {
updateStatus("✗ 无法下载:缺少下载链接", "error"); updateStatus("✗ 无法下载:缺少下载链接", "error");
return; return;
} }
const link = document.createElement("a"); // 对于 /download/ 路径,使用 fetch 以更好地处理大文件和错误
link.href = url; if (url.startsWith("/download/")) {
link.download = filename || ""; fetch(url)
link.target = "_blank"; .then((response) => {
link.rel = "noopener"; 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); const statusDiv = updateStatus(`✓ 开始下载: ${filename || ""}`, "success");
link.click(); hideStatusLater(statusDiv);
document.body.removeChild(link); })
.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"); document.body.appendChild(link);
hideStatusLater(statusDiv); link.click();
document.body.removeChild(link);
const statusDiv = updateStatus(`✓ 开始下载: ${filename || ""}`, "success");
hideStatusLater(statusDiv);
}
} }
async function deleteSelectedEntries() { async function deleteSelectedEntries() {
@@ -885,8 +943,9 @@
initDialog(); initDialog();
initThemeAndView(); initThemeAndView();
registerModalHandlers(); registerModalHandlers();
registerServiceWorker(); unregisterServiceWorker();
attachEntryCheckboxListeners(); attachEntryCheckboxListeners();
attachDownloadButtonListeners();
}); });
window.uploadFiles = uploadFiles; window.uploadFiles = uploadFiles;

View File

@@ -1,6 +1,6 @@
from .base import BaseStorage from .base import BaseStorage
from .cnbcool import CnbCoolStorage
from .factory import StorageFactory from .factory import StorageFactory
from .github import GitHubStorage
from .r2 import R2Storage from .r2 import R2Storage
__all__ = ["BaseStorage", "R2Storage", "CnbCoolStorage", "StorageFactory"] __all__ = ["BaseStorage", "R2Storage", "CnbCoolStorage", "StorageFactory"]

View File

@@ -6,6 +6,7 @@ import dotenv
from .base import BaseStorage from .base import BaseStorage
# from .cnbcool import CnbCoolStorage # from .cnbcool import CnbCoolStorage
from .github import GitHubStorage
from .r2 import R2Storage from .r2 import R2Storage
dotenv.load_dotenv() dotenv.load_dotenv()
@@ -36,14 +37,14 @@ class StorageFactory:
if storage_type == "r2": if storage_type == "r2":
cls._instance = R2Storage() cls._instance = R2Storage()
# elif storage_type == "github": elif storage_type == "github":
# cls._instance = GithubStorage() cls._instance = GitHubStorage()
# elif storage_type == "cnbcool": # elif storage_type == "cnbcool":
# cls._instance = CnbCoolStorage() # cls._instance = CnbCoolStorage()
else: else:
raise RuntimeError( raise RuntimeError(
f"Unsupported storage type: {storage_type}. " f"Unsupported storage type: {storage_type}. "
f"Supported types: r2, cnbcool" f"Supported types: r2, github, cnbcool"
) )
return cls._instance return cls._instance

500
storages/github.py Normal file
View File

@@ -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

View File

@@ -1,5 +1,5 @@
{% extends 'base.html' %} {% block title %}Cloud Index{% endblock %} {% block body_attrs {% extends 'base.html' %} {% block title %}Cloud Index{% endblock %} {% block body_attrs %}data-current-prefix="{{
%}data-current-prefix="{{ current_prefix }}"{% endblock %} {% block content %} current_prefix }}"{% endblock %} {% block content %}
<div class="container"> <div class="container">
<h1> <h1>
Cloud Index Cloud Index
@@ -109,7 +109,8 @@
</button> </button>
<button <button
class="action-link download-btn" class="action-link download-btn"
onclick="downloadFile({{ (entry.public_url or entry.file_url)|tojson|safe }}, {{ entry.name|tojson|safe }})" data-download-key="{{ entry.key }}"
data-download-name="{{ entry.name }}"
> >
<i class="fas fa-download"></i> 下载 <i class="fas fa-download"></i> 下载
</button> </button>
@@ -148,7 +149,9 @@
/> />
</div> </div>
{% if entry.is_dir %} {% if entry.is_dir %}
<i class="fas fa-folder fa-2x" style="color: var(--folder-color)"></i> <div class="grid-icon" onclick="window.location.href='/{{ entry.key.rstrip('/') }}'">
<i class="fas fa-folder" style="color: var(--folder-color)"></i>
</div>
<a class="grid-name" href="/{{ entry.key.rstrip('/') }}">{{ entry.name }}</a> <a class="grid-name" href="/{{ entry.key.rstrip('/') }}">{{ entry.name }}</a>
<div class="grid-actions"> <div class="grid-actions">
<button <button
@@ -177,11 +180,17 @@
</button> </button>
</div> </div>
{% else %} {% if entry.name|fileicon == 'fas fa-image' %} {% else %} {% if entry.name|fileicon == 'fas fa-image' %}
<div style="cursor: pointer" onclick="openPreview('{{ entry.file_url }}', '{{ entry.name }}')"> <div class="grid-thumb" onclick="openPreview('{{ entry.file_url }}', '{{ entry.name }}')">
<img class="grid-thumb" src="{{ entry.file_url }}" alt="{{ entry.name }}" /> <img
style="width: 100%; height: 100%; object-fit: cover; border-radius: 6px"
src="{{ entry.file_url }}"
alt="{{ entry.name }}"
/>
</div> </div>
{% else %} {% else %}
<i class="{{ entry.name|fileicon }} fa-2x" style="color: var(--file-color)"></i> <div class="grid-icon" onclick="openPreview('{{ entry.file_url }}', '{{ entry.name }}')">
<i class="{{ entry.name|fileicon }}" style="color: var(--file-color)"></i>
</div>
{% endif %} {% endif %}
<a <a
class="grid-name" class="grid-name"
@@ -202,7 +211,8 @@
</button> </button>
<button <button
class="grid-action-btn download" class="grid-action-btn download"
onclick="downloadFile({{ (entry.public_url or entry.file_url)|tojson|safe }}, {{ entry.name|tojson|safe }})" data-download-key="{{ entry.key }}"
data-download-name="{{ entry.name }}"
title="下载" title="下载"
> >
<i class="fas fa-download"></i> <i class="fas fa-download"></i>