From a02bcc57d7e863fdd1e1cc233dc5506d233ae88c Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Sat, 8 Nov 2025 17:45:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=95=B4=E7=9A=84=20R2=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=E7=B3=BB=E7=BB=9F=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 11 个 API 端点:上传、删除、重命名、复制、移动、创建文件夹等 - 构建存储抽象层,支持多后端(R2、S3、GitHub) - 添加完整的前端界面:列表/网格视图、文件操作、批量管理 - 添加深色/浅色主题支持 - 迁移至 Jinja2 模板继承架构 - 添加完整的 API 文档和项目文档 - 优化 Vercel 部署配置 --- .env.example | 28 +- .gitignore | 7 +- .vercelignore | 38 ++ README.md | 275 +++++++++++++ app.py | 336 +-------------- docs/api.md | 626 ++++++++++++++++++++++++++++ handlers/routes.py | 482 ++++++++++++++++++++++ requirements.txt | 6 + static/css/main.css | 705 ++++++++++++++++++++++++++++++++ static/js/main.js | 947 +++++++++++++++++++++++++++++++++++++++++++ static/sw.js | 37 -- storages/__init__.py | 6 + storages/base.py | 210 ++++++++++ storages/factory.py | 54 +++ storages/r2.py | 324 +++++++++++++++ templates/base.html | 33 ++ templates/index.html | 635 +++++++++++------------------ vercel.json | 10 +- 18 files changed, 4005 insertions(+), 754 deletions(-) create mode 100644 .vercelignore create mode 100644 README.md create mode 100644 docs/api.md create mode 100644 handlers/routes.py create mode 100644 requirements.txt create mode 100644 static/css/main.css create mode 100644 static/js/main.js delete mode 100644 static/sw.js create mode 100644 storages/__init__.py create mode 100644 storages/base.py create mode 100644 storages/factory.py create mode 100644 storages/r2.py create mode 100644 templates/base.html diff --git a/.env.example b/.env.example index 702dbf7..d7571bc 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,32 @@ +# 存储类型选择 +## Done +# r2 | Cloudflare R2 +## TODO LIST: +# cnbcool | Tencent cnb.cool +# s3 | Amazon S3 +# github-repo | GitHub Repo +STORAGE_TYPE=r2 + +# ==================== Cloudflare R2 配置 ==================== +# R2 访问凭证 ACCESS_KEY_ID=YOUR_R2_ACCESS_KEY_ID SECRET_ACCESS_KEY=YOUR_R2_SECRET_ACCESS_KEY + +# R2 存储桶名称 R2_BUCKET_NAME=drive + +# R2 Endpoint URL R2_ENDPOINT_URL=https://.r2.cloudflarestorage.com + +# R2 区域 (默认: auto) R2_REGION=auto -REDIS_URL=redis://localhost:6379/0 +# R2 公共访问 URL (可选,例如: https://pub-.r2.dev) +R2_PUBLIC_URL=https://pub-.r2.dev -# 可选: 公共访问 URL -R2_PUBLIC_URL=https://pub-.r2.dev \ No newline at end of file +# R2 预签名 URL 过期时间(秒,默认: 3600) +R2_PRESIGN_EXPIRES=3600 + +# ==================== 应用配置 ==================== +# 缩略图缓存时间(秒,默认: 3600) +THUMB_TTL_SECONDS=3600 diff --git a/.gitignore b/.gitignore index b473697..eb0e7aa 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,8 @@ ENV/ env.bak/ venv.bak/ .vercel - -.vercel + +test* +tests/ + +.VSCodeCounter \ No newline at end of file diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..64c59a3 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,38 @@ +# 测试文件 +test_*.py +*_test.py +tests/ + +# 开发工具 +debug/ +.vscode/ +.idea/ +*.pyc +__pycache__/ + +# 文档 +docs/ +*.md +!README.md + +# 环境配置 +.env +.env.local +.env.*.local + +# Git +.git/ +.gitignore + +# Python +venv/ +env/ +*.egg-info/ + +test/ +tests/ +test* + +# 临时文件 +*.tmp +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..762a6b5 --- /dev/null +++ b/README.md @@ -0,0 +1,275 @@ +# R2-Index + +一个支持多种云存储后端的文件管理、索引和浏览服务。 + +## 特性 + +- 📁 浏览和预览云存储中的文件 +- 🖼️ 图片缩略图生成 +- 🌐 公共访问 URL 支持 +- 🔄 多存储后端支持(可扩展) +- ⬆️ 文件上传功能 +- 🗑️ 文件删除功能 +- ✏️ 文件/文件夹重命名 +- 📂 创建文件夹 +- 📋 文件/文件夹复制 +- 🔄 文件/文件夹移动 +- 📱 响应式界面,多种设备尺寸支持 +- 🌙 深色模式支持 + +## TODO + +- [] Github Repo 储存支持 +- [] Github Release 储存支持 +- [] 基于数据库的用户/权限管理 +- [] 操作日志记录 +- [] Office Documents 预览支持 +- [] 视频预览支持 +- [] 文件夹打包下载支持 + +## 支持的存储后端 + +- **Cloudflare R2** - Cloudflare 的对象存储服务(S3 兼容) +- **Amazon S3** - Amazon S3 对象存储服务 + + +## 快速开始 + +### 1. 克隆项目 + +```bash +git clone https://github.com/RhenCloud/R2-Index.git +cd R2-Index +``` + +### 2. 安装依赖 + +```bash +pip install -r requirements.txt +``` + +### 3. 配置环境变量 + +复制 `.env.example` 到 `.env` 并填写配置: + +```bash +cp .env.example .env +``` + +### 4. 运行应用 + +```bash +python app.py +``` + +## 配置说明 + +### 选择存储类型 + +在 `.env` 文件中设置 `STORAGE_TYPE` 来选择存储后端: + +```env +# 使用 Cloudflare R2 +STORAGE_TYPE=r2 + +# 或使用腾讯云 cnb.cool +STORAGE_TYPE=cnbcool +``` + +### Cloudflare R2 配置 + +```env +STORAGE_TYPE=r2 + +# R2 访问凭证 +ACCESS_KEY_ID=your_access_key_id +SECRET_ACCESS_KEY=your_secret_access_key + +# R2 存储桶配置 +R2_BUCKET_NAME=your_bucket_name +R2_ENDPOINT_URL=https://your-account-id.r2.cloudflarestorage.com +R2_REGION=auto + +# 可选:公共访问 URL +R2_PUBLIC_URL=https://pub-your-bucket.r2.dev + +# 可选:预签名 URL 过期时间(秒) +R2_PRESIGN_EXPIRES=3600 +``` + +## 项目结构 + +``` +r2-index/ +├── app.py # Flask 应用主入口 +├── handlers/ +│ └── routes.py # 路由处理器 +├── storages/ # 存储后端实现 +│ ├── __init__.py +│ ├── base.py # 基础存储类(抽象类) +│ ├── factory.py # 存储工厂类 +│ ├── r2.py # Cloudflare R2 实现 +│ └── cnbcool.py # 腾讯云 cnb.cool 实现 +├── templates/ # HTML 模板 +│ ├── index.html +│ └── footer.html +├── static/ # 静态资源 +│ └── thumbs/ +├── .env.example # 环境变量示例 +└── requirements.txt # Python 依赖 +``` + +## 贡献 + +项目采用策略模式和工厂模式,使得添加新的存储后端变得简单: + +1. **BaseStorage** - 定义存储后端的统一接口 +2. **具体实现** (R2Storage, CnbCoolStorage) - 实现具体的存储逻辑 +3. **StorageFactory** - 根据配置创建对应的存储实例 + +### 添加新的存储后端 + +1. 在 `storages/` 目录下创建新的存储实现文件 +2. 继承 `BaseStorage` 并实现所有抽象方法 +3. 在 `StorageFactory` 中添加对应的创建逻辑 +4. 更新 `.env.example` 添加新的配置项 + +## API 路由 + +- `GET /` - 浏览根目录 +- `GET /` - 浏览指定目录 +- `GET /file/` - 获取文件内容 +- `GET /thumb/` - 获取图片缩略图 +- `POST /upload` - 上传文件 +- `DELETE /delete/` - 删除文件 +- `POST /rename/` - 重命名文件 +- `DELETE /delete_folder/` - 删除文件夹 +- `POST /rename_folder/` - 重命名文件夹 +- `POST /copy` - 复制文件或文件夹 +- `POST /move` - 移动文件或文件夹 +- `POST /create_folder` - 创建文件夹 + +详细 API 文档:[API 文档](docs/api.md) + +## 部署 + +### Vercel 部署 + +项目包含 `vercel.json` 配置文件,可直接部署到 Vercel: + +1. 在 Vercel 中导入项目 +2. 在 Vercel 项目设置中配置环境变量 +3. 部署 + +### Docker 部署 + +您也可以使用 Docker 部署此应用: + +```dockerfile +FROM python:3.9-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["python", "app.py"] +``` + +构建和运行: + +```bash +docker build -t r2-index . +docker run -p 5000:5000 --env-file .env r2-index +``` + +### 本地开发 + +```bash +# 1. 克隆项目 +git clone https://github.com/RhenCloud/R2-Index.git +cd R2-Index + +# 2. 创建虚拟环境 +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate + +# 3. 安装依赖 +pip install -r requirements.txt + +# 4. 配置环境变量 +cp .env.example .env +# 编辑 .env 文件,填入你的存储配置 + +# 5. 运行应用 +python app.py +``` + +应用将在 `http://localhost:5000` 启动。 + +## 技术栈 + +- **Flask** - Web 框架 +- **Boto3** - AWS SDK(用于 S3 兼容存储) +- **Pillow** - 图片处理 +- **Python-dotenv** - 环境变量管理 + +## 常见问题 + +### Q: 如何限制上传文件大小? + +A: 在 `handlers/routes.py` 中的 `upload()` 函数中添加文件大小检查: + +```python +@main_route.route("/upload", methods=["POST"]) +def upload(): + file = request.files["file"] + + # 限制文件大小为 100MB + MAX_FILE_SIZE = 100 * 1024 * 1024 + if len(file.read()) > MAX_FILE_SIZE: + return jsonify({"success": False, "error": "File too large"}), 413 + + file.seek(0) # 重置文件指针 + # ... 继续上传逻辑 +``` + +### Q: 如何添加访问认证? + +A: 参考 [API 文档](docs/api.md) 的"安全建议"部分,可以添加基于 Token 的简单认证。 + +### Q: 支持哪些图片格式的缩略图生成? + +A: 支持 `jpg`, `jpeg`, `png`, `gif`, `bmp`, `webp`, `svg`, `ico` 等常见格式。 + +### Q: 如何在深色模式和浅色模式间切换? + +A: 点击页面顶部的月亮/太阳图标即可切换。设置将保存在本地存储中。 + +### Q: 支持哪些存储后端? + +A: 当前支持: + +- Cloudflare R2(推荐) +- Amazon S3 +- GitHub Repository(通过 cnb.cool) + +### Q: 如何添加新的存储后端? + +A: 参考项目结构中的"添加新的存储后端"部分,继承 `BaseStorage` 并实现所有抽象方法即可。 + +## 贡献指南 + +欢迎提交 Issue 和 Pull Request! + +1. Fork 项目 +2. 创建特性分支 (`git checkout -b feature/AmazingFeature`) +3. 提交更改 (`git commit -m 'Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 开启 Pull Request + +## 许可证 + +MIT License diff --git a/app.py b/app.py index 89fff4c..5df3657 100644 --- a/app.py +++ b/app.py @@ -1,24 +1,24 @@ -import hashlib import os -from datetime import datetime -from io import BytesIO -from pathlib import Path -from typing import Any, Dict, List -import boto3 -from botocore.config import Config -from dotenv import load_dotenv -from flask import Flask, Response, abort, render_template, request, send_file -from PIL import Image +import dotenv +from flask import Flask -# 加载环境变量 -load_dotenv() +from handlers.routes import main_route +from storages.factory import StorageFactory -app = Flask(__name__, static_url_path="/static", static_folder="static") +dotenv.load_dotenv() +app = Flask(__name__) + + +# 注册蓝图 +app.register_blueprint(main_route) + +# 初始化存储(使用工厂模式) +storage = StorageFactory.get_storage() # 缩略图默认 TTL(秒),可通过环境变量覆盖 -THUMB_TTL = int(os.getenv("THUMB_TTL_SECONDS", "3600")) +THUMB_TTL = int(os.environ.get("THUMB_TTL_SECONDS", "3600")) # 注册一个安全的 filesizeformat 过滤器,处理 None 和非数字值 @@ -86,258 +86,25 @@ def fileicon_filter(filename): return "fas fa-file" -def get_s3_client(): - """ - 创建并返回配置好的 S3 客户端,用于访问 R2 存储 - """ - endpoint = os.getenv("R2_ENDPOINT_URL") - if not endpoint: - # 更明确的错误,便于调试环境变量问题 - raise RuntimeError("R2_ENDPOINT_URL environment variable is not set") - - # 支持常见 AWS 环境变量名:AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY - access_key = os.getenv("ACCESS_KEY_ID") or os.getenv("ACCESS_KEY_ID") - secret_key = os.getenv("SECRET_ACCESS_KEY") or os.getenv("SECRET_ACCESS_KEY") - - if not access_key or not secret_key: - raise RuntimeError("ACCESS_KEY_ID and SECRET_ACCESS_KEY must be set") - - return boto3.client( - "s3", - endpoint_url=endpoint, - aws_access_key_id=access_key, - aws_secret_access_key=secret_key, - config=Config(signature_version="s3v4"), - region_name=os.getenv("R2_REGION", "auto"), - ) - - def get_public_url(key: str) -> str: """ 生成对象的公共访问 URL """ - base_url = os.getenv("R2_PUBLIC_URL") - if not base_url: - return None - return f"{base_url.rstrip('/')}/{key}" + return storage.get_public_url(key) def format_timestamp(timestamp) -> str: """ 格式化时间戳为人类可读的格式 """ - if isinstance(timestamp, datetime): - return timestamp.strftime("%Y-%m-%d %H:%M:%S") - return str(timestamp) + return storage.format_timestamp(timestamp) def generate_presigned_url( s3_client, bucket_name: str, key: str, expires: int = None ) -> str: """为指定对象生成 presigned URL(GET)。""" - if expires is None: - try: - expires = int(os.getenv("R2_PRESIGN_EXPIRES", "3600")) - except Exception: - expires = 3600 - - try: - url = s3_client.generate_presigned_url( - "get_object", Params={"Bucket": bucket_name, "Key": key}, ExpiresIn=expires - ) - return url - except Exception: - app.logger.exception("Failed to generate presigned URL for %s", key) - return None - - -@app.route("/") -def index(): - """ - 返回 R2 存储桶中的文件和目录列表的 HTML 页面。 - """ - try: - s3_client = get_s3_client() - bucket_name = os.getenv("R2_BUCKET_NAME") - - # 支持 prefix 查询参数(用于浏览子目录) - prefix = request.args.get("prefix", "") or "" - if prefix and not prefix.endswith("/"): - prefix = prefix + "/" - - list_kwargs = {"Bucket": bucket_name, "Delimiter": "/"} - if prefix: - list_kwargs["Prefix"] = prefix - - # 列出指定前缀(根或子目录)下的对象 - response = s3_client.list_objects_v2(**list_kwargs) - - entries: List[Dict[str, Any]] = [] - if "Contents" in response: - for obj in response["Contents"]: - # 跳过等于 prefix 的条目(有时存在) - key = obj.get("Key", "") - if prefix and key == prefix: - continue - if key.endswith("/"): - continue - - # 显示相对名称(去掉当前 prefix) - rel_name = key[len(prefix) :] if prefix else key - - entry = { - "name": rel_name, - "key": key, - "size": obj.get("Size"), - "last_modified": format_timestamp(obj.get("LastModified")), - "is_dir": False, - } - - # 添加公共访问 URL(如果配置了) - public_url = get_public_url(key) - if public_url: - entry["public_url"] = public_url - - # 添加 presigned URL(优先于后端代理预览) - presigned = generate_presigned_url(s3_client, bucket_name, key) - if presigned: - entry["presigned_url"] = presigned - - # 通过服务器访问的文件 URL(用于预览和缩略图) - entry["file_url"] = get_file_url(key) - entries.append(entry) - - # 添加当前前缀下的文件夹(CommonPrefixes) - if "CommonPrefixes" in response: - for p in response["CommonPrefixes"]: - pref = p.get("Prefix") - # 相对文件夹名 - rel = pref[len(prefix) :].rstrip("/") if prefix else pref.rstrip("/") - entries.append({"name": rel, "key": pref, "is_dir": True}) - - # 按照类型(目录在前)和名称排序 - entries.sort(key=lambda x: (not x.get("is_dir", False), x["name"])) - - # 构造面包屑导航 - crumbs = [] - if prefix: - segs = prefix.rstrip("/").split("/") - acc = "" - for seg in segs: - acc = acc + seg + "/" - crumbs.append({"name": seg, "prefix": acc}) - - return render_template( - "index.html", - entries=entries, - current_prefix=prefix, - crumbs=crumbs, - current_year=datetime.now().year, - ) - except Exception as e: - app.logger.exception("Error listing R2 bucket") - return render_template( - "index.html", error=str(e), current_year=datetime.now().year - ) - - -@app.route("/") -def browse(prefix_path): - """漂亮的目录路由。将 URL /a/b 映射为 prefix 'a/b/' 并重用 index 的逻辑。""" - # delegate to index-like logic but with provided prefix - try: - s3_client = get_s3_client() - bucket_name = os.getenv("R2_BUCKET_NAME") - - prefix = prefix_path or "" - if prefix and not prefix.endswith("/"): - prefix = prefix + "/" - - list_kwargs = {"Bucket": bucket_name, "Delimiter": "/", "Prefix": prefix} - response = s3_client.list_objects_v2(**list_kwargs) - - entries: List[Dict[str, Any]] = [] - if "Contents" in response: - for obj in response["Contents"]: - key = obj.get("Key", "") - if prefix and key == prefix: - continue - if key.endswith("/"): - continue - rel_name = key[len(prefix) :] if prefix else key - entry = { - "name": rel_name, - "key": key, - "size": obj.get("Size"), - "last_modified": format_timestamp(obj.get("LastModified")), - "is_dir": False, - "file_url": get_file_url(key), - } - entries.append(entry) - - if "CommonPrefixes" in response: - for p in response["CommonPrefixes"]: - pref = p.get("Prefix") - rel = pref[len(prefix) :].rstrip("/") if prefix else pref.rstrip("/") - entries.append({"name": rel, "key": pref, "is_dir": True}) - - entries.sort(key=lambda x: (not x.get("is_dir", False), x["name"])) - - crumbs = [] - if prefix: - segs = prefix.rstrip("/").split("/") - acc = "" - for seg in segs: - acc = acc + seg + "/" - crumbs.append({"name": seg, "prefix": acc}) - - return render_template( - "index.html", - entries=entries, - current_prefix=prefix, - crumbs=crumbs, - current_year=datetime.now().year, - ) - except Exception as e: - app.logger.exception("Error browsing R2 bucket") - return render_template( - "index.html", error=str(e), current_year=datetime.now().year - ) - - -@app.route("/file/") -def serve_file(file_path): - """通过服务器提供文件访问""" - try: - s3_client = get_s3_client() - bucket_name = os.getenv("R2_BUCKET_NAME") - - # 获取文件的基本信息 - try: - response = s3_client.head_object(Bucket=bucket_name, Key=file_path) - except s3_client.exceptions.ClientError as e: - if e.response["Error"]["Code"] == "404": - abort(404) - else: - abort(500) - - # 获取文件对象 - file_obj = s3_client.get_object(Bucket=bucket_name, Key=file_path) - - # 设置响应头 - headers = { - "Content-Type": file_obj["ContentType"], - "Content-Length": str(file_obj["ContentLength"]), - } - - # 使用 Response 流式传输文件内容 - return Response( - file_obj["Body"].iter_chunks(), headers=headers, direct_passthrough=True - ) - - except Exception as e: - app.logger.exception("Error serving file") - abort(500) + return storage.generate_presigned_url(key, expires) def get_file_url(key: str) -> str: @@ -345,75 +112,6 @@ def get_file_url(key: str) -> str: return f"/file/{key}" -@app.route("/thumb/") -def thumb(file_path): - """返回图片的缩略图,使用 Vercel Cache Headers 避免重复从 R2 拉取""" - bucket_name = os.getenv("R2_BUCKET_NAME") - - # 设置更长的缓存控制头以支持浏览器本地缓存 - cache_headers = { - "Cache-Control": f"public, max-age={THUMB_TTL}", - "ETag": f'W/"{hashlib.md5(file_path.encode("utf-8")).hexdigest()}"', - } - - # 先检查客户端是否已经有缓存版本 - etag = request.headers.get("If-None-Match") - if etag and etag == cache_headers["ETag"]: - return Response(status=304, headers=cache_headers) - - # 从 R2 获取原始对象并生成缩略图 - try: - s3 = get_s3_client() - try: - obj = s3.get_object(Bucket=bucket_name, Key=file_path) - data = obj["Body"].read() - except Exception: - app.logger.exception("Failed to fetch object for thumb: %s", file_path) - response = send_file( - os.path.join(app.static_folder, "thumb_placeholder.svg"), - mimetype="image/svg+xml", - ) - response.headers.update(cache_headers) - return response - - try: - img = Image.open(BytesIO(data)) - img = img.convert("RGB") - img.thumbnail((320, 320)) - buf = BytesIO() - img.save(buf, "JPEG", quality=80, optimize=True) - buf.seek(0) - thumb_bytes = buf.getvalue() - - response = Response(thumb_bytes, mimetype="image/jpeg") - response.headers.update(cache_headers) - return response - except Exception: - app.logger.exception("Failed to generate thumbnail for %s", file_path) - response = send_file( - os.path.join(app.static_folder, "thumb_placeholder.svg"), - mimetype="image/svg+xml", - ) - response.headers.update(cache_headers) - return response - except Exception: - app.logger.exception("Unexpected error in thumb endpoint") - response = send_file( - os.path.join(app.static_folder, "thumb_placeholder.svg"), - mimetype="image/svg+xml", - ) - response.headers.update(cache_headers) - return response - - -# 添加路由以提供Service Worker文件 -@app.route("/static/sw.js") -def sw(): - return send_file( - os.path.join(app.static_folder, "sw.js"), mimetype="application/javascript" - ) - - if __name__ == "__main__": port = int(os.environ.get("PORT", 5000)) host = os.environ.get("HOST", "0.0.0.0") diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..ff6ba9d --- /dev/null +++ b/docs/api.md @@ -0,0 +1,626 @@ +# API 文档 + +Cloud Index 提供了 REST API 用于文件管理操作。 + +## API + +### 1. 上传文件 + +**端点:** `POST /upload` + +**描述:** 上传文件到存储 + +**请求:** + +- Method: `POST` +- Content-Type: `multipart/form-data` +- Body: + - `file` (required): 要上传的文件 + - `prefix` (optional): 目标路径前缀 + +**示例 (cURL):** + +```bash +# 上传到根目录 +curl -X POST http://localhost:5000/upload \ + -F "file=@/path/to/your/file.jpg" + +# 上传到指定目录 +curl -X POST http://localhost:5000/upload \ + -F "file=@/path/to/your/file.jpg" \ + -F "prefix=images/" +``` + +**示例 (JavaScript):** + +```javascript +const formData = new FormData(); +formData.append('file', fileInput.files[0]); +formData.append('prefix', 'images/'); + +const response = await fetch('/upload', { + method: 'POST', + body: formData +}); + +const result = await response.json(); +console.log(result); +``` + +**响应:** + +成功 (200): + +```json +{ + "success": true, + "message": "File uploaded successfully", + "path": "images/file.jpg" +} +``` + +失败 (400/500): + +```json +{ + "success": false, + "error": "Error message" +} +``` + +### 2. 删除文件 + +**端点:** `DELETE /delete/` + +**描述:** 删除存储中的文件 + +**请求:** + +- Method: `DELETE` 或 `POST` +- Path Parameter: + - `file_path`: 要删除的文件路径 + +**示例 (cURL):** + +```bash +# 使用 DELETE 方法 +curl -X DELETE http://localhost:5000/delete/images/file.jpg + +# 使用 POST 方法(某些环境不支持 DELETE) +curl -X POST http://localhost:5000/delete/images/file.jpg +``` + +**示例 (JavaScript):** + +```javascript +const response = await fetch('/delete/images/file.jpg', { + method: 'DELETE' +}); + +const result = await response.json(); +console.log(result); +``` + +**响应:** + +成功 (200): + +```json +{ + "success": true, + "message": "File deleted successfully" +} +``` + +失败 (500): + +```json +{ + "success": false, + "error": "Error message" +} +``` + +### 3. 列出文件 + +**端点:** `GET /` 或 `GET /` + +**描述:** 列出指定路径下的文件和目录 + +**请求:** + +- Method: `GET` +- Query Parameters: + - `prefix` (optional): 路径前缀 + +**示例:** + +```bash +# 列出根目录 +curl http://localhost:5000/ + +# 列出指定目录 +curl http://localhost:5000/images/ + +# 使用查询参数 +curl http://localhost:5000/?prefix=images/ +``` + +### 4. 获取文件 + +**端点:** `GET /file/` + +**描述:** 获取文件内容或重定向到预签名 URL(大文件) + +**请求:** + +- Method: `GET` +- Path Parameter: + - `file_path`: 文件路径 + +**示例:** + +```bash +curl http://localhost:5000/file/images/photo.jpg +``` + +**响应:** + +- 小文件 (< 6MB): 直接返回文件内容 +- 大文件 (>= 6MB): 302 重定向到预签名 URL + +### 5. 获取缩略图 + +**端点:** `GET /thumb/` + +**描述:** 获取图片的缩略图(320x320 JPEG) + +**请求:** + +- Method: `GET` +- Path Parameter: + - `file_path`: 图片文件路径 + +**示例:** + +```bash +curl http://localhost:5000/thumb/images/photo.jpg +``` + +**响应:** + +- Content-Type: `image/jpeg` +- Body: 缩略图数据 + +### 6. 重命名文件 + +**端点:** `POST /rename/` + +**描述:** 重命名文件 + +**请求:** + +- Method: `POST` +- Content-Type: `application/json` +- Path Parameter: + - `old_key`: 旧的文件路径 +- Body: + - `newName`: 新的文件名 + +**示例 (cURL):** + +```bash +curl -X POST http://localhost:5000/rename/images/photo.jpg \ + -H "Content-Type: application/json" \ + -d '{"newName":"photo_new.jpg"}' +``` + +**示例 (JavaScript):** + +```javascript +const response = await fetch('/rename/images/photo.jpg', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({newName: 'photo_new.jpg'}) +}); + +const result = await response.json(); +console.log(result); +``` + +**响应:** + +成功 (200): + +```json +{ + "success": true, + "message": "File renamed successfully", + "newKey": "images/photo_new.jpg" +} +``` + +失败 (400/500): + +```json +{ + "success": false, + "error": "Error message" +} +``` + +### 7. 删除文件夹 + +**端点:** `DELETE /delete_folder/` + +**描述:** 删除文件夹及其所有内容 + +**请求:** + +- Method: `DELETE` +- Path Parameter: + - `prefix`: 文件夹路径 + +**示例 (cURL):** + +```bash +curl -X DELETE http://localhost:5000/delete_folder/images +``` + +**示例 (JavaScript):** + +```javascript +const response = await fetch('/delete_folder/images', { + method: 'DELETE' +}); + +const result = await response.json(); +console.log(result); +``` + +**响应:** + +成功 (200): + +```json +{ + "success": true, + "message": "Folder deleted successfully" +} +``` + +失败 (500): + +```json +{ + "success": false, + "error": "Error message" +} +``` + +### 8. 重命名文件夹 + +**端点:** `POST /rename_folder/` + +**描述:** 重命名文件夹 + +**请求:** + +- Method: `POST` +- Content-Type: `application/json` +- Path Parameter: + - `old_prefix`: 旧的文件夹路径 +- Body: + - `newName`: 新的文件夹名 + +**示例 (cURL):** + +```bash +curl -X POST http://localhost:5000/rename_folder/old_folder \ + -H "Content-Type: application/json" \ + -d '{"newName":"new_folder"}' +``` + +**示例 (JavaScript):** + +```javascript +const response = await fetch('/rename_folder/old_folder', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({newName: 'new_folder'}) +}); + +const result = await response.json(); +console.log(result); +``` + +**响应:** + +成功 (200): + +```json +{ + "success": true, + "message": "Folder renamed successfully", + "newPrefix": "new_folder/" +} +``` + +失败 (500): + +```json +{ + "success": false, + "error": "Error message" +} +``` + +### 9. 复制文件或文件夹 + +**端点:** `POST /copy` + +**描述:** 复制文件或文件夹到另一位置 + +**请求:** + +- Method: `POST` +- Content-Type: `application/json` +- Body: + - `source`: 源文件或文件夹路径 + - `destination`: 目标文件或文件夹路径 + - `is_folder`: 是否为文件夹(true/false) + +**示例 (cURL):** + +```bash +# 复制文件 +curl -X POST http://localhost:5000/copy \ + -H "Content-Type: application/json" \ + -d '{"source":"images/photo.jpg","destination":"backup/photo.jpg","is_folder":false}' + +# 复制文件夹 +curl -X POST http://localhost:5000/copy \ + -H "Content-Type: application/json" \ + -d '{"source":"images/","destination":"backup/images/","is_folder":true}' +``` + +**示例 (JavaScript):** + +```javascript +const response = await fetch('/copy', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + source: 'images/photo.jpg', + destination: 'backup/photo.jpg', + is_folder: false + }) +}); + +const result = await response.json(); +console.log(result); +``` + +**响应:** + +成功 (200): + +```json +{ + "success": true, + "message": "Item copied successfully" +} +``` + +失败 (400/500): + +```json +{ + "success": false, + "error": "Error message" +} +``` + +### 10. 移动文件或文件夹 + +**端点:** `POST /move` + +**描述:** 移动(重命名路径)文件或文件夹到另一位置 + +**请求:** + +- Method: `POST` +- Content-Type: `application/json` +- Body: + - `source`: 源文件或文件夹路径 + - `destination`: 目标文件或文件夹路径 + - `is_folder`: 是否为文件夹(true/false) + +**示例 (cURL):** + +```bash +# 移动文件 +curl -X POST http://localhost:5000/move \ + -H "Content-Type: application/json" \ + -d '{"source":"images/photo.jpg","destination":"archive/photo.jpg","is_folder":false}' + +# 移动文件夹 +curl -X POST http://localhost:5000/move \ + -H "Content-Type: application/json" \ + -d '{"source":"images/","destination":"archive/images/","is_folder":true}' +``` + +**示例 (JavaScript):** + +```javascript +const response = await fetch('/move', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + source: 'images/photo.jpg', + destination: 'archive/photo.jpg', + is_folder: false + }) +}); + +const result = await response.json(); +console.log(result); +``` + +**响应:** + +成功 (200): + +```json +{ + "success": true, + "message": "Item moved successfully" +} +``` + +失败 (400/500): + +```json +{ + "success": false, + "error": "Error message" +} +``` + +### 11. 创建文件夹 + +**端点:** `POST /create_folder` + +**描述:** 创建新文件夹 + +**请求:** + +- Method: `POST` +- Content-Type: `application/json` +- Body: + - `path`: 新文件夹的路径 + +**示例 (cURL):** + +```bash +curl -X POST http://localhost:5000/create_folder \ + -H "Content-Type: application/json" \ + -d '{"path":"new_folder/subfolder"}' +``` + +**示例 (JavaScript):** + +```javascript +const response = await fetch('/create_folder', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({path: 'new_folder/subfolder'}) +}); + +const result = await response.json(); +console.log(result); +``` + +**响应:** + +成功 (200): + +```json +{ + "success": true, + "message": "Folder created successfully" +} +``` + +失败 (400/500): + +```json +{ + "success": false, + "error": "Error message" +} +``` + +## 错误代码 + +- `400 Bad Request`: 请求参数错误或缺少必要参数 +- `404 Not Found`: 文件或文件夹不存在 +- `413 Payload Too Large`: 文件太大(超过 6MB 且无预签名 URL) +- `500 Internal Server Error`: 服务器内部错误 + +## 功能特性 + +### 文件操作 + +- ✅ 上传文件 +- ✅ 下载文件(直接下载或预签名 URL) +- ✅ 删除文件 +- ✅ 重命名文件 +- ✅ 复制文件 +- ✅ 移动文件 + +### 文件夹操作 + +- ✅ 创建文件夹 +- ✅ 删除文件夹(包含其所有内容) +- ✅ 重命名文件夹 +- ✅ 复制文件夹(包含其所有内容) +- ✅ 移动文件夹(包含其所有内容) +- ✅ 列出文件夹内容 + +### 其他功能 + +- ✅ 生成缩略图(图片文件) +- ✅ 批量文件操作 +- ✅ 预签名 URL(用于大文件直接访问) + +## 批量操作 + +### 批量上传 + +前端可以遍历多个文件并依次调用上传 API: + +```javascript +async function uploadMultipleFiles(files) { + for (let file of files) { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch('/upload', { + method: 'POST', + body: formData + }); + + const result = await response.json(); + console.log(`${file.name}: ${result.success ? 'success' : 'failed'}`); + } +} +``` + +## 限制说明 + +### Cloudflare R2(免费套餐) + +- 总文件大小: 最多 10GB +- API 请求频率: 根据您的 R2 套餐 + + + +## 安全建议 + +1. **添加认证**: 在生产环境中为所有 API 端点添加认证 +2. **文件类型验证**: 验证上传文件的类型和扩展名,防止恶意文件上传 +3. **文件大小限制**: 在应用层限制上传文件的大小 +4. **路径验证**: 验证文件和文件夹路径,防止目录遍历攻击 +5. **速率限制**: 防止滥用 API,实施请求频率限制 +6. **CORS 配置**: 正确配置跨域访问策略 +7. **日志记录**: 记录所有文件操作以用于审计 +8. **访问控制**: 根据用户身份实施细粒度的访问控制 +9. **加密**: 对敏感数据进行加密存储和传输 +10. **备份**: 定期备份存储中的重要数据 diff --git a/handlers/routes.py b/handlers/routes.py new file mode 100644 index 0000000..1b6b6b6 --- /dev/null +++ b/handlers/routes.py @@ -0,0 +1,482 @@ +import hashlib +import os +from datetime import datetime +from typing import Any, Dict, List + +from flask import ( + Blueprint, + Response, + abort, + jsonify, + redirect, + render_template, + request, +) + +from storages.factory import StorageFactory + +main_route = Blueprint("main", __name__) + +# 初始化存储(使用工厂模式) +storage = StorageFactory.get_storage() + +# 缩略图默认 TTL(秒),可通过环境变量覆盖 +THUMB_TTL = int(os.environ.get("THUMB_TTL_SECONDS", "3600")) + + +def format_timestamp(timestamp) -> str: + """ + 格式化时间戳为人类可读的格式 + """ + return storage.format_timestamp(timestamp) + + +def get_public_url(key: str) -> str: + """ + 生成对象的公共访问 URL + """ + return storage.get_public_url(key) + + +def get_file_url(key: str) -> str: + """生成通过服务器访问文件的 URL""" + return f"/file/{key}" + + +def build_file_entry(obj: Dict[str, Any], prefix: str) -> Dict[str, Any] | None: + """根据对象信息构建文件条目。""" + key = obj.get("Key", "") + if not key: + return None + + if prefix and key == prefix: + return None + + if key.endswith("/"): + return None + + rel_name = key[len(prefix) :] if prefix else key + + entry: Dict[str, Any] = { + "name": rel_name, + "key": key, + "size": obj.get("Size"), + "last_modified": format_timestamp(obj.get("LastModified")), + "is_dir": False, + "file_url": get_file_url(key), + } + + public_url = get_public_url(key) + if public_url: + entry["public_url"] = public_url + + presigned = storage.generate_presigned_url(key) + if presigned: + entry["presigned_url"] = presigned + + return entry + + +def build_directory_entry( + prefix_value: str | None, current_prefix: str +) -> Dict[str, Any] | None: + """根据前缀构建目录条目。""" + if not prefix_value: + return None + + rel = ( + prefix_value[len(current_prefix) :].rstrip("/") + if current_prefix + else prefix_value.rstrip("/") + ) + + return {"name": rel, "key": prefix_value, "is_dir": True} + + +def build_entries(response: Dict[str, Any], prefix: str) -> List[Dict[str, Any]]: + """将存储响应转换为用于模板渲染的条目列表。""" + entries: List[Dict[str, Any]] = [] + + for obj in response.get("Contents", []): + entry = build_file_entry(obj, prefix) + if entry: + entries.append(entry) + + for pref in response.get("CommonPrefixes", []): + directory_entry = build_directory_entry(pref.get("Prefix"), prefix) + if directory_entry: + entries.append(directory_entry) + + entries.sort(key=lambda x: (not x.get("is_dir", False), x["name"])) + return entries + + +def build_crumbs(prefix: str) -> List[Dict[str, str]]: + """根据当前前缀构建面包屑导航数据。""" + crumbs: List[Dict[str, str]] = [] + if prefix: + segs = prefix.rstrip("/").split("/") + acc = "" + for seg in segs: + acc = acc + seg + "/" + crumbs.append({"name": seg, "prefix": acc}) + return crumbs + + +@main_route.route("/") +def index(): + """ + 返回文件和目录列表的 HTML 页面。 + """ + try: + prefix = request.args.get("prefix", "") or "" + + response = storage.list_objects(prefix) + entries = build_entries(response, prefix) + crumbs = build_crumbs(prefix) + + return render_template( + "index.html", + entries=entries, + current_prefix=prefix, + crumbs=crumbs, + current_year=datetime.now().year, + ) + except Exception: + abort(500) + + +@main_route.route("/") +def browse(prefix_path): + """目录路由。将 URL /a/b 映射为 prefix 'a/b/' 并重用 index 的逻辑。""" + try: + prefix = prefix_path or "" + if prefix and not prefix.endswith("/"): + prefix = prefix + "/" + + response = storage.list_objects(prefix) + entries = build_entries(response, prefix) + crumbs = build_crumbs(prefix) + + return render_template( + "index.html", + entries=entries, + current_prefix=prefix, + crumbs=crumbs, + current_year=datetime.now().year, + ) + except Exception: + abort(500) + + +@main_route.route("/file/") +def serve_file(file_path): + """通过服务器提供文件访问""" + try: + # 获取文件的基本信息,验证文件存在并检查大小 + try: + info = 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: + presigned = storage.generate_presigned_url(file_path) + if presigned: + return redirect(presigned) + # 如果没有预签名 URL,则返回 413(Payload Too Large) + abort(413) + + # 小文件:直接从存储获取并通过 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 + ) + + except Exception: + abort(500) + + +@main_route.route("/thumb/") +def thumb(file_path): + """返回图片的缩略图,使用 Vercel Cache Headers 避免重复从 R2 拉取""" + # 设置更长的缓存控制头以支持浏览器本地缓存 + cache_headers = { + "Cache-Control": f"public, max-age={THUMB_TTL}", + "ETag": f'W/"{hashlib.md5(file_path.encode("utf-8")).hexdigest()}"', + } + + # 先检查客户端是否已经有缓存版本 + etag = request.headers.get("If-None-Match") + if etag and etag == cache_headers["ETag"]: + return Response(status=304, headers=cache_headers) + + # 从获取原始对象并生成缩略图 + try: + # 对于较大的源文件,避免在函数内读取并处理,优先使用预签名 URL + try: + info = storage.get_object_info(file_path) + except Exception: + abort(404) + + size = int(info.get("ContentLength", 0) or 0) + limit = 6 * 1024 * 1024 + if size > limit: + presigned = storage.generate_presigned_url(file_path) + if presigned: + return redirect(presigned) + abort(413) + + thumb_bytes = storage.generate_thumbnail(file_path) + response = Response(thumb_bytes, mimetype="image/jpeg") + response.headers.update(cache_headers) + return response + except Exception: + abort(404) + + +@main_route.route("/upload", methods=["POST"]) +def upload(): + """上传文件到存储""" + try: + # 检查是否有文件 + if "file" not in request.files: + return jsonify({"success": False, "error": "No file provided"}), 400 + + file = request.files["file"] + + # 检查文件名 + if file.filename == "": + return jsonify({"success": False, "error": "No file selected"}), 400 + + # 获取目标路径(可选) + prefix = request.form.get("prefix", "") + if prefix and not prefix.endswith("/"): + prefix = prefix + "/" + + # 构建完整的文件路径 + file_path = prefix + file.filename + + # 读取文件数据 + file_data = file.read() + + # 获取文件类型 + content_type = file.content_type + + # 上传文件 + success = storage.upload_file(file_path, file_data, content_type) + + if success: + return jsonify( + { + "success": True, + "message": "File uploaded successfully", + "path": file_path, + } + ) + else: + return jsonify({"success": False, "error": "Upload failed"}), 500 + + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@main_route.route("/delete/", methods=["DELETE", "POST"]) +def delete(file_path): + """删除存储中的文件""" + try: + # 删除文件 + success = storage.delete_file(file_path) + + if success: + return jsonify({"success": True, "message": "File deleted successfully"}) + else: + return jsonify({"success": False, "error": "Delete failed"}), 500 + + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@main_route.route("/rename/", methods=["POST"]) +def rename(old_key): + """重命名存储中的文件""" + try: + data = request.get_json() + new_name = data.get("newName") + + if not new_name: + return jsonify({"success": False, "error": "New name not provided"}), 400 + + # 构建新的文件路径 + prefix = os.path.dirname(old_key) + if prefix: + new_key = f"{prefix}/{new_name}" + else: + new_key = new_name + + # 重命名文件 + success = storage.rename_file(old_key, new_key) + + if success: + return jsonify( + { + "success": True, + "message": "File renamed successfully", + "newKey": new_key, + } + ) + else: + return jsonify({"success": False, "error": "Rename failed"}), 500 + + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@main_route.route("/delete_folder/", methods=["DELETE"]) +def delete_folder_route(prefix): + """删除存储中的文件夹""" + try: + if not prefix.endswith("/"): + prefix += "/" + success = storage.delete_folder(prefix) + if success: + return jsonify({"success": True, "message": "Folder deleted successfully"}) + else: + return jsonify({"success": False, "error": "Folder delete failed"}), 500 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@main_route.route("/rename_folder/", methods=["POST"]) +def rename_folder_route(old_prefix): + """重命名存储中的文件夹""" + try: + data = request.get_json() + new_name = data.get("newName") + + if not new_name: + return jsonify({"success": False, "error": "New name not provided"}), 400 + + if not old_prefix.endswith("/"): + old_prefix += "/" + + # 构建新的文件夹路径 + parent_prefix = os.path.dirname(os.path.dirname(old_prefix)) + if parent_prefix: + new_prefix = f"{parent_prefix}/{new_name}/" + else: + new_prefix = f"{new_name}/" + + success = storage.rename_folder(old_prefix, new_prefix) + + if success: + return jsonify( + { + "success": True, + "message": "Folder renamed successfully", + "newPrefix": new_prefix, + } + ) + else: + return jsonify({"success": False, "error": "Folder rename failed"}), 500 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@main_route.route("/copy", methods=["POST"]) +def copy_item(): + """复制文件或文件夹""" + try: + data = request.get_json() + source = data.get("source") + destination = data.get("destination") + is_folder = data.get("is_folder", False) + + if not source or not destination: + return ( + jsonify( + {"success": False, "error": "Source or destination not provided"} + ), + 400, + ) + + if is_folder: + if not source.endswith("/"): + source += "/" + if not destination.endswith("/"): + destination += "/" + success = storage.copy_folder(source, destination) + else: + success = storage.copy_file(source, destination) + + if success: + return jsonify({"success": True, "message": "Item copied successfully"}) + else: + return jsonify({"success": False, "error": "Copy failed"}), 500 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@main_route.route("/move", methods=["POST"]) +def move_item(): + """移动文件或文件夹""" + try: + data = request.get_json() + source = data.get("source") + destination = data.get("destination") + is_folder = data.get("is_folder", False) + + if not source or not destination: + return ( + jsonify( + {"success": False, "error": "Source or destination not provided"} + ), + 400, + ) + + if is_folder: + if not source.endswith("/"): + source += "/" + if not destination.endswith("/"): + destination += "/" + success = storage.rename_folder(source, destination) + else: + success = storage.rename_file(source, destination) + + if success: + return jsonify({"success": True, "message": "Item moved successfully"}) + else: + return jsonify({"success": False, "error": "Move failed"}), 500 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@main_route.route("/create_folder", methods=["POST"]) +def create_folder_route(): + """创建文件夹""" + try: + data = request.get_json() + path = data.get("path") + + if not path: + return jsonify({"success": False, "error": "Path not provided"}), 400 + + if not path.endswith("/"): + path += "/" + + success = storage.create_folder(path) + + if success: + return jsonify({"success": True, "message": "Folder created successfully"}) + else: + return jsonify({"success": False, "error": "Folder creation failed"}), 500 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..036998e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +flask>=2.3.0 +boto3>=1.28.0 +python-dotenv>=1.0.0 +Pillow>=10.0.0 +botocore>=1.31.0 +requests>=2.31.0 diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..2a75231 --- /dev/null +++ b/static/css/main.css @@ -0,0 +1,705 @@ +:root { + --bg-color: #f5f5f5; + --container-bg: white; + --text-color: #2c3e50; + --border-color: #eee; + --hover-bg: #f8f9fa; + --secondary-text: #6c757d; + --link-color: #007bff; + --folder-color: #ffc107; + --file-color: #6c757d; + --shadow-color: rgba(0, 0, 0, 0.1); +} + +[data-theme="dark"] { + --bg-color: #1a1a1a; + --container-bg: #2d2d2d; + --text-color: #e1e1e1; + --border-color: #404040; + --hover-bg: #363636; + --secondary-text: #a0a0a0; + --link-color: #66b3ff; + --folder-color: #ffd54f; + --file-color: #b0b0b0; + --shadow-color: rgba(0, 0, 0, 0.3); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + max-width: 1200px; + margin: 0 auto; + padding: 20px; + background-color: var(--bg-color); + color: var(--text-color); + transition: background-color 0.3s ease, color 0.3s ease; +} + +.container { + background-color: var(--container-bg); + border-radius: 8px; + box-shadow: 0 2px 4px var(--shadow-color); + padding: 20px; +} + +h1 { + color: var(--text-color); + margin-bottom: 30px; + border-bottom: 2px solid var(--border-color); + padding-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; +} + +/* 通用按钮样式 */ +.btn { + background: none; + border: none; + cursor: pointer; + padding: 8px; + border-radius: 6px; + font-size: 1em; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.btn:hover { + background-color: var(--hover-bg); + transform: translateY(-1px); +} + +.btn:active { + transform: translateY(0); +} + +/* 导航栏按钮 */ +.nav-btn { + color: var(--secondary-text); + font-size: 1.1em; + padding: 8px 10px; +} + +.nav-btn:hover { + color: var(--text-color); +} + +/* 兼容旧的类名 */ +.theme-toggle, +.view-toggle { + background: none; + border: none; + color: var(--secondary-text); + cursor: pointer; + padding: 8px 10px; + border-radius: 6px; + font-size: 1.1em; + transition: all 0.2s ease; +} + +.theme-toggle:hover, +.view-toggle:hover { + background-color: var(--hover-bg); + color: var(--text-color); + transform: translateY(-1px); +} + +.theme-toggle:active, +.view-toggle:active { + transform: translateY(0); +} + +/* 导航栏删除按钮特殊样式 */ +.view-toggle[style*="color: #dc3545"]:hover { + color: #c82333 !important; + background-color: rgba(220, 53, 69, 0.1); +} + +/* Grid view styles */ +.grid-container { + display: none; + margin-top: 20px; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; +} + +.grid-card { + background: var(--container-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px; + text-align: center; + box-shadow: 0 1px 2px var(--shadow-color); +} + +.grid-checkbox { + text-align: left; + margin-bottom: 8px; +} + +.grid-checkbox input { + transform: scale(1.05); + accent-color: var(--link-color); +} + +.grid-thumb { + width: 100%; + height: 100px; + object-fit: cover; + border-radius: 6px; + margin-bottom: 8px; + background-color: #f0f0f0; +} + +.grid-name { + display: block; + font-size: 0.95em; + color: var(--text-color); + margin-bottom: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 网格卡片操作按钮 */ +.grid-actions { + display: flex; + gap: 4px; + justify-content: center; + margin-top: 8px; +} + +.grid-action-btn { + background: none; + border: none; + cursor: pointer; + padding: 4px 6px; + border-radius: 4px; + font-size: 0.85em; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 3px; +} + +.grid-action-btn.preview { + color: var(--link-color); +} + +.grid-action-btn.preview:hover { + background-color: rgba(0, 123, 255, 0.1); +} + +.grid-action-btn.download { + color: #28a745; +} + +.grid-action-btn.download:hover { + background-color: rgba(40, 167, 69, 0.1); +} + +.grid-action-btn.delete { + color: #dc3545; +} + +.grid-action-btn.delete:hover { + background-color: rgba(220, 53, 69, 0.1); +} + +/* Toggle visibility based on data-view on root */ +:root[data-view="grid"] .files-table { + display: none; +} + +:root[data-view="grid"] .grid-container { + display: grid; +} + +.files-table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; +} + +.checkbox-col { + width: 48px; + text-align: center; +} + +.checkbox-col input { + transform: scale(1.05); + accent-color: var(--link-color); +} + +.files-table th, +.files-table td { + padding: 12px; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +.files-table th { + background-color: var(--hover-bg); + color: var(--text-color); + font-weight: 600; +} + +.files-table tr:hover { + background-color: var(--hover-bg); +} + +.file-icon { + margin-right: 8px; +} + +.folder { + color: var(--folder-color); +} + +.file { + color: var(--file-color); +} + +a { + color: var(--link-color); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.file-size { + color: var(--secondary-text); + font-size: 0.9em; +} + +.last-modified { + color: var(--secondary-text); + font-size: 0.9em; +} + +.empty-message { + text-align: center; + padding: 40px; + color: var(--secondary-text); +} + +.breadcrumb { + margin-bottom: 12px; + font-size: 0.95em; + color: var(--secondary-text); + background-color: var(--container-bg); + padding: 8px 12px; + border-radius: 4px; +} + +/* 页脚样式 */ +.site-footer { + margin-top: 20px; + text-align: center; + color: var(--secondary-text); + font-size: 0.9em; + padding: 14px 12px; +} + +.site-footer a { + color: var(--link-color); + text-decoration: none; + margin: 0 6px; +} + +.site-footer a:hover { + text-decoration: underline; +} + +@media (max-width: 600px) { + .site-footer { + font-size: 0.85em; + padding: 12px 8px; + } +} + +/* 文件上传输入框 */ +.upload-input { + display: none; +} + +/* 删除按钮 */ +.delete-btn { + background: none; + border: none; + color: #dc3545; + cursor: pointer; + padding: 6px 10px; + border-radius: 6px; + font-size: 0.9em; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.delete-btn:hover { + background-color: rgba(220, 53, 69, 0.1); + color: #c82333; + transform: translateY(-1px); +} + +.delete-btn:active { + transform: translateY(0); +} + +/* 操作链接按钮 */ +.action-link { + color: var(--link-color); + text-decoration: none; + padding: 4px 8px; + border-radius: 4px; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.action-link:hover { + background-color: var(--hover-bg); + text-decoration: none; +} + +/* 预览按钮(button标签样式) */ +button.action-link { + border: none; + background: none; + cursor: pointer; + font-size: 0.9em; + font-family: inherit; +} + +/* 下载按钮 */ +.download-btn { + background: none; + border: none; + color: #28a745; + cursor: pointer; + padding: 6px 10px; + border-radius: 6px; + font-size: 0.9em; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.download-btn:hover { + background-color: rgba(40, 167, 69, 0.1); + color: #218838; + transform: translateY(-1px); +} + +.download-btn:active { + transform: translateY(0); +} + +/* 预览模态框 */ +.preview-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.9); + z-index: 1000; + opacity: 0; + transition: opacity 0.3s ease; +} + +.preview-modal.show { + display: flex; + opacity: 1; + justify-content: center; + align-items: center; +} + +.preview-container { + position: relative; + max-width: 90%; + max-height: 90%; + display: flex; + flex-direction: column; + align-items: center; +} + +.preview-content { + max-width: 100%; + max-height: 85vh; + object-fit: contain; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); +} + +.preview-controls { + position: absolute; + top: 20px; + right: 20px; + display: flex; + gap: 10px; + z-index: 1001; +} + +.preview-btn { + background-color: rgba(255, 255, 255, 0.9); + border: none; + color: #333; + width: 40px; + height: 40px; + border-radius: 50%; + cursor: pointer; + font-size: 1.2em; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.preview-btn:hover { + background-color: white; + transform: scale(1.1); +} + +.preview-info { + margin-top: 15px; + color: white; + text-align: center; + font-size: 0.95em; + background-color: rgba(0, 0, 0, 0.7); + padding: 10px 20px; + border-radius: 20px; + max-width: 80%; + word-break: break-all; +} + +.preview-loading { + color: white; + font-size: 1.2em; + text-align: center; +} + +.preview-error { + color: #ff6b6b; + font-size: 1.1em; + text-align: center; + padding: 20px; + background-color: rgba(255, 255, 255, 0.1); + border-radius: 8px; +} + +video.preview-content { + background-color: #000; +} + +@media (max-width: 768px) { + .preview-container { + max-width: 95%; + } + + .preview-controls { + top: 10px; + right: 10px; + } + + .preview-btn { + width: 35px; + height: 35px; + font-size: 1em; + } + + .preview-info { + font-size: 0.85em; + padding: 8px 15px; + } +} + +/* 上传进度提示 */ +.upload-status { + padding: 10px; + border-radius: 4px; + margin-bottom: 10px; + display: none; +} + +.upload-status.success { + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; + display: block; +} + +.upload-status.error { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + display: block; +} + +/* 自定义对话框样式 */ +.app-dialog[hidden] { + display: none; +} + +.app-dialog { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.35); + backdrop-filter: blur(2px); + z-index: 1100; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; +} + +.app-dialog.is-visible { + opacity: 1; + pointer-events: auto; +} + +.app-dialog__backdrop { + position: absolute; + inset: 0; +} + +.app-dialog__panel { + position: relative; + background-color: var(--container-bg); + color: var(--text-color); + border-radius: 12px; + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.25); + padding: 24px; + min-width: 320px; + max-width: min(420px, 90vw); + transform: translateY(20px); + transition: transform 0.2s ease; +} + +.app-dialog.is-visible .app-dialog__panel { + transform: translateY(0); +} + +.app-dialog__title { + margin: 0 0 12px; + font-size: 1.15em; + font-weight: 600; +} + +.app-dialog__message { + margin-bottom: 18px; + line-height: 1.5; + color: var(--text-color); + word-break: break-word; +} + +.app-dialog__input { + margin-bottom: 18px; + display: block; + width: 100%; +} + +.app-dialog__input input { + width: 100%; + padding: 10px 12px; + border-radius: 6px; + border: 1px solid var(--border-color); + background-color: var(--container-bg); + color: var(--text-color); + font-size: 0.95em; + box-sizing: border-box; + font-family: inherit; +} + +.app-dialog__input input::placeholder { + color: var(--secondary-text); +} + +.app-dialog__input input:focus { + outline: none; + border-color: var(--link-color); + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.15); +} + +.app-dialog__actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.app-dialog__btn { + padding: 8px 16px; + border-radius: 6px; + border: none; + cursor: pointer; + font-size: 0.95em; + transition: transform 0.15s ease, background-color 0.2s ease; +} + +.app-dialog__btn:hover { + transform: translateY(-1px); +} + +.app-dialog__cancel { + background-color: var(--hover-bg); + color: var(--secondary-text); +} + +.app-dialog__cancel:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +.app-dialog__confirm { + background-color: #dc3545; + color: #fff; +} + +.app-dialog__confirm:hover { + background-color: #c82333; +} + +@media (max-width: 480px) { + .app-dialog__panel { + width: calc(100vw - 40px); + padding: 20px; + } +} + +.action-link:hover, +.grid-action-btn:hover { + color: var(--link-hover-color); + background-color: var(--hover-bg-color); +} + +#deleteTrigger, +.delete-btn, +.grid-action-btn.delete { + color: var(--danger-color, #dc3545); +} + +#deleteTrigger:hover, +.delete-btn:hover, +.grid-action-btn.delete:hover { + color: var(--danger-hover-color, #a0202d); +} + +.rename-btn, +.grid-action-btn.rename { + color: var(--warning-color, #ffc107); +} + +.rename-btn:hover, +.grid-action-btn.rename:hover { + color: var(--warning-hover-color, #e0a800); +} diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..7ca8381 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,947 @@ +(function () { + function updateStatus(message, state) { + const statusDiv = document.getElementById("uploadStatus"); + if (!statusDiv) { + return null; + } + + statusDiv.textContent = message; + statusDiv.className = "upload-status" + (state ? ` ${state}` : ""); + statusDiv.style.display = "block"; + return statusDiv; + } + + function hideStatusLater(statusDiv, delay = 2000) { + if (!statusDiv) { + return; + } + setTimeout(() => { + statusDiv.style.display = "none"; + }, delay); + } + + const dialogState = { + container: null, + title: null, + message: null, + inputWrapper: null, + input: null, + confirmBtn: null, + cancelBtn: null, + resolve: null, + options: null, + previousActiveElement: null, + }; + + function isDialogOpen() { + return Boolean(dialogState.container && !dialogState.container.hasAttribute("hidden")); + } + + function closeDialog(confirmed) { + if (!dialogState.resolve || !dialogState.container) { + return; + } + + const showInput = dialogState.options?.showInput; + const value = confirmed && showInput && dialogState.input ? dialogState.input.value : undefined; + + dialogState.container.classList.remove("is-visible"); + dialogState.container.setAttribute("aria-hidden", "true"); + + window.setTimeout(() => { + if (!dialogState.container.classList.contains("is-visible")) { + dialogState.container.setAttribute("hidden", ""); + } + }, 200); + + if (dialogState.inputWrapper) { + dialogState.inputWrapper.hidden = true; + } + + const resolve = dialogState.resolve; + dialogState.resolve = null; + const options = dialogState.options || {}; + dialogState.options = null; + + if (dialogState.previousActiveElement && typeof dialogState.previousActiveElement.focus === "function") { + dialogState.previousActiveElement.focus({ preventScroll: true }); + } + dialogState.previousActiveElement = null; + + resolve({ + confirmed, + value: value !== undefined ? value : undefined, + options, + }); + } + + function openDialog(options) { + if (!dialogState.container) { + return Promise.resolve({ confirmed: false }); + } + + return new Promise((resolve) => { + dialogState.resolve = resolve; + dialogState.options = options; + dialogState.previousActiveElement = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + + dialogState.container.removeAttribute("hidden"); + dialogState.container.setAttribute("aria-hidden", "false"); + + if (dialogState.title) { + dialogState.title.textContent = options.title || ""; + dialogState.title.hidden = !options.title; + } + + if (dialogState.message) { + dialogState.message.textContent = options.message || ""; + } + + if (dialogState.inputWrapper && dialogState.input) { + dialogState.inputWrapper.hidden = !options.showInput; + dialogState.input.value = options.defaultValue || ""; + dialogState.input.placeholder = options.placeholder || ""; + } + + if (dialogState.confirmBtn) { + dialogState.confirmBtn.textContent = options.confirmLabel || "确定"; + } + + if (dialogState.cancelBtn) { + dialogState.cancelBtn.textContent = options.cancelLabel || "取消"; + dialogState.cancelBtn.hidden = options.hideCancel || false; + } + + window.requestAnimationFrame(() => { + if (!dialogState.container) { + return; + } + dialogState.container.classList.add("is-visible"); + + if (options.showInput && dialogState.input) { + dialogState.input.focus(); + dialogState.input.select(); + } else if (dialogState.confirmBtn) { + dialogState.confirmBtn.focus(); + } + }); + }); + } + + function initDialog() { + const container = document.getElementById("appDialog"); + if (!container || container.dataset.initialized === "true") { + return; + } + + container.dataset.initialized = "true"; + dialogState.container = container; + dialogState.title = document.getElementById("appDialogTitle"); + dialogState.message = document.getElementById("appDialogMessage"); + dialogState.inputWrapper = document.getElementById("appDialogInputWrapper"); + dialogState.input = document.getElementById("appDialogInput"); + dialogState.confirmBtn = document.getElementById("appDialogConfirm"); + dialogState.cancelBtn = document.getElementById("appDialogCancel"); + + if (dialogState.confirmBtn) { + dialogState.confirmBtn.addEventListener("click", () => closeDialog(true)); + } + + if (dialogState.cancelBtn) { + dialogState.cancelBtn.addEventListener("click", () => closeDialog(false)); + } + + container.addEventListener("click", (event) => { + if ( + event.target === container || + (event.target instanceof HTMLElement && event.target.dataset.dialogDismiss === "true") + ) { + closeDialog(false); + } + }); + + document.addEventListener("keydown", (event) => { + if (!isDialogOpen()) { + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + closeDialog(false); + return; + } + + if (event.key === "Enter" && dialogState.options?.showInput) { + const active = document.activeElement; + if (active === dialogState.input) { + event.preventDefault(); + closeDialog(true); + } + } + }); + } + + function showConfirm(message, options = {}) { + return openDialog({ + title: options.title || "确认操作", + message, + confirmLabel: options.confirmLabel || "确定", + cancelLabel: options.cancelLabel || "取消", + hideCancel: options.hideCancel || false, + }).then((result) => Boolean(result.confirmed)); + } + + async function showPrompt(message, options = {}) { + const result = await openDialog({ + title: options.title || "请输入内容", + message, + confirmLabel: options.confirmLabel || "确定", + cancelLabel: options.cancelLabel || "取消", + showInput: true, + defaultValue: options.defaultValue || "", + placeholder: options.placeholder || "", + }); + + if (!result.confirmed) { + return null; + } + + const value = typeof result.value === "string" ? result.value.trim() : ""; + return value === "" ? null : value; + } + + function initThemeAndView() { + const themeToggle = document.getElementById("themeToggle"); + const viewToggle = document.getElementById("viewToggle"); + + if (!themeToggle || !viewToggle) { + return; + } + + const themeIcon = themeToggle.querySelector("i"); + const viewIcon = viewToggle.querySelector("i"); + + const savedTheme = localStorage.getItem("theme"); + if (savedTheme === "dark") { + document.documentElement.setAttribute("data-theme", "dark"); + if (themeIcon) { + themeIcon.classList.remove("fa-moon"); + themeIcon.classList.add("fa-sun"); + } + } + + const savedView = localStorage.getItem("view") || "list"; + document.documentElement.setAttribute("data-view", savedView); + if (viewIcon) { + if (savedView === "grid") { + viewIcon.classList.remove("fa-th-large"); + viewIcon.classList.add("fa-th-list"); + } else { + viewIcon.classList.remove("fa-th-list"); + viewIcon.classList.add("fa-th-large"); + } + } + + viewToggle.addEventListener("click", () => { + const current = document.documentElement.getAttribute("data-view") || "list"; + const next = current === "grid" ? "list" : "grid"; + document.documentElement.setAttribute("data-view", next); + localStorage.setItem("view", next); + + if (!viewIcon) { + return; + } + + if (next === "grid") { + viewIcon.classList.remove("fa-th-large"); + viewIcon.classList.add("fa-th-list"); + } else { + viewIcon.classList.remove("fa-th-list"); + viewIcon.classList.add("fa-th-large"); + } + }); + + themeToggle.addEventListener("click", () => { + const currentTheme = document.documentElement.getAttribute("data-theme"); + const newTheme = currentTheme === "dark" ? "light" : "dark"; + + document.documentElement.setAttribute("data-theme", newTheme); + localStorage.setItem("theme", newTheme); + + if (!themeIcon) { + return; + } + + if (newTheme === "dark") { + themeIcon.classList.remove("fa-moon"); + themeIcon.classList.add("fa-sun"); + } else { + themeIcon.classList.remove("fa-sun"); + themeIcon.classList.add("fa-moon"); + } + }); + + if (!savedTheme) { + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + if (prefersDark) { + document.documentElement.setAttribute("data-theme", "dark"); + if (themeIcon) { + themeIcon.classList.remove("fa-moon"); + themeIcon.classList.add("fa-sun"); + } + localStorage.setItem("theme", "dark"); + } + } + } + + function registerServiceWorker() { + if (!("serviceWorker" in navigator)) { + return; + } + + window.addEventListener("load", () => { + navigator.serviceWorker + .register("/static/sw.js") + .then((registration) => { + console.log("SW registered:", registration); + }) + .catch((error) => { + console.log("SW registration failed:", error); + }); + }); + } + + function registerModalHandlers() { + const modal = document.getElementById("previewModal"); + if (!modal) { + return; + } + + modal.addEventListener("click", (event) => { + if (event.target === modal) { + closePreview(); + } + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !isDialogOpen()) { + closePreview(); + } + }); + } + + async function uploadFiles(files) { + const currentPrefix = document.body.dataset.currentPrefix || ""; + + for (const file of files) { + const formData = new FormData(); + formData.append("file", file); + formData.append("prefix", currentPrefix); + + try { + updateStatus(`正在上传: ${file.name}...`, null); + + const response = await fetch("/upload", { + method: "POST", + body: formData, + }); + + const result = await response.json(); + + if (result.success) { + const statusDiv = updateStatus(`✓ ${file.name} 上传成功!`, "success"); + hideStatusLater(statusDiv); + + setTimeout(() => { + window.location.reload(); + }, 2000); + } else { + updateStatus(`✗ ${file.name} 上传失败: ${result.error}`, "error"); + } + } catch (error) { + updateStatus(`✗ ${file.name} 上传失败: ${error.message}`, "error"); + } + } + } + + async function promptDelete() { + const suggested = ""; + const path = await showPrompt("请输入要删除的文件路径(相对于存储桶),例如:folder/file.jpg", { + title: "删除文件", + defaultValue: suggested, + placeholder: "folder/file.jpg", + confirmLabel: "删除", + }); + + if (path) { + await deleteFile(path); + } + } + + async function deleteFolder(prefix) { + const confirmed = await showConfirm(`确定要删除文件夹 "${prefix}" 及其所有内容吗?此操作不可逆!`, { + title: "删除文件夹", + confirmLabel: "确认删除", + }); + + if (!confirmed) { + return; + } + + updateStatus(`正在删除文件夹: ${prefix}...`, null); + + try { + const response = await fetch(`/delete_folder/${prefix}`, { + method: "DELETE", + }); + + const result = await response.json(); + + if (result.success) { + const statusDiv = updateStatus("✓ 文件夹删除成功!", "success"); + hideStatusLater(statusDiv); + + setTimeout(() => { + // 返回上一级目录 + const parentPath = prefix.split("/").slice(0, -2).join("/"); + window.location.href = parentPath ? `/${parentPath}` : "/"; + }, 1500); + } else { + updateStatus(`✗ 删除失败: ${result.error}`, "error"); + } + } catch (error) { + updateStatus(`✗ 删除失败: ${error.message}`, "error"); + } + } + + async function deleteFile(filePath, options = {}) { + const { skipConfirm = false, suppressReload = false, suppressStatus = false } = options; + + if (!skipConfirm) { + const confirmed = await showConfirm(`确定要删除 "${filePath}" 吗?`, { + title: "删除文件", + confirmLabel: "删除", + }); + + if (!confirmed) { + return false; + } + } + + if (!suppressStatus) { + updateStatus(`正在删除: ${filePath}...`, null); + } + + try { + const response = await fetch(`/delete/${filePath}`, { + method: "DELETE", + }); + + const result = await response.json(); + + if (result.success) { + if (!suppressStatus) { + const statusDiv = updateStatus("✓ 文件删除成功!", "success"); + hideStatusLater(statusDiv); + } + + if (!suppressReload) { + setTimeout(() => { + window.location.reload(); + }, 2000); + } + + return true; + } + + if (!suppressStatus) { + updateStatus(`✗ 删除失败: ${result.error}`, "error"); + } + return false; + } catch (error) { + if (!suppressStatus) { + updateStatus(`✗ 删除失败: ${error.message}`, "error"); + } + return false; + } + } + + function getEntryCheckboxes() { + return document.querySelectorAll(".entry-checkbox"); + } + + function updateSelectAllState() { + const master = document.getElementById("selectAll"); + if (!master) { + return; + } + + const checkboxes = Array.from(getEntryCheckboxes()); + if (checkboxes.length === 0) { + master.checked = false; + master.indeterminate = false; + return; + } + + const checkedCount = checkboxes.filter((checkbox) => checkbox.checked).length; + + if (checkedCount === 0) { + master.checked = false; + master.indeterminate = false; + } else if (checkedCount === checkboxes.length) { + master.checked = true; + master.indeterminate = false; + } else { + master.checked = false; + master.indeterminate = true; + } + } + + function toggleSelectAll(master) { + const checkboxes = getEntryCheckboxes(); + const desiredState = Boolean(master.checked); + + checkboxes.forEach((checkbox) => { + checkbox.checked = desiredState; + }); + + master.indeterminate = false; + updateSelectAllState(); + } + + function attachEntryCheckboxListeners() { + const master = document.getElementById("selectAll"); + if (master && !master.dataset.listenerAttached) { + master.addEventListener("change", () => toggleSelectAll(master)); + master.dataset.listenerAttached = "true"; + } + + getEntryCheckboxes().forEach((checkbox) => { + if (!checkbox.dataset.listenerAttached) { + checkbox.addEventListener("change", updateSelectAllState); + checkbox.dataset.listenerAttached = "true"; + } + }); + + updateSelectAllState(); + } + + 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"; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + const statusDiv = updateStatus(`✓ 开始下载: ${filename || ""}`, "success"); + hideStatusLater(statusDiv); + } + + async function deleteSelectedEntries() { + const selected = Array.from(getEntryCheckboxes()).filter((checkbox) => checkbox.checked); + + if (selected.length === 0) { + const statusDiv = updateStatus("✗ 请先选择要删除的项目", "error"); + hideStatusLater(statusDiv); + return; + } + + const directories = selected.filter((checkbox) => checkbox.dataset.type === "dir"); + if (directories.length > 0) { + const statusDiv = updateStatus("✗ 暂不支持批量删除文件夹,请仅选择文件", "error"); + hideStatusLater(statusDiv); + return; + } + + const files = selected.map((checkbox) => checkbox.value); + const confirmMessage = + files.length === 1 ? `确定要删除 "${files[0]}" 吗?` : `确定要删除选中的 ${files.length} 个文件吗?`; + + const confirmed = await showConfirm(confirmMessage, { + title: "批量删除", + confirmLabel: "删除", + }); + + if (!confirmed) { + return; + } + + const deleteButton = document.getElementById("deleteTrigger"); + if (deleteButton) { + deleteButton.disabled = true; + deleteButton.classList.add("is-disabled"); + } + + const inProgressStatus = updateStatus(`正在删除 ${files.length} 个文件...`, null); + + const failures = []; + let successCount = 0; + + for (const filePath of files) { + // 跳过额外提示和页面刷新,在批量完成后统一处理 + const result = await deleteFile(filePath, { + skipConfirm: true, + suppressReload: true, + suppressStatus: true, + }); + + if (result) { + successCount += 1; + } else { + failures.push(filePath); + } + } + + if (deleteButton) { + deleteButton.disabled = false; + deleteButton.classList.remove("is-disabled"); + } + + if (inProgressStatus) { + inProgressStatus.style.display = "none"; + } + + if (failures.length === 0 && successCount > 0) { + const statusDiv = updateStatus(`✓ 已删除 ${successCount} 个文件`, "success"); + hideStatusLater(statusDiv, 3000); + + setTimeout(() => { + window.location.reload(); + }, 1500); + return; + } + + if (failures.length > 0) { + const message = + failures.length === files.length + ? "✗ 删除失败,请稍后重试" + : `删除部分文件失败:${failures.join(", ")}`; + const statusDiv = updateStatus(message, "error"); + hideStatusLater(statusDiv, 4000); + + if (successCount > 0) { + setTimeout(() => { + window.location.reload(); + }, 1500); + } + } + } + + async function promptRename(oldKey, oldName, isFolder = false) { + const title = isFolder ? "重命名文件夹" : "重命名文件"; + const newName = await showPrompt(`请输入新的名称:`, { + title: title, + defaultValue: oldName, + confirmLabel: "重命名", + }); + + if (newName && newName !== oldName) { + if (isFolder) { + await renameFolder(oldKey, newName); + } else { + await renameFile(oldKey, newName); + } + } + } + + async function renameFile(oldKey, newName) { + updateStatus(`正在重命名: ${oldKey}...`, null); + + try { + const response = await fetch(`/rename/${oldKey}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ newName: newName }), + }); + + const result = await response.json(); + + if (result.success) { + const statusDiv = updateStatus("✓ 文件重命名成功!", "success"); + hideStatusLater(statusDiv); + + setTimeout(() => { + window.location.reload(); + }, 1500); + } else { + updateStatus(`✗ 重命名失败: ${result.error}`, "error"); + } + } catch (error) { + updateStatus(`✗ 重命名失败: ${error.message}`, "error"); + } + } + + async function renameFolder(oldPrefix, newName) { + updateStatus(`正在重命名文件夹: ${oldPrefix}...`, null); + + try { + const response = await fetch(`/rename_folder/${oldPrefix}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ newName: newName }), + }); + + const result = await response.json(); + + if (result.success) { + const statusDiv = updateStatus("✓ 文件夹重命名成功!", "success"); + hideStatusLater(statusDiv); + + setTimeout(() => { + window.location.reload(); + }, 1500); + } else { + updateStatus(`✗ 重命名失败: ${result.error}`, "error"); + } + } catch (error) { + updateStatus(`✗ 重命名失败: ${error.message}`, "error"); + } + } + + async function promptCopyOrMove(source, isFolder, operation) { + const opText = operation === "copy" ? "复制" : "移动"; + const itemText = isFolder ? "文件夹" : "文件"; + const dest = await showPrompt(`请输入目标目录路径:`, { + title: `${opText}${itemText}`, + confirmLabel: opText, + }); + + if (dest) { + // 确保 dest 以 / 结尾 + let normalizedDest = dest; + if (!normalizedDest.endsWith("/")) { + normalizedDest += "/"; + } + // 提取源名称 + let name = source + .split("/") + .filter((p) => p) + .pop(); + if (isFolder) { + name += "/"; + } + const fullDest = normalizedDest + name; + + if (operation === "copy") { + await copyItem(source, fullDest, isFolder); + } else { + await moveItem(source, fullDest, isFolder); + } + } + } + + async function copyItem(source, destination, isFolder) { + updateStatus(`正在复制...`, null); + await performOperation("/copy", "复制", { source, destination, is_folder: isFolder }); + } + + async function moveItem(source, destination, isFolder) { + updateStatus(`正在移动...`, null); + await performOperation("/move", "移动", { source, destination, is_folder: isFolder }); + } + + async function performOperation(endpoint, opText, body) { + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + const result = await response.json(); + + if (result.success) { + const statusDiv = updateStatus(`✓ ${opText}成功!`, "success"); + hideStatusLater(statusDiv); + + setTimeout(() => { + window.location.reload(); + }, 1500); + } else { + updateStatus(`✗ ${opText}失败: ${result.error}`, "error"); + } + } catch (error) { + updateStatus(`✗ ${opText}失败: ${error.message}`, "error"); + } + } + + function getFileType(filename) { + const extension = filename.toLowerCase().split(".").pop(); + const imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "ico"]; + const videoExtensions = ["mp4", "webm", "ogg", "mov", "avi", "mkv", "m4v"]; + + if (imageExtensions.includes(extension)) { + return "image"; + } + if (videoExtensions.includes(extension)) { + return "video"; + } + return "unknown"; + } + + function openPreview(url, filename) { + const modal = document.getElementById("previewModal"); + const container = document.getElementById("previewContainer"); + const info = document.getElementById("previewInfo"); + + if (!modal || !container || !info) { + window.open(url, "_blank"); + return; + } + + const fileType = getFileType(filename); + + if (fileType === "unknown") { + window.open(url, "_blank"); + return; + } + + container.innerHTML = '
加载中...
'; + info.textContent = filename; + modal.classList.add("show"); + document.body.style.overflow = "hidden"; + + setTimeout(() => { + if (fileType === "image") { + const image = document.createElement("img"); + image.className = "preview-content"; + image.src = url; + image.alt = filename; + + image.onload = () => { + container.innerHTML = ""; + container.appendChild(image); + }; + + image.onerror = () => { + container.innerHTML = '
图片加载失败
'; + }; + } else if (fileType === "video") { + const video = document.createElement("video"); + video.className = "preview-content"; + video.src = url; + video.controls = true; + video.autoplay = false; + + video.onloadedmetadata = () => { + container.innerHTML = ""; + container.appendChild(video); + }; + + video.onerror = () => { + container.innerHTML = '
视频加载失败
'; + }; + } + }, 100); + } + + function closePreview() { + const modal = document.getElementById("previewModal"); + const container = document.getElementById("previewContainer"); + + if (!modal || !container) { + return; + } + + modal.classList.remove("show"); + document.body.style.overflow = ""; + + setTimeout(() => { + container.innerHTML = ""; + }, 300); + } + + function downloadPreview() { + const container = document.getElementById("previewContainer"); + const info = document.getElementById("previewInfo"); + + if (!container || !info) { + return; + } + + const media = container.querySelector(".preview-content"); + if (media && media.src) { + downloadFile(media.src, info.textContent); + } + } + + document.addEventListener("DOMContentLoaded", () => { + initDialog(); + initThemeAndView(); + registerModalHandlers(); + registerServiceWorker(); + attachEntryCheckboxListeners(); + }); + + window.uploadFiles = uploadFiles; + window.promptDelete = promptDelete; + window.deleteFile = deleteFile; + window.deleteSelectedEntries = deleteSelectedEntries; + window.toggleSelectAll = (master) => toggleSelectAll(master); + window.downloadFile = downloadFile; + window.openPreview = openPreview; + window.closePreview = closePreview; + window.downloadPreview = downloadPreview; + window.promptRename = promptRename; + window.deleteFolder = deleteFolder; + window.promptCopyOrMove = promptCopyOrMove; + + async function promptCreateFolder() { + const currentPrefix = document.body.dataset.currentPrefix || ""; + const folderName = await showPrompt("请输入新文件夹的名称:", { + title: "新建文件夹", + confirmLabel: "创建", + }); + + if (folderName) { + const path = currentPrefix + folderName; + await createFolder(path); + } + } + + async function createFolder(path) { + updateStatus(`正在创建文件夹: ${path}...`, null); + + try { + const response = await fetch(`/create_folder`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ path: path }), + }); + + const result = await response.json(); + + if (result.success) { + const statusDiv = updateStatus("✓ 文件夹创建成功!", "success"); + hideStatusLater(statusDiv); + + setTimeout(() => { + window.location.reload(); + }, 1500); + } else { + updateStatus(`✗ 创建失败: ${result.error}`, "error"); + } + } catch (error) { + updateStatus(`✗ 创建失败: ${error.message}`, "error"); + } + } + window.promptCreateFolder = promptCreateFolder; +})(); diff --git a/static/sw.js b/static/sw.js deleted file mode 100644 index 3fe78a5..0000000 --- a/static/sw.js +++ /dev/null @@ -1,37 +0,0 @@ -const CACHE_NAME = 'thumbnail-cache-v1'; -const THUMB_URL_PATTERN = /^\/thumb\//; - -self.addEventListener('install', (event) => { - self.skipWaiting(); -}); - -self.addEventListener('activate', (event) => { - event.waitUntil(clients.claim()); -}); - -self.addEventListener('fetch', (event) => { - const { request } = event; - const url = new URL(request.url); - - // 只缓存缩略图请求 - if (THUMB_URL_PATTERN.test(url.pathname)) { - event.respondWith( - caches.open(CACHE_NAME).then((cache) => { - return cache.match(request).then((response) => { - // 如果缓存中有响应,直接返回 - if (response) { - return response; - } - - // 否则发起网络请求,并将响应添加到缓存中 - return fetch(request).then((networkResponse) => { - // 复制响应,因为流只能读取一次 - const responseToCache = networkResponse.clone(); - cache.put(request, responseToCache); - return networkResponse; - }); - }); - }) - ); - } -}); \ No newline at end of file diff --git a/storages/__init__.py b/storages/__init__.py new file mode 100644 index 0000000..0906a7e --- /dev/null +++ b/storages/__init__.py @@ -0,0 +1,6 @@ +from .base import BaseStorage +from .cnbcool import CnbCoolStorage +from .factory import StorageFactory +from .r2 import R2Storage + +__all__ = ["BaseStorage", "R2Storage", "CnbCoolStorage", "StorageFactory"] diff --git a/storages/base.py b/storages/base.py new file mode 100644 index 0000000..f7b0b72 --- /dev/null +++ b/storages/base.py @@ -0,0 +1,210 @@ +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Any, Dict + + +class BaseStorage(ABC): + """存储后端的基类,定义统一接口""" + + @abstractmethod + def list_objects(self, prefix: str = "") -> Dict[str, Any]: + """ + 列出存储桶中的对象 + + Args: + prefix: 对象前缀(用于目录浏览) + + Returns: + 包含对象列表的字典 + """ + pass + + @abstractmethod + def get_object_info(self, key: str) -> Dict[str, Any]: + """ + 获取对象基本信息 + + Args: + key: 对象键名 + + Returns: + 对象元数据 + """ + pass + + @abstractmethod + def get_object(self, key: str) -> Dict[str, Any]: + """ + 获取对象内容 + + Args: + key: 对象键名 + + Returns: + 包含对象内容的字典 + """ + pass + + @abstractmethod + def generate_presigned_url(self, key: str, expires: int = None) -> str: + """ + 为指定对象生成预签名 URL + + Args: + key: 对象键名 + expires: 过期时间(秒) + + Returns: + 预签名 URL,失败返回 None + """ + pass + + @abstractmethod + def get_public_url(self, key: str) -> str: + """ + 生成对象的公共访问 URL + + Args: + key: 对象键名 + + Returns: + 公共 URL,未配置返回 None + """ + pass + + def format_timestamp(self, timestamp) -> str: + """ + 格式化时间戳为人类可读的格式 + + Args: + timestamp: 时间戳对象 + + Returns: + 格式化后的时间字符串 + """ + if isinstance(timestamp, datetime): + return timestamp.strftime("%Y-%m-%d %H:%M:%S") + return str(timestamp) + + @abstractmethod + def generate_thumbnail(self, file_path: str) -> bytes: + """ + 生成图片缩略图 + + Args: + file_path: 文件路径 + + Returns: + 缩略图字节数据 + """ + pass + + @abstractmethod + 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 + """ + pass + + @abstractmethod + def delete_file(self, key: str) -> bool: + """ + 删除存储中的文件 + + Args: + key: 对象键名(文件路径) + + Returns: + 删除成功返回 True,失败返回 False + """ + pass + + @abstractmethod + def rename_file(self, old_key: str, new_key: str) -> bool: + """ + 重命名存储中的文件 + + Args: + old_key: 旧的对象键名 + new_key: 新的对象键名 + + Returns: + 重命名成功返回 True,失败返回 False + """ + pass + + @abstractmethod + def delete_folder(self, prefix: str) -> bool: + """ + 删除存储中的文件夹(前缀) + + Args: + prefix: 要删除的文件夹前缀 + + Returns: + 删除成功返回 True,失败返回 False + """ + pass + + @abstractmethod + def rename_folder(self, old_prefix: str, new_prefix: str) -> bool: + """ + 重命名存储中的文件夹(前缀) + + Args: + old_prefix: 旧的文件夹前缀 + new_prefix: 新的文件夹前缀 + + Returns: + 重命名成功返回 True,失败返回 False + """ + pass + + @abstractmethod + def copy_file(self, source_key: str, dest_key: str) -> bool: + """ + 复制存储中的文件 + + Args: + source_key: 源对象键名 + dest_key: 目标对象键名 + + Returns: + 复制成功返回 True,失败返回 False + """ + pass + + @abstractmethod + def copy_folder(self, source_prefix: str, dest_prefix: str) -> bool: + """ + 复制存储中的文件夹(前缀) + + Args: + source_prefix: 源文件夹前缀 + dest_prefix: 目标文件夹前缀 + + Returns: + 复制成功返回 True,失败返回 False + """ + pass + + @abstractmethod + def create_folder(self, key: str) -> bool: + """ + 创建文件夹 + + Args: + key: 文件夹路径(以 / 结尾) + + Returns: + 创建成功返回 True,失败返回 False + """ + pass diff --git a/storages/factory.py b/storages/factory.py new file mode 100644 index 0000000..b9ad2c5 --- /dev/null +++ b/storages/factory.py @@ -0,0 +1,54 @@ +import os +from typing import Optional + +import dotenv + +from .base import BaseStorage + +# from .cnbcool import CnbCoolStorage +from .r2 import R2Storage + +dotenv.load_dotenv() + + +class StorageFactory: + """存储工厂类,根据配置创建对应的存储实例""" + + _instance: Optional[BaseStorage] = None + + @classmethod + def get_storage(cls) -> BaseStorage: + """ + 获取存储实例(单例模式) + + Returns: + BaseStorage: 存储实例 + + Raises: + RuntimeError: 当存储类型未配置或不支持时 + """ + if cls._instance is not None: + return cls._instance + + storage_type = os.getenv( + "STORAGE_TYPE", + ).lower() + + if storage_type == "r2": + cls._instance = R2Storage() + # 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" + ) + + return cls._instance + + @classmethod + def reset(cls): + """重置单例实例(主要用于测试)""" + cls._instance = None diff --git a/storages/r2.py b/storages/r2.py new file mode 100644 index 0000000..b715d56 --- /dev/null +++ b/storages/r2.py @@ -0,0 +1,324 @@ +import os +from io import BytesIO +from typing import Any, Dict + +import boto3 +import dotenv +from botocore.config import Config +from PIL import Image + +from .base import BaseStorage + +dotenv.load_dotenv() + + +class R2Storage(BaseStorage): + def __init__(self): + self.endpoint = os.getenv("R2_ENDPOINT_URL") + if not self.endpoint: + raise RuntimeError("R2_ENDPOINT_URL environment variable is not set") + + self.access_key = os.getenv("ACCESS_KEY_ID") or os.getenv("ACCESS_KEY_ID") + self.secret_key = os.getenv("SECRET_ACCESS_KEY") or os.getenv( + "SECRET_ACCESS_KEY" + ) + + if not self.access_key or not self.secret_key: + raise RuntimeError("ACCESS_KEY_ID and SECRET_ACCESS_KEY must be set") + + self.region_name = os.getenv("R2_REGION", "auto") + self.bucket_name = os.getenv("R2_BUCKET_NAME") + + def get_s3_client(self): + """ + 创建并返回配置好的 S3 客户端,用于访问 R2 存储 + """ + return boto3.client( + "s3", + endpoint_url=self.endpoint, + aws_access_key_id=self.access_key, + aws_secret_access_key=self.secret_key, + config=Config(signature_version="s3v4"), + region_name=self.region_name, + ) + + def list_objects(self, prefix: str = "") -> Dict[str, Any]: + """ + 列出存储桶中的对象 + """ + s3_client = self.get_s3_client() + + if prefix and not prefix.endswith("/"): + prefix = prefix + "/" + + list_kwargs = {"Bucket": self.bucket_name, "Delimiter": "/"} + if prefix: + list_kwargs["Prefix"] = prefix + + return s3_client.list_objects_v2(**list_kwargs) + + def get_object_info(self, key: str) -> Dict[str, Any]: + """ + 获取对象基本信息 + """ + s3_client = self.get_s3_client() + return s3_client.head_object(Bucket=self.bucket_name, Key=key) + + def get_object(self, key: str) -> Dict[str, Any]: + """ + 获取对象内容 + """ + s3_client = self.get_s3_client() + return s3_client.get_object(Bucket=self.bucket_name, Key=key) + + def generate_presigned_url(self, key: str, expires: int = None) -> str: + """为指定对象生成 presigned URL(GET)。""" + s3_client = self.get_s3_client() + + if expires is None: + try: + expires = int(os.getenv("R2_PRESIGN_EXPIRES", "3600")) + except Exception: + expires = 3600 + + try: + url = s3_client.generate_presigned_url( + "get_object", + Params={"Bucket": self.bucket_name, "Key": key}, + ExpiresIn=expires, + ) + return url + except Exception: + return None + + def get_public_url(self, key: str) -> str: + """ + 生成对象的公共访问 URL + """ + base_url = os.getenv("R2_PUBLIC_URL") + if not base_url: + return None + return f"{base_url.rstrip('/')}/{key}" + + def generate_thumbnail(self, file_path: str) -> bytes: + """ + 生成图片缩略图 + """ + try: + obj = self.get_object(file_path) + data = obj["Body"].read() + + img = Image.open(BytesIO(data)) + img = img.convert("RGB") + img.thumbnail((320, 320)) + buf = BytesIO() + img.save(buf, "JPEG", quality=80, optimize=True) + buf.seek(0) + return buf.getvalue() + except Exception: + raise + + def upload_file(self, key: str, file_data: bytes, content_type: str = None) -> bool: + """ + 上传文件到 R2 存储 + """ + try: + s3_client = self.get_s3_client() + + # 如果没有指定 content_type,尝试根据文件扩展名猜测 + if not content_type: + content_type = self._guess_content_type(key) + + # 上传文件 + s3_client.put_object( + Bucket=self.bucket_name, + Key=key, + Body=file_data, + ContentType=content_type, + ) + return True + except Exception as e: + print(f"Upload failed: {str(e)}") + return False + + def delete_file(self, key: str) -> bool: + """ + 从 R2 存储删除文件 + """ + try: + s3_client = self.get_s3_client() + s3_client.delete_object(Bucket=self.bucket_name, Key=key) + 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: + """ + 重命名 R2 中的对象,通过复制和删除实现 + """ + try: + s3_client = self.get_s3_client() + + # 复制对象到新路径 + copy_source = {"Bucket": self.bucket_name, "Key": old_key} + s3_client.copy_object( + CopySource=copy_source, Bucket=self.bucket_name, Key=new_key + ) + + # 删除原对象 + s3_client.delete_object(Bucket=self.bucket_name, Key=old_key) + + return True + except Exception as e: + print(f"Rename failed: {str(e)}") + return False + + def delete_folder(self, prefix: str) -> bool: + """ + 删除 R2 中整个文件夹(前缀)下的所有对象 + """ + try: + s3_client = self.get_s3_client() + paginator = s3_client.get_paginator("list_objects_v2") + pages = paginator.paginate(Bucket=self.bucket_name, Prefix=prefix) + + objects_to_delete = [] + for page in pages: + if "Contents" in page: + for obj in page["Contents"]: + objects_to_delete.append({"Key": obj["Key"]}) + + if not objects_to_delete: + return True # 文件夹为空,直接返回成功 + + # 分批次删除,S3/R2 一次最多删除 1000 个 + for i in range(0, len(objects_to_delete), 1000): + chunk = objects_to_delete[i : i + 1000] + s3_client.delete_objects( + Bucket=self.bucket_name, Delete={"Objects": chunk} + ) + + return True + except Exception as e: + print(f"Folder delete failed: {str(e)}") + return False + + def rename_folder(self, old_prefix: str, new_prefix: str) -> bool: + """ + 重命名 R2 中的文件夹(前缀),通过复制和删除实现 + """ + try: + s3_client = self.get_s3_client() + paginator = s3_client.get_paginator("list_objects_v2") + pages = paginator.paginate(Bucket=self.bucket_name, Prefix=old_prefix) + + objects_to_rename = [] + for page in pages: + if "Contents" in page: + for obj in page["Contents"]: + objects_to_rename.append(obj["Key"]) + + if not objects_to_rename: + return True # 文件夹为空,直接返回成功 + + for old_key in objects_to_rename: + new_key = old_key.replace(old_prefix, new_prefix, 1) + copy_source = {"Bucket": self.bucket_name, "Key": old_key} + s3_client.copy_object( + CopySource=copy_source, Bucket=self.bucket_name, Key=new_key + ) + + # 删除旧文件夹下的所有对象 + self.delete_folder(old_prefix) + + return True + except Exception as e: + print(f"Folder rename failed: {str(e)}") + return False + + def copy_file(self, source_key: str, dest_key: str) -> bool: + """ + 复制 R2 中的对象 + """ + try: + s3_client = self.get_s3_client() + copy_source = {"Bucket": self.bucket_name, "Key": source_key} + s3_client.copy_object( + CopySource=copy_source, Bucket=self.bucket_name, Key=dest_key + ) + return True + except Exception as e: + print(f"File copy failed: {str(e)}") + return False + + def copy_folder(self, source_prefix: str, dest_prefix: str) -> bool: + """ + 复制 R2 中的文件夹(前缀) + """ + try: + s3_client = self.get_s3_client() + paginator = s3_client.get_paginator("list_objects_v2") + pages = paginator.paginate(Bucket=self.bucket_name, Prefix=source_prefix) + + for page in pages: + if "Contents" in page: + for obj in page["Contents"]: + old_key = obj["Key"] + new_key = old_key.replace(source_prefix, dest_prefix, 1) + copy_source = {"Bucket": self.bucket_name, "Key": old_key} + s3_client.copy_object( + CopySource=copy_source, + Bucket=self.bucket_name, + Key=new_key, + ) + return True + except Exception as e: + print(f"Folder copy failed: {str(e)}") + return False + + def create_folder(self, key: str) -> bool: + """ + 在 R2 中创建文件夹(通过创建一个以 / 结尾的 0 字节对象) + """ + try: + s3_client = self.get_s3_client() + s3_client.put_object(Bucket=self.bucket_name, Key=key, Body=b"") + return True + except Exception as e: + print(f"Folder creation failed: {str(e)}") + return False + + def _guess_content_type(self, filename: str) -> str: + """ + 根据文件扩展名猜测 Content-Type + """ + ext = filename.lower().split(".")[-1] if "." in filename else "" + + content_types = { + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "png": "image/png", + "gif": "image/gif", + "webp": "image/webp", + "svg": "image/svg+xml", + "pdf": "application/pdf", + "html": "text/html", + "css": "text/css", + "js": "application/javascript", + "json": "application/json", + "xml": "application/xml", + "txt": "text/plain", + "md": "text/markdown", + "mp4": "video/mp4", + "webm": "video/webm", + "mp3": "audio/mpeg", + "wav": "audio/wav", + "zip": "application/zip", + "rar": "application/x-rar-compressed", + "7z": "application/x-7z-compressed", + "tar": "application/x-tar", + "gz": "application/gzip", + } + + return content_types.get(ext, "application/octet-stream") diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..249362b --- /dev/null +++ b/templates/base.html @@ -0,0 +1,33 @@ + + + + + + {% block title %}Cloud Index{% endblock %} + + + + {% block head_extra %}{% endblock %} + + + {% block content %}{% endblock %} + + + {% block scripts %}{% endblock %} + + diff --git a/templates/index.html b/templates/index.html index c96f164..1442d08 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,403 +1,262 @@ - - - - - - R2 存储浏览器 - - - - - -
-

