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

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

View File

@@ -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-<bucket-name>.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

View File

@@ -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 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
```
### 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/

View File

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

View File

@@ -171,37 +171,94 @@ def browse(prefix_path):
@main_route.route("/file/<path:file_path>")
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
# 如果文件较大,避免通过 Serverless 函数传输,返回预签名 URL 的重定向
if size > limit:
# 尝试获取预签名 URL用于私有存储或需要时间限制的 URL
presigned = storage.generate_presigned_url(file_path)
if presigned:
return redirect(presigned)
# 如果没有预签名 URL则返回 413Payload Too Large
abort(413)
# 小文件:直接从存储获取并通过 Response 返回(流式)
# 如果没有预签名 URL尝试获取公共 URL
public_url = storage.get_public_url(file_path)
if public_url:
return redirect(public_url)
# 如果都没有可用的 URL返回错误
abort(403)
except Exception:
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-Length": str(file_obj.get("ContentLength", 0)),
"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(
file_obj["Body"].iter_chunks(), headers=headers, direct_passthrough=True
content,
headers=headers,
mimetype=file_obj.get("ContentType", "application/octet-stream"),
)
except Exception as e:
print(f"GitHub download error: {e}")
abort(404)
except Exception:
# 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)

View File

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

View File

@@ -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,12 +542,54 @@
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;
}
// 对于 /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);
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 || "";
@@ -546,6 +603,7 @@
const statusDiv = updateStatus(`✓ 开始下载: ${filename || ""}`, "success");
hideStatusLater(statusDiv);
}
}
async function deleteSelectedEntries() {
const selected = Array.from(getEntryCheckboxes()).filter((checkbox) => checkbox.checked);
@@ -885,8 +943,9 @@
initDialog();
initThemeAndView();
registerModalHandlers();
registerServiceWorker();
unregisterServiceWorker();
attachEntryCheckboxListeners();
attachDownloadButtonListeners();
});
window.uploadFiles = uploadFiles;

View File

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

View File

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

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
%}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 %}
<div class="container">
<h1>
Cloud Index
@@ -109,7 +109,8 @@
</button>
<button
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> 下载
</button>
@@ -148,7 +149,9 @@
/>
</div>
{% 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>
<div class="grid-actions">
<button
@@ -177,11 +180,17 @@
</button>
</div>
{% else %} {% if entry.name|fileicon == 'fas fa-image' %}
<div style="cursor: pointer" onclick="openPreview('{{ entry.file_url }}', '{{ entry.name }}')">
<img class="grid-thumb" src="{{ entry.file_url }}" alt="{{ entry.name }}" />
<div class="grid-thumb" onclick="openPreview('{{ entry.file_url }}', '{{ entry.name }}')">
<img
style="width: 100%; height: 100%; object-fit: cover; border-radius: 6px"
src="{{ entry.file_url }}"
alt="{{ entry.name }}"
/>
</div>
{% 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 %}
<a
class="grid-name"
@@ -202,7 +211,8 @@
</button>
<button
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="下载"
>
<i class="fas fa-download"></i>