feat: file upload, allowlist, and download/preview

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
肉末
2026-07-14 12:47:58 +08:00
parent 0e59936e34
commit eda7010cae
4 changed files with 219 additions and 7 deletions
+52 -2
View File
@@ -1,12 +1,16 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.db import get_db
from app.schemas import ItemCreate, ItemOut, WallResponse
from app.schemas import ItemCreate, ItemOut, TTLOption, WallResponse
from app.services import items as items_service
from app.services import storage as storage_service
router = APIRouter(prefix="/api/items", tags=["items"])
_INLINE_IMAGE_PREFIXES = ("image/",)
def _client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
@@ -27,6 +31,32 @@ def create_item(
return items_service.item_to_dict(item)
@router.post("/upload", response_model=ItemOut)
async def upload_item(
request: Request,
db: Session = Depends(get_db),
file: UploadFile = File(...),
is_public: bool = Form(True),
burn_after_read: bool = Form(False),
ttl: TTLOption = Form("24h"),
title: str | None = Form(None),
):
rel_path, size, mime, safe_name = await storage_service.save_upload(file)
item = items_service.create_file_item(
db,
file_name=safe_name,
file_path=rel_path,
mime=mime,
size_bytes=size,
is_public=is_public,
burn_after_read=burn_after_read,
ttl=ttl,
created_ip=_client_ip(request),
title=title,
)
return items_service.item_to_dict(item)
@router.get("/wall", response_model=WallResponse)
def wall(
page: int = Query(1, ge=1),
@@ -42,6 +72,26 @@ def wall(
)
@router.get("/{slug}/file")
def download_file(slug: str, db: Session = Depends(get_db)):
item = items_service.get_file_item(db, slug)
if item is None:
raise HTTPException(status_code=404, detail="Not found")
path = items_service.resolve_file_path(item)
if path is None or not path.is_file():
raise HTTPException(status_code=404, detail="Not found")
media_type = item.mime or "application/octet-stream"
filename = item.file_name or path.name
inline = media_type.startswith(_INLINE_IMAGE_PREFIXES)
return FileResponse(
path,
media_type=media_type,
filename=filename,
content_disposition_type="inline" if inline else "attachment",
)
@router.get("/{slug}", response_model=ItemOut)
def get_item(slug: str, db: Session = Depends(get_db)):
item = items_service.get_item(db, slug)
+54 -5
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.config import settings
from app import config
from app.models import Item
from app.schemas import ItemCreate, TTLOption
@@ -83,6 +83,57 @@ def create_text_item(db: Session, payload: ItemCreate, created_ip: str) -> Item:
return item
def create_file_item(
db: Session,
*,
file_name: str,
file_path: str,
mime: str,
size_bytes: int,
is_public: bool,
burn_after_read: bool,
ttl: TTLOption,
created_ip: str,
title: str | None = None,
) -> Item:
now = datetime.utcnow()
item = Item(
slug=_slug(),
kind="file",
title=(title or file_name)[:255],
body=None,
file_name=file_name,
file_path=file_path,
mime=mime,
size_bytes=size_bytes,
is_public=is_public,
burn_after_read=burn_after_read,
expires_at=_expires_at(ttl, now),
created_ip=created_ip,
created_at=now,
)
db.add(item)
db.commit()
db.refresh(item)
return item
def resolve_file_path(item: Item) -> Path | None:
if not item.file_path:
return None
path = Path(item.file_path)
if not path.is_absolute():
path = config.settings.data_dir / path
return path
def get_file_item(db: Session, slug: str) -> Item | None:
item = db.scalar(select(Item).where(Item.slug == slug))
if item is None or item.kind != "file" or _is_unavailable(item):
return None
return item
def get_item(db: Session, slug: str) -> Item | None:
item = db.scalar(select(Item).where(Item.slug == slug))
if item is None or _is_unavailable(item):
@@ -91,10 +142,8 @@ def get_item(db: Session, slug: str) -> Item | None:
item.view_count += 1
if item.burn_after_read:
item.burned = True
if item.file_path:
path = Path(item.file_path)
if not path.is_absolute():
path = settings.data_dir / path
path = resolve_file_path(item)
if path is not None:
try:
path.unlink(missing_ok=True)
except OSError:
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import re
import uuid
from pathlib import Path
from fastapi import HTTPException, UploadFile
from app import config
ALLOWED_EXTENSIONS = frozenset(
{
"png",
"jpg",
"jpeg",
"gif",
"webp",
"svg",
"txt",
"md",
"pdf",
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
"csv",
"zip",
"7z",
"tar",
"gz",
"rar",
"json",
}
)
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
def _safe_original_name(filename: str | None) -> str:
name = Path(filename or "file").name
name = _SAFE_NAME_RE.sub("_", name).strip("._") or "file"
return name[:255]
def _extension(filename: str) -> str:
return Path(filename).suffix.lstrip(".").lower()
async def save_upload(file: UploadFile) -> tuple[str, int, str, str]:
original = _safe_original_name(file.filename)
ext = _extension(original)
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail="File type not allowed")
max_bytes = config.settings.max_upload_mb * 1024 * 1024
config.settings.uploads_dir.mkdir(parents=True, exist_ok=True)
stored_name = f"{uuid.uuid4().hex}.{ext}"
dest = config.settings.uploads_dir / stored_name
size = 0
try:
with dest.open("wb") as out:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
size += len(chunk)
if size > max_bytes:
raise HTTPException(status_code=413, detail="File too large")
out.write(chunk)
except HTTPException:
dest.unlink(missing_ok=True)
raise
except Exception:
dest.unlink(missing_ok=True)
raise
mime = file.content_type or "application/octet-stream"
rel_path = f"uploads/{stored_name}"
return rel_path, size, mime, original