From 4d3cd2ac8b8a86f8451701d92053deac630ba032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=82=89=E6=9C=AB?= Date: Tue, 14 Jul 2026 12:40:26 +0800 Subject: [PATCH] feat: scaffold FastAPI app, config, and SQLite models Co-authored-by: Cursor --- backend/app/__init__.py | 0 backend/app/config.py | 33 +++++++++++++++++++++++++++++++++ backend/app/db.py | 24 ++++++++++++++++++++++++ backend/app/main.py | 23 +++++++++++++++++++++++ backend/app/models.py | 35 +++++++++++++++++++++++++++++++++++ backend/requirements.txt | 8 ++++++++ 6 files changed, 123 insertions(+) create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/db.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/requirements.txt diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..efa8f4f --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + populate_by_name=True, + extra="ignore", + ) + + data_dir: Path = Field(default=Path("/data"), alias="DATA_DIR") + admin_password: str = Field(default="changeme", alias="ADMIN_PASSWORD") + secret_key: str = Field(default="dev-secret-change-me", alias="SECRET_KEY") + max_upload_mb: int = Field(default=200, alias="MAX_UPLOAD_MB") + rate_limit_per_second: int = Field(default=2, alias="RATE_LIMIT_PER_SECOND") + jwt_expire_hours: int = Field(default=12, alias="JWT_EXPIRE_HOURS") + host: str = Field(default="0.0.0.0", alias="HOST") + port: int = Field(default=8080, alias="PORT") + + @property + def db_url(self) -> str: + return f"sqlite:///{self.data_dir / 'mincedpad.db'}" + + @property + def uploads_dir(self) -> Path: + return self.data_dir / "uploads" + + +settings = Settings() diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..f4560de --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,24 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.config import settings + +engine = create_engine( + settings.db_url, + connect_args={"check_same_thread": False}, +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..ab21d0d --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,23 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app.config import settings +from app.db import Base, engine +from app import models # noqa: F401 — register models with Base.metadata + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + settings.data_dir.mkdir(parents=True, exist_ok=True) + settings.uploads_dir.mkdir(parents=True, exist_ok=True) + Base.metadata.create_all(bind=engine) + yield + + +app = FastAPI(title="MincedPad", lifespan=lifespan) + + +@app.get("/api/health") +def health(): + return {"ok": True, "name": "MincedPad"} diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..35e2b9d --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,35 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class Item(Base): + __tablename__ = "items" + + id: Mapped[int] = mapped_column(primary_key=True) + slug: Mapped[str] = mapped_column(String(32), unique=True, index=True) + kind: Mapped[str] = mapped_column(String(16)) # text|file + title: Mapped[str] = mapped_column(String(255), default="") + body: Mapped[str | None] = mapped_column(Text, nullable=True) + file_name: Mapped[str | None] = mapped_column(String(512), nullable=True) + file_path: Mapped[str | None] = mapped_column(String(512), nullable=True) + mime: Mapped[str | None] = mapped_column(String(128), nullable=True) + size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True) + is_public: Mapped[bool] = mapped_column(Boolean, default=True) + burn_after_read: Mapped[bool] = mapped_column(Boolean, default=False) + expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_ip: Mapped[str] = mapped_column(String(64), default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + view_count: Mapped[int] = mapped_column(Integer, default=0) + burned: Mapped[bool] = mapped_column(Boolean, default=False) + + +class BannedIP(Base): + __tablename__ = "banned_ips" + + ip: Mapped[str] = mapped_column(String(64), primary_key=True) + reason: Mapped[str] = mapped_column(String(255), default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3035e41 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy==2.0.36 +python-multipart==0.0.20 +pyjwt==2.10.1 +httpx==0.28.1 +pytest==8.3.4 +pydantic-settings==2.7.0