manjuOSS / app.py
Jaffe2718's picture
Upload app.py
b719536 verified
Raw History Blame Contribute Delete
13.5 kB
"""Temporary, session-isolated media relay. No provider credentials required."""
import asyncio
import hashlib
import json
import os
from pathlib import Path
import re
import secrets
import time
import spaces
import gradio as gr
from fastapi import FastAPI, File, Header, HTTPException, UploadFile
from fastapi.responses import FileResponse
ROOT = Path(os.environ.get("MANJU_STORAGE_DIR", "./runtime")).resolve()
ROOT.mkdir(parents=True, exist_ok=True)
MAX_FILE = 300 * 1024 * 1024
MAX_TOTAL = 10 * 1024 * 1024 * 1024
MAX_SESSION = 1024 * 1024 * 1024
MAX_FILES = 100
PUBLIC_BASE = os.environ.get("MANJU_PUBLIC_BASE", "https://jaffe2718-manjuoss.hf.space").rstrip("/")
LOCK = asyncio.Lock()
EXTENSIONS = {".mp4", ".avi", ".mov", ".wav", ".mp3", ".aac", ".jpg", ".jpeg", ".png", ".bmp", ".webp"}
app = FastAPI(title="manjuOSS", docs_url="/docs", redoc_url=None)
@spaces.GPU(duration=10)
def gpu_self_check():
"""Optional manual CUDA check; never called by storage or startup."""
import torch
value = torch.tensor([1.0, 2.0, 3.0], device="cuda").sum().item()
return {"device": torch.cuda.get_device_name(0), "sum": value, "ok": value == 6.0}
def owner(session: str | None) -> str:
if not session or not re.fullmatch(r"[A-Za-z0-9_-]{32,256}", session):
raise HTTPException(401, "需要本地随机生成、至少32字符的 X-Session-ID")
return hashlib.sha256(session.encode()).hexdigest()
def records():
return [json.loads(p.read_text(encoding="utf-8")) for p in ROOT.glob("*.json")]
def visible(record):
return {k: v for k, v in record.items() if k != "owner"}
def validate_id(file_id):
if not re.fullmatch(r"[0-9a-f]{64}\.[a-z0-9]{2,4}", file_id):
raise HTTPException(404, "文件不存在")
@app.post("/api/upload", status_code=201)
async def upload(file: UploadFile = File(...), x_session_id: str | None = Header(default=None)):
session_owner = owner(x_session_id)
name = (file.filename or "file").replace("\\", "/").split("/")[-1][:200]
extension = Path(name).suffix.lower()
if extension not in EXTENSIONS:
raise HTTPException(415, "仅接受支持的音频、视频及图片扩展名")
salt = secrets.token_bytes(32)
digest = hashlib.sha256(salt)
content_digest = hashlib.sha256()
staging = ROOT / ("upload-" + secrets.token_hex(24) + ".part")
try:
async with LOCK:
all_records = records()
own = [r for r in all_records if r["owner"] == session_owner]
if len(own) >= MAX_FILES:
raise HTTPException(413, "会话文件数达到上限")
available = min(MAX_TOTAL - sum(r["size"] for r in all_records), MAX_SESSION - sum(r["size"] for r in own))
size = 0
with staging.open("xb") as target:
while chunk := await file.read(1024 * 1024):
size += len(chunk)
if size > min(MAX_FILE, available):
raise HTTPException(413, "文件或存储配额超限")
digest.update(chunk)
content_digest.update(chunk)
target.write(chunk)
if not size:
raise HTTPException(400, "不能上传空文件")
file_id = digest.hexdigest() + extension
record = {"id": file_id, "name": name, "size": size, "sha256": content_digest.hexdigest(), "created_at": int(time.time()), "owner": session_owner, "url": f"{PUBLIC_BASE}/files/{file_id}"}
destination = ROOT / file_id
staging.replace(destination)
metadata = ROOT / (file_id + ".json")
try:
metadata.write_text(json.dumps(record, ensure_ascii=False), encoding="utf-8")
except BaseException:
destination.unlink(missing_ok=True)
metadata.unlink(missing_ok=True)
raise
return visible(record)
finally:
staging.unlink(missing_ok=True)
await file.close()
@app.get("/api/list")
async def list_files(x_session_id: str | None = Header(default=None)):
session_owner = owner(x_session_id)
async with LOCK:
items = sorted((visible(r) for r in records() if r["owner"] == session_owner), key=lambda r: (r["created_at"], r["id"]))
return {"files": items, "count": len(items), "server_time": int(time.time()), "persistent": False}
@app.delete("/api/files/{file_id}")
async def delete_file(file_id: str, x_session_id: str | None = Header(default=None)):
session_owner = owner(x_session_id)
validate_id(file_id)
async with LOCK:
metadata = ROOT / (file_id + ".json")
if not metadata.is_file() or json.loads(metadata.read_text(encoding="utf-8"))["owner"] != session_owner:
raise HTTPException(404, "文件不存在")
(ROOT / file_id).unlink(missing_ok=True)
metadata.unlink()
return {"deleted": file_id}
@app.api_route("/files/{file_id}", methods=["GET", "HEAD"])
async def download(file_id: str):
validate_id(file_id)
path = ROOT / file_id
if not path.is_file() or not (ROOT / (file_id + ".json")).is_file():
raise HTTPException(404, "文件不存在")
return FileResponse(path, headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer"})
MANAGER_HTML = """
<section id="manju-manager" style="max-width:1000px;margin:auto;padding:16px">
<h1>manjuOSS · 文件中转</h1>
<p><a href="/docs" target="_blank" rel="noopener noreferrer">文件接口文档(上传 / 列表 / 删除)</a></p>
<p>同一个 sessionID 共享同一组文件,可上传、查看和删除。请只与协作者共享它。</p>
<label for="manju-session">sessionID</label>
<input id="manju-session" type="password" autocomplete="off" spellcheck="false"
placeholder="粘贴已有 sessionID,或点击生成新会话" style="width:100%;padding:12px;margin:8px 0;border:1px solid #999;border-radius:6px">
<div style="display:flex;gap:12px;flex-wrap:wrap;margin:12px 0">
<button id="manju-new" type="button">生成新会话</button>
<button id="manju-copy" type="button">复制 sessionID</button>
<button id="manju-refresh" type="button">加载 / 刷新文件</button>
</div>
<p>刷新页面后需重新输入 sessionID,请自行保存。生成新会话不会删除旧会话文件。</p>
<label for="manju-files">上传文件(可多选,单个最多 300 MiB)</label>
<input id="manju-files" type="file" multiple
accept=".mp4,.avi,.mov,.wav,.mp3,.aac,.jpg,.jpeg,.png,.bmp,.webp" style="display:block;margin:12px 0">
<button id="manju-upload" type="button">上传到当前会话</button>
<p id="manju-status" role="status" aria-live="polite" style="margin:16px 0">输入 sessionID 后加载文件。</p>
<div style="overflow-x:auto">
<table style="width:100%;text-align:left;border-collapse:collapse">
<thead><tr><th>文件名</th><th>大小</th><th>上传时间</th><th>访问 / 删除</th></tr></thead>
<tbody id="manju-list"></tbody>
</table>
</div>
<p style="margin-top:20px">文件链接可直接用于阿里取材,持有链接的人可读取文件。任务处理结束后再删除。临时文件可能因实例重启丢失,请保留本地原件。</p>
</section>
"""
MANAGER_JS = r"""() => {
const root = document.getElementById('manju-manager');
if (!root || root.dataset.bound) return;
root.dataset.bound = 'true';
const el = id => document.getElementById('manju-' + id);
const status = text => { el('status').textContent = text; };
const session = () => {
const value = el('session').value.trim();
if (!/^[A-Za-z0-9_-]{32,256}$/.test(value)) throw new Error('请输入有效的 sessionID:32–256 位字母、数字、下划线或短横线。');
return value;
};
let busy = false;
async function action(fn) {
if (busy) return;
busy = true;
root.querySelectorAll('button,input').forEach(x => x.disabled = true);
try { await fn(); } catch (e) { status(e.message || '操作失败,请稍后重试。'); }
finally {
busy = false;
root.querySelectorAll('button,input').forEach(x => x.disabled = false);
}
}
async function request(path, sid, options = {}) {
const response = await fetch(path, {...options, headers: {'X-Session-ID': sid}, cache: 'no-store'});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : '服务暂不可用,请检查运行状态。');
return data;
}
async function refresh(sid) {
const data = await request('/api/list', sid);
el('list').replaceChildren();
for (const file of data.files) {
const row = document.createElement('tr');
for (const value of [file.name, (file.size / 1048576).toFixed(2) + ' MiB', new Date(file.created_at * 1000).toLocaleString()]) {
const cell = document.createElement('td'); cell.textContent = value; cell.style.padding = '10px 6px'; row.append(cell);
}
const cell = document.createElement('td');
const link = document.createElement('a');
link.textContent = '打开 / 下载'; link.href = file.url; link.target = '_blank'; link.rel = 'noopener noreferrer';
const copy = document.createElement('button'); copy.type = 'button'; copy.textContent = '复制链接';
copy.onclick = () => action(async () => { await navigator.clipboard.writeText(file.url); status('文件链接已复制。'); });
const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = '删除';
remove.onclick = () => action(async () => {
if (session() !== sid) throw new Error('会话已变更,请先重新加载文件。');
await request('/api/files/' + encodeURIComponent(file.id), sid, {method: 'DELETE'});
await refresh(sid); status('已删除:' + file.name);
});
cell.append(link, document.createTextNode(' '), copy, document.createTextNode(' '), remove);
row.append(cell); el('list').append(row);
}
status(data.count ? '当前会话共有 ' + data.count + ' 个文件。' : '当前会话暂无文件。');
}
el('session').addEventListener('input', () => { el('list').replaceChildren(); status('会话已变更,请点击加载 / 刷新文件。'); });
el('new').onclick = () => {
const bytes = crypto.getRandomValues(new Uint8Array(32));
el('session').value = Array.from(bytes, x => x.toString(16).padStart(2, '0')).join('');
el('list').replaceChildren(); status('新会话已生成,请复制并保存 sessionID。');
};
el('copy').onclick = () => action(async () => { await navigator.clipboard.writeText(session()); status('sessionID 已复制,请妥善保存或交给协作者。'); });
el('refresh').onclick = () => action(async () => { status('正在加载文件…'); await refresh(session()); });
el('upload').onclick = () => action(async () => {
const sid = session(), files = Array.from(el('files').files);
if (!files.length) throw new Error('请先选择要上传的文件。');
let uploaded = 0;
for (const file of files) {
if (file.size > 300 * 1048576) throw new Error('文件超过 300 MiB:' + file.name + ';此前已完成 ' + uploaded + ' 个文件,可刷新查看。');
status('正在上传 ' + (uploaded + 1) + '/' + files.length + ':' + file.name);
const form = new FormData(); form.append('file', file);
try { await request('/api/upload', sid, {method: 'POST', body: form}); }
catch (error) { await refresh(sid).catch(() => {}); throw new Error(error.message + ';此次已完成 ' + uploaded + ' 个文件,请查看列表后再重试。'); }
uploaded++;
}
el('files').value = ''; await refresh(sid); status('上传完成,当前会话中的文件已更新。');
});
}"""
MANAGER_CSS = """
#manju-manager button {padding:9px 13px;border:1px solid #b7c4d5;border-radius:6px;background:#f1f5fa;color:#163c69;cursor:pointer;margin:3px 2px;line-height:1.4}
#manju-manager button:hover {background:#e0ebf8}
#manju-manager button:disabled {opacity:.55;cursor:wait}
#manju-manager button:focus-visible,#manju-manager input:focus-visible {outline:2px solid #2563eb;outline-offset:2px}
#manju-manager #manju-upload {background:#2563eb;color:white;border-color:#2563eb}
#manju-manager th {padding:12px 6px;background:#f1f5fa}
#manju-manager td {border-bottom:1px solid #d5dde8}
#manju-manager a {color:#1d4ed8;text-decoration:underline}
#manju-manager p {margin:12px 0;line-height:1.6}
"""
with gr.Blocks(js=MANAGER_JS, css=MANAGER_CSS) as ui:
gr.HTML(MANAGER_HTML)
with gr.Accordion("可选 GPU 自检(文件管理无需运行)", open=False):
gr.Markdown("仅点击时申请 GPU 并消耗相应额度。")
check_button = gr.Button("运行 GPU 自检(可选)")
check_result = gr.JSON(label="GPU 自检结果")
check_button.click(gpu_self_check, inputs=[], outputs=check_result, api_name=False)
ui.queue()
if __name__ == "__main__":
# spaces 0.51.3 attaches its startup report to Blocks.launch.
# Preserve REST routes ahead of Gradio's routes on that same server.
ui.launch(
server_name="0.0.0.0", server_port=7860, ssr_mode=False, show_api=False,
app_kwargs={
"routes": list(app.router.routes),
"docs_url": None, "redoc_url": None, "openapi_url": None,
},
)