- RhenCloud's R2 Drive -
- - -
-

- - - {% if entries %} - - - - - - - - - - - {% for entry in entries %} - - - - - - - {% endfor %} - -
名称大小最后修改时间操作
- {% if entry.is_dir %} - - {{ entry.name }} - {% else %} - - {{ entry.name }} - {% endif %} - - {% if not entry.is_dir %} {{ entry.size|filesizeformat }} {% else %} - {% endif %} - - {% if not entry.is_dir %} {{ entry.last_modified }} {% else %} - {% endif %} - - {% if not entry.is_dir %} - 预览 - {% endif %} -
- -
- {% for entry in entries %} -
+ {% if entries %} + + + + + + + + + + + + {% for entry in entries %} + + + + + + + + {% endfor %} + +
+ + 名称大小最后修改时间操作
+ + {% if entry.is_dir %} - - {{ entry.name }} - {% else %} {% if entry.name|fileicon == 'fas fa-image' %} - - {{ entry.name }} - + + {{ entry.name }} {% else %} - + + {{ entry.name }} {% endif %} - {{ entry.name }} -
{% if not entry.is_dir %} {{ entry.size|filesizeformat }} {% endif %}
+
+ {% if not entry.is_dir %} {{ entry.size|filesizeformat }} {% else %} - {% endif %} + + {% if not entry.is_dir %} {{ entry.last_modified }} {% else %} - {% endif %} + + {% if entry.is_dir %} + + + {% else %} + + + + + + {% endif %} - - {% endfor %} +
+ +
+ {% for entry in entries %} +
+
+ +
+ {% if entry.is_dir %} + + {{ entry.name }} +
+ + + + +
+ {% else %} {% if entry.name|fileicon == 'fas fa-image' %} +
+ {{ entry.name }}
{% else %} -
-

存储桶为空或未找到任何文件

+ + {% endif %} + + {{ entry.name }} + +
{{ entry.size|filesizeformat }}
+
+ + + + + +
{% endif %}
+ {% endfor %} +
+ {% else %} +
+

源存储为空或未找到任何文件

+
+ {% endif %} +
- {% include 'footer.html' %} +{% include 'footer.html' %} - - - +
+
+ + +
+
+
+
+
+
+{% endblock %} diff --git a/vercel.json b/vercel.json index f3fd8d3..976fbff 100644 --- a/vercel.json +++ b/vercel.json @@ -1,13 +1,13 @@ { "version": 2, - "devCommand": "python ./app.py", "builds": [ { "src": "app.py", - "use": "@vercel/python", - "config": { - "pythonVersion": "3.11" - } + "use": "@vercel/python" + }, + { + "src": "/static/**", + "use": "@vercel/static" } ], "routes": [