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
+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