From eda7010cae50eb5b60bd94256699e89e0ac42078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=82=89=E6=9C=AB?= Date: Tue, 14 Jul 2026 12:47:58 +0800 Subject: [PATCH] feat: file upload, allowlist, and download/preview Co-authored-by: Cursor --- backend/app/api/public.py | 54 +++++++++++++++++++++- backend/app/services/items.py | 59 ++++++++++++++++++++++-- backend/app/services/storage.py | 82 +++++++++++++++++++++++++++++++++ backend/tests/test_items.py | 31 +++++++++++++ 4 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 backend/app/services/storage.py diff --git a/backend/app/api/public.py b/backend/app/api/public.py index 9657ba8..7ff8dc7 100644 --- a/backend/app/api/public.py +++ b/backend/app/api/public.py @@ -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) diff --git a/backend/app/services/items.py b/backend/app/services/items.py index 084d879..c0f6843 100644 --- a/backend/app/services/items.py +++ b/backend/app/services/items.py @@ -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: diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py new file mode 100644 index 0000000..c254296 --- /dev/null +++ b/backend/app/services/storage.py @@ -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 diff --git a/backend/tests/test_items.py b/backend/tests/test_items.py index cb606c9..19fff0d 100644 --- a/backend/tests/test_items.py +++ b/backend/tests/test_items.py @@ -68,3 +68,34 @@ def test_title_from_first_line(client): def test_explicit_title(client): r = client.post("/api/items", json={"body": "body text", "title": "Custom"}) assert r.json()["title"] == "Custom" + + +def test_upload_small_file(client): + files = {"file": ("hi.txt", b"abc", "text/plain")} + data = {"is_public": "true", "ttl": "24h"} + r = client.post("/api/items/upload", files=files, data=data) + assert r.status_code == 200 + slug = r.json()["slug"] + f = client.get(f"/api/items/{slug}/file") + assert f.status_code == 200 + assert f.content == b"abc" + + +def test_reject_oversize(client, monkeypatch): + import app.config as config_module + + monkeypatch.setattr(config_module.settings, "max_upload_mb", 1) + big = b"x" * (1 * 1024 * 1024 + 1) + files = {"file": ("big.txt", big, "text/plain")} + r = client.post("/api/items/upload", files=files, data={"is_public": "true", "ttl": "24h"}) + assert r.status_code == 413 + + +def test_reject_disallowed_extension(client): + files = {"file": ("malware.exe", b"mz", "application/octet-stream")} + r = client.post( + "/api/items/upload", + files=files, + data={"is_public": "true", "ttl": "24h"}, + ) + assert r.status_code == 400