mirror of
https://github.com/RhenCloud/Cloud-Index.git
synced 2025-12-06 07:06:41 +08:00
feat: 添加 GitHub 存储支持,更新相关文档和配置
This commit is contained in:
20
.env.example
20
.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-<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
|
||||
|
||||
34
README.md
34
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 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/
|
||||
|
||||
39
docs/api.md
39
docs/api.md
@@ -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 和密钥
|
||||
|
||||
@@ -171,40 +171,97 @@ 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
|
||||
# 尝试获取预签名 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/<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>")
|
||||
def thumb(file_path):
|
||||
"""返回图片的缩略图,使用 Vercel Cache Headers 避免重复从 R2 拉取"""
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
500
storages/github.py
Normal 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
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user