feat: public text items, wall, and detail API

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
肉末
2026-07-14 12:43:53 +08:00
parent 4d3cd2ac8b
commit 0e59936e34
8 changed files with 324 additions and 0 deletions
View File
+50
View File
@@ -0,0 +1,50 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from app.db import get_db
from app.schemas import ItemCreate, ItemOut, WallResponse
from app.services import items as items_service
router = APIRouter(prefix="/api/items", tags=["items"])
def _client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client and request.client.host:
return request.client.host
return ""
@router.post("", response_model=ItemOut)
def create_item(
payload: ItemCreate,
request: Request,
db: Session = Depends(get_db),
):
item = items_service.create_text_item(db, payload, _client_ip(request))
return items_service.item_to_dict(item)
@router.get("/wall", response_model=WallResponse)
def wall(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
):
rows, total = items_service.list_wall(db, page=page, page_size=page_size)
return WallResponse(
items=[ItemOut.model_validate(items_service.item_to_dict(i)) for i in rows],
page=page,
page_size=page_size,
total=total,
)
@router.get("/{slug}", response_model=ItemOut)
def get_item(slug: str, db: Session = Depends(get_db)):
item = items_service.get_item(db, slug)
if item is None:
raise HTTPException(status_code=404, detail="Not found")
return items_service.item_to_dict(item)
+2
View File
@@ -2,6 +2,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.api.public import router as public_router
from app.config import settings
from app.db import Base, engine
from app import models # noqa: F401 — register models with Base.metadata
@@ -16,6 +17,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(title="MincedPad", lifespan=lifespan)
app.include_router(public_router)
@app.get("/api/health")
+39
View File
@@ -0,0 +1,39 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
TTLOption = Literal["1h", "24h", "7d", "never"]
class ItemCreate(BaseModel):
body: str = Field(min_length=1)
title: str | None = None
is_public: bool = True
burn_after_read: bool = False
ttl: TTLOption = "24h"
class ItemOut(BaseModel):
slug: str
kind: str
title: str
body: str | None = None
file_name: str | None = None
mime: str | None = None
size_bytes: int | None = None
is_public: bool
burn_after_read: bool
expires_at: datetime | None
created_at: datetime
view_count: int
model_config = {"from_attributes": True}
class WallResponse(BaseModel):
items: list[ItemOut]
page: int
page_size: int
total: int
View File
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
import secrets
from datetime import datetime, timedelta
from pathlib import Path
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.config import settings
from app.models import Item
from app.schemas import ItemCreate, TTLOption
TTL_DELTAS: dict[TTLOption, timedelta | None] = {
"1h": timedelta(hours=1),
"24h": timedelta(days=1),
"7d": timedelta(days=7),
"never": None,
}
def _slug() -> str:
return secrets.token_urlsafe(8)
def _expires_at(ttl: TTLOption, now: datetime | None = None) -> datetime | None:
delta = TTL_DELTAS[ttl]
if delta is None:
return None
return (now or datetime.utcnow()) + delta
def _title_from_body(body: str) -> str:
first = body.split("\n", 1)[0].strip()
return first[:255]
def _is_unavailable(item: Item, now: datetime | None = None) -> bool:
if item.burned:
return True
now = now or datetime.utcnow()
if item.expires_at is not None and item.expires_at <= now:
return True
return False
def item_to_dict(item: Item, *, include_body: bool = True) -> dict:
data = {
"slug": item.slug,
"kind": item.kind,
"title": item.title,
"file_name": item.file_name,
"mime": item.mime,
"size_bytes": item.size_bytes,
"is_public": item.is_public,
"burn_after_read": item.burn_after_read,
"expires_at": item.expires_at,
"created_at": item.created_at,
"view_count": item.view_count,
}
if include_body:
data["body"] = item.body
return data
def create_text_item(db: Session, payload: ItemCreate, created_ip: str) -> Item:
now = datetime.utcnow()
title = payload.title if payload.title is not None else _title_from_body(payload.body)
item = Item(
slug=_slug(),
kind="text",
title=title[:255],
body=payload.body,
is_public=payload.is_public,
burn_after_read=payload.burn_after_read,
expires_at=_expires_at(payload.ttl, now),
created_ip=created_ip,
created_at=now,
)
db.add(item)
db.commit()
db.refresh(item)
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):
return 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
try:
path.unlink(missing_ok=True)
except OSError:
pass
db.commit()
db.refresh(item)
return item
def list_wall(
db: Session,
*,
page: int = 1,
page_size: int = 20,
) -> tuple[list[Item], int]:
page = max(page, 1)
page_size = min(max(page_size, 1), 100)
now = datetime.utcnow()
filters = [
Item.is_public.is_(True),
Item.burned.is_(False),
(Item.expires_at.is_(None) | (Item.expires_at > now)),
]
total = db.scalar(select(func.count()).select_from(Item).where(*filters)) or 0
items = list(
db.scalars(
select(Item)
.where(*filters)
.order_by(Item.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
).all()
)
return items, total