Download app.py from Jaffe2718/manjuOSS: direct link, hf CLI and curl.
- Browser
- Download file 13.5 kB
-
https://huggingface.co/spaces/Jaffe2718/manjuOSS/resolve/main/app.py
- Command line
-
hf download hf://spaces/Jaffe2718/manjuOSS/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Jaffe2718/manjuOSS/resolve/main/app.py
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) | |
| 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, "文件不存在") | |
| 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() | |
| 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} | |
| 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} | |
| 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, | |
| }, | |
| ) | |