mirror of
https://github.com/RhenCloud/Cloud-Index.git
synced 2025-12-06 15:26:10 +08:00
init project
This commit is contained in:
10
.env.example
Normal file
10
.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
ACCESS_KEY_ID=7bd97fe81b9b0f7cd68a745a4eb3f19f
|
||||
SECRET_ACCESS_KEY=dbb41f793f3ae8ae90fb60342ca09716596df3cc6c42d602215c550d3579b3f8
|
||||
R2_BUCKET_NAME=drive
|
||||
R2_ENDPOINT_URL=https://731b927733dccabbd9210b33c3ee513e.r2.cloudflarestorage.com
|
||||
R2_REGION=auto
|
||||
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# 可选: 公共访问 URL
|
||||
R2_PUBLIC_URL=https://pub-<bucket-name>.r2.dev
|
||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# flask api
|
||||
*.pyc
|
||||
__pycache__/
|
||||
|
||||
instance/
|
||||
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
*.log
|
||||
*.pyc
|
||||
__pycache__
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.vercel
|
||||
|
||||
.vercel
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["humao.rest-client"]
|
||||
}
|
||||
10
.vscode/settings.json
vendored
Normal file
10
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"python.defaultInterpreterPath": ".venv/bin/python",
|
||||
"python.linting.enabled": true,
|
||||
"python.linting.ruffEnabled": true,
|
||||
"python.formatting.provider": "ruff",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
}
|
||||
420
app.py
Normal file
420
app.py
Normal file
@@ -0,0 +1,420 @@
|
||||
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
|
||||
|
||||
# 加载环境变量
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__, static_url_path="/static", static_folder="static")
|
||||
|
||||
|
||||
# 缩略图默认 TTL(秒),可通过环境变量覆盖
|
||||
THUMB_TTL = int(os.getenv("THUMB_TTL_SECONDS", "3600"))
|
||||
|
||||
|
||||
# 注册一个安全的 filesizeformat 过滤器,处理 None 和非数字值
|
||||
@app.template_filter("filesizeformat")
|
||||
def filesizeformat_filter(value):
|
||||
try:
|
||||
if value is None:
|
||||
return "-"
|
||||
num = float(value) # 使用 float 而不是 int 以保持精度
|
||||
except Exception:
|
||||
return "-"
|
||||
|
||||
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
||||
if num < 1024:
|
||||
# 对于字节,显示整数
|
||||
if unit == "B":
|
||||
return f"{int(num)}{unit}"
|
||||
# 其他单位保留两位小数
|
||||
return f"{num:.2f}{unit}"
|
||||
num = num / 1024.0
|
||||
return f"{num:.2f}PB"
|
||||
|
||||
|
||||
# 注册一个文件图标过滤器
|
||||
@app.template_filter("fileicon")
|
||||
def fileicon_filter(filename):
|
||||
if not filename:
|
||||
return "fas fa-file"
|
||||
|
||||
ext = filename.lower().split(".")[-1] if "." in filename else ""
|
||||
|
||||
# 图片文件
|
||||
if ext in ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"]:
|
||||
return "fas fa-image"
|
||||
|
||||
# 音频文件
|
||||
if ext in ["mp3", "wav", "ogg", "flac", "m4a", "aac"]:
|
||||
return "fas fa-music"
|
||||
|
||||
# 视频文件
|
||||
if ext in ["mp4", "webm", "avi", "mov", "wmv", "flv", "mkv"]:
|
||||
return "fas fa-video"
|
||||
|
||||
# 文档文件
|
||||
if ext in ["pdf", "doc", "docx", "txt", "md", "rtf"]:
|
||||
return "fas fa-file-alt"
|
||||
|
||||
# 压缩文件
|
||||
if ext in ["zip", "rar", "7z", "tar", "gz"]:
|
||||
return "fas fa-file-archive"
|
||||
|
||||
# 代码文件
|
||||
if ext in ["py", "js", "html", "css", "java", "cpp", "c", "php"]:
|
||||
return "fas fa-file-code"
|
||||
|
||||
# 表格文件
|
||||
if ext in ["xls", "xlsx", "csv"]:
|
||||
return "fas fa-file-excel"
|
||||
|
||||
# 演示文件
|
||||
if ext in ["ppt", "pptx"]:
|
||||
return "fas fa-file-powerpoint"
|
||||
|
||||
# 默认文件图标
|
||||
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}"
|
||||
|
||||
|
||||
def format_timestamp(timestamp) -> str:
|
||||
"""
|
||||
格式化时间戳为人类可读的格式
|
||||
"""
|
||||
if isinstance(timestamp, datetime):
|
||||
return timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return str(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("/<path:prefix_path>")
|
||||
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/<path:file_path>")
|
||||
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)
|
||||
|
||||
|
||||
def get_file_url(key: str) -> str:
|
||||
"""生成通过服务器访问文件的 URL"""
|
||||
return f"/file/{key}"
|
||||
|
||||
|
||||
@app.route("/thumb/<path:file_path>")
|
||||
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")
|
||||
app.run(host=host, port=port, debug=True)
|
||||
40
debug/apitest.http
Normal file
40
debug/apitest.http
Normal file
@@ -0,0 +1,40 @@
|
||||
# "REST Client" from Exensions
|
||||
# 记得安装 “ REST Client ” 插件
|
||||
|
||||
# @baseUri=http://localhost:3000
|
||||
@baseUri=http://127.0.0.1:5000
|
||||
|
||||
### root - app
|
||||
GET {{baseUri}}/
|
||||
|
||||
### get - app
|
||||
GET {{baseUri}}/get
|
||||
|
||||
### post - app
|
||||
POST {{baseUri}}/post
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"msg": "hello? couly you get it?"
|
||||
}
|
||||
|
||||
|
||||
### double get - app
|
||||
GET {{baseUri}}/double
|
||||
|
||||
### double post - app
|
||||
POST {{baseUri}}/double
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"msg": "a post for doule"
|
||||
}
|
||||
|
||||
### Blueprint - blueprint
|
||||
GET {{baseUri}}/blueprint/
|
||||
|
||||
### Blueprint - g - blueprint
|
||||
GET {{baseUri}}/blueprint/g
|
||||
|
||||
### lib
|
||||
GET {{baseUri}}/lib
|
||||
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"engines": {
|
||||
"node": "22.x"
|
||||
}
|
||||
}
|
||||
42
pyproject.toml
Normal file
42
pyproject.toml
Normal file
@@ -0,0 +1,42 @@
|
||||
[project]
|
||||
name = "musictool"
|
||||
version = "0.1.0"
|
||||
description = "A music tool API built with FastAPI"
|
||||
authors = [{ name = "RhenCloud", email = "i@rhen.cloud" }]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"flask>=3.1.2",
|
||||
"uvicorn[standard]>=0.38.0",
|
||||
"pydantic>=2.12.3",
|
||||
"requests>=2.32.5",
|
||||
"boto3>=1.40.64",
|
||||
"dotenv>=0.9.9",
|
||||
"pillow>=11.3.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["ruff>=0.1.6"]
|
||||
|
||||
# 添加运行脚本配置
|
||||
# [project.scripts]
|
||||
# start = "flask run api:app"
|
||||
# dev = "ruff run --watch api/app.py"
|
||||
|
||||
# [build-system]
|
||||
# requires = ["setuptools>=45", "wheel"]
|
||||
# build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"C", # flake8-comprehensions
|
||||
"B", # flake8-bugbear
|
||||
]
|
||||
extend-ignore = [
|
||||
"E501", # line too long, handled by black
|
||||
]
|
||||
1
runtime.txt
Normal file
1
runtime.txt
Normal file
@@ -0,0 +1 @@
|
||||
python-3.11.4
|
||||
7
static/favicon.svg
Normal file
7
static/favicon.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 640 640">
|
||||
<!-- Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License -
|
||||
https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc. -->
|
||||
<path fill="#4FC3F7"
|
||||
d="M32 400C32 479.5 96.5 544 176 544L480 544C550.7 544 608 486.7 608 416C608 364.4 577.5 319.9 533.5 299.7C540.2 286.6 544 271.7 544 256C544 203 501 160 448 160C430.3 160 413.8 164.8 399.6 173.1C375.5 127.3 327.4 96 272 96C192.5 96 128 160.5 128 240C128 248 128.7 255.9 129.9 263.5C73 282.7 32 336.6 32 400z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 644 B |
37
static/sw.js
Normal file
37
static/sw.js
Normal file
@@ -0,0 +1,37 @@
|
||||
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;
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
7
static/thumb_placeholder.svg
Normal file
7
static/thumb_placeholder.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180">
|
||||
<rect width="320" height="180" fill="#e9ecef" />
|
||||
<g fill="#adb5bd" font-family="Arial, Helvetica, sans-serif" font-size="14">
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle">No preview</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 378 B |
9
templates/footer.html
Normal file
9
templates/footer.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<footer class="site-footer">
|
||||
<div class="container-footer">
|
||||
<span>© {{ current_year if current_year else '2025' }} RhenCloud</span>
|
||||
<span>·</span>
|
||||
<a href="https://github.com/RhenCloud/R2-Index" target="_blank" rel="noopener">GitHub</a>
|
||||
<span>·</span>
|
||||
<a href="mailto:i@rhen.cloud">联系</a>
|
||||
</div>
|
||||
</footer>
|
||||
403
templates/index.html
Normal file
403
templates/index.html
Normal file
@@ -0,0 +1,403 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>R2 存储浏览器</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" />
|
||||
<style>
|
||||
: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;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--secondary-text);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
.view-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--secondary-text);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 1.2em;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.view-toggle:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
/* Grid view styles */
|
||||
.grid-container {
|
||||
display: none; /* 默认隐藏,只有 data-view="grid" 时显示 */
|
||||
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-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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>
|
||||
RhenCloud's R2 Drive
|
||||
<div>
|
||||
<button class="theme-toggle" aria-label="切换深色模式">
|
||||
<i class="fas fa-moon"></i>
|
||||
</button>
|
||||
<button class="view-toggle" aria-label="切换视图">
|
||||
<i class="fas fa-th-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
</h1>
|
||||
<div class="breadcrumb">
|
||||
<a href="/">Home</a>
|
||||
{% if crumbs %} / {% for c in crumbs %}
|
||||
<a href="/{{ c.prefix.rstrip('/') }}">{{ c.name }}</a>{% if not loop.last %} / {% endif %} {% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if entries %}
|
||||
<table class="files-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>大小</th>
|
||||
<th>最后修改时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if entry.is_dir %}
|
||||
<i class="file-icon folder fas fa-folder"></i>
|
||||
<a href="/{{ entry.key.rstrip('/') }}">{{ entry.name }}</a>
|
||||
{% else %}
|
||||
<i class="file-icon file {{ entry.name|fileicon }}"></i>
|
||||
<a href="{{ entry.file_url }}" target="_blank">{{ entry.name }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="file-size">
|
||||
{% if not entry.is_dir %} {{ entry.size|filesizeformat }} {% else %} - {% endif %}
|
||||
</td>
|
||||
<td class="last-modified">
|
||||
{% if not entry.is_dir %} {{ entry.last_modified }} {% else %} - {% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not entry.is_dir %}
|
||||
<a href="{{ entry.file_url }}" target="_blank">预览</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="grid-container" id="gridContainer">
|
||||
{% for entry in entries %}
|
||||
<div class="grid-card">
|
||||
{% if entry.is_dir %}
|
||||
<i class="fas fa-folder fa-2x" style="color: var(--folder-color)"></i>
|
||||
<a class="grid-name" href="/{{ entry.key.rstrip('/') }}">{{ entry.name }}</a>
|
||||
{% else %} {% if entry.name|fileicon == 'fas fa-image' %}
|
||||
<a href="{{ entry.file_url }}" target="_blank">
|
||||
<img class="grid-thumb" src="{{ entry.file_url }}" alt="{{ entry.name }}" />
|
||||
</a>
|
||||
{% else %}
|
||||
<i class="{{ entry.name|fileicon }} fa-2x" style="color: var(--file-color)"></i>
|
||||
{% endif %}
|
||||
<a class="grid-name" href="{{ entry.file_url }}" target="_blank">{{ entry.name }}</a>
|
||||
<div class="file-size">{% if not entry.is_dir %} {{ entry.size|filesizeformat }} {% endif %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-message">
|
||||
<p>存储桶为空或未找到任何文件</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% include 'footer.html' %}
|
||||
|
||||
<script>
|
||||
// 主题切换功能
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const themeToggle = document.querySelector(".theme-toggle");
|
||||
const themeIcon = themeToggle.querySelector("i");
|
||||
|
||||
// 检查用户之前的主题偏好
|
||||
const savedTheme = localStorage.getItem("theme");
|
||||
if (savedTheme === "dark") {
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
themeIcon.classList.remove("fa-moon");
|
||||
themeIcon.classList.add("fa-sun");
|
||||
}
|
||||
|
||||
// 视图切换(列表 / 网格)
|
||||
const viewToggle = document.querySelector(".view-toggle");
|
||||
const viewIcon = viewToggle.querySelector("i");
|
||||
|
||||
// 初始化视图(从 localStorage 或默认 list)
|
||||
const savedView = localStorage.getItem("view") || "list";
|
||||
document.documentElement.setAttribute("data-view", savedView);
|
||||
if (savedView === "grid") {
|
||||
viewIcon.classList.remove("fa-th-list");
|
||||
viewIcon.classList.add("fa-th-large");
|
||||
} else {
|
||||
viewIcon.classList.remove("fa-th-large");
|
||||
viewIcon.classList.add("fa-th-list");
|
||||
}
|
||||
|
||||
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 (next === "grid") {
|
||||
viewIcon.classList.remove("fa-th-list");
|
||||
viewIcon.classList.add("fa-th-large");
|
||||
} else {
|
||||
viewIcon.classList.remove("fa-th-large");
|
||||
viewIcon.classList.add("fa-th-list");
|
||||
}
|
||||
});
|
||||
|
||||
// 切换主题
|
||||
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 (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");
|
||||
themeIcon.classList.remove("fa-moon");
|
||||
themeIcon.classList.add("fa-sun");
|
||||
localStorage.setItem("theme", "dark");
|
||||
}
|
||||
}
|
||||
|
||||
// 注册 Service Worker 来处理缩略图缓存
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker.register('/static/sw.js')
|
||||
.then(function(registration) {
|
||||
console.log('SW registered: ', registration);
|
||||
})
|
||||
.catch(function(registrationError) {
|
||||
console.log('SW registration failed: ', registrationError);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
19
vercel.json
Normal file
19
vercel.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"version": 2,
|
||||
"devCommand": "python ./app.py",
|
||||
"builds": [
|
||||
{
|
||||
"src": "app.py",
|
||||
"use": "@vercel/python",
|
||||
"config": {
|
||||
"pythonVersion": "3.11"
|
||||
}
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"src": "/(.*)",
|
||||
"dest": "app.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user