Skip to content

Commit 8cf168f

Browse files
committed
Restructure app into modular packages with templated UI
1 parent b101bc8 commit 8cf168f

20 files changed

Lines changed: 649 additions & 591 deletions

File tree

app/api/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from fastapi import APIRouter
2+
3+
from app.api.routes import router
4+
5+
__all__ = ["router"]

app/api/routes.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from datetime import datetime
5+
from pathlib import Path
6+
from urllib.parse import quote
7+
8+
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
9+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
10+
from sqlmodel import Session, select
11+
12+
from app.config import CACHE_MAX_AGE_SECONDS, MAX_FILE_SIZE, RATE_LIMIT_PER_MINUTE, UPLOAD_DIR
13+
from app.core.metrics import metrics
14+
from app.core.rate_limit import RateLimiter
15+
from app.core.templates import render_template
16+
from app.db import get_session
17+
from app.models import File as FileModel
18+
from app.services.stats import fetch_storage_totals
19+
from app.storage import save_file
20+
21+
router = APIRouter()
22+
23+
logger = logging.getLogger("image_uploader")
24+
25+
rate_limiter = RateLimiter(RATE_LIMIT_PER_MINUTE)
26+
MAX_FILE_SIZE_MB = MAX_FILE_SIZE / (1024 * 1024)
27+
UPLOAD_ROOT = Path(UPLOAD_DIR).resolve()
28+
29+
30+
async def enforce_rate_limit(request: Request):
31+
client = request.client.host if request.client else "unknown"
32+
allowed, retry_after = rate_limiter.hit(client)
33+
if not allowed:
34+
headers = {"Retry-After": str(retry_after)}
35+
raise HTTPException(
36+
status_code=429,
37+
detail=f"Rate limit exceeded. Try again in {retry_after} seconds.",
38+
headers=headers,
39+
)
40+
41+
42+
@router.get("/", response_class=HTMLResponse)
43+
async def home(session: Session = Depends(get_session)):
44+
stats = metrics.snapshot()
45+
totals = fetch_storage_totals(session)
46+
uploads_count = max(int(stats.get("uploads", 0)), totals["total_files"])
47+
48+
html = render_template(
49+
"pages/home.html",
50+
{
51+
"max_file_mb": f"{MAX_FILE_SIZE_MB:.1f}",
52+
"uploads": str(uploads_count),
53+
"downloads": str(stats.get("downloads", 0)),
54+
"deleted": str(stats.get("deleted", 0)),
55+
"storage_bytes": str(totals["total_bytes"]),
56+
"year": str(datetime.utcnow().year),
57+
},
58+
)
59+
return HTMLResponse(content=html)
60+
61+
62+
@router.get("/api-info", response_class=HTMLResponse)
63+
async def api_info():
64+
html = render_template(
65+
"pages/api.html",
66+
{"max_file_mb": f"{MAX_FILE_SIZE_MB:.1f}", "rate_limit": str(RATE_LIMIT_PER_MINUTE)},
67+
)
68+
return HTMLResponse(content=html)
69+
70+
71+
@router.get("/list", dependencies=[Depends(enforce_rate_limit)])
72+
def list_files(session: Session = Depends(get_session)):
73+
files = session.exec(select(FileModel).order_by(FileModel.created_at.desc())).all()
74+
return [
75+
{
76+
"id": f.id,
77+
"url": f"/{quote(f.stored_name)}",
78+
"name": f.original_name,
79+
"size": f.size_bytes,
80+
"created_at": f.created_at,
81+
}
82+
for f in files
83+
]
84+
85+
86+
@router.post("/upload", dependencies=[Depends(enforce_rate_limit)])
87+
async def upload(file: UploadFile = File(...), session: Session = Depends(get_session)):
88+
if not file.filename:
89+
raise HTTPException(status_code=400, detail="Missing filename")
90+
data = await file.read()
91+
size_bytes = len(data)
92+
if size_bytes > MAX_FILE_SIZE:
93+
logger.warning(
94+
"event=upload_rejected reason=max_size filename=%s size_bytes=%s limit_bytes=%s",
95+
file.filename,
96+
size_bytes,
97+
MAX_FILE_SIZE,
98+
)
99+
raise HTTPException(
100+
status_code=413,
101+
detail=f"File too large. Maximum allowed size is {MAX_FILE_SIZE_MB:.1f} MB.",
102+
)
103+
104+
stored_name, size_bytes = save_file(data, file.filename, file.content_type or "application/octet-stream")
105+
file_id = stored_name.split(".")[0]
106+
107+
record = FileModel(
108+
id=file_id,
109+
original_name=file.filename,
110+
stored_name=stored_name,
111+
content_type=file.content_type or "application/octet-stream",
112+
size_bytes=size_bytes,
113+
)
114+
session.add(record)
115+
session.commit()
116+
117+
metrics.record_upload(size_bytes)
118+
logger.info(
119+
"event=upload_success file_id=%s stored_name=%s size_bytes=%s content_type=%s",
120+
file_id,
121+
stored_name,
122+
size_bytes,
123+
file.content_type or "application/octet-stream",
124+
)
125+
126+
return {
127+
"id": file_id,
128+
"url": f"/{quote(stored_name)}",
129+
"size": size_bytes,
130+
"type": file.content_type,
131+
}
132+
133+
134+
@router.get("/metrics", dependencies=[Depends(enforce_rate_limit)])
135+
def metrics_snapshot(session: Session = Depends(get_session)):
136+
stats = metrics.snapshot()
137+
totals = fetch_storage_totals(session)
138+
payload = {
139+
"uploads": max(int(stats.get("uploads", 0)), totals["total_files"]),
140+
"downloads": int(stats.get("downloads", 0)),
141+
"deleted": int(stats.get("deleted", 0)),
142+
"storage_bytes": totals["total_bytes"],
143+
}
144+
response = JSONResponse(payload)
145+
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
146+
return response
147+
148+
149+
@router.get("/{filename}", dependencies=[Depends(enforce_rate_limit)])
150+
def serve_file(filename: str):
151+
try:
152+
path = (UPLOAD_ROOT / filename).resolve()
153+
path.relative_to(UPLOAD_ROOT)
154+
except (ValueError, RuntimeError):
155+
raise HTTPException(status_code=404, detail="Not found")
156+
if not path.is_file():
157+
raise HTTPException(status_code=404, detail="Not found")
158+
159+
metrics.record_download()
160+
logger.info("event=file_served filename=%s path=%s", filename, path)
161+
162+
response = FileResponse(path)
163+
response.headers["Cache-Control"] = f"public, max-age={CACHE_MAX_AGE_SECONDS}"
164+
return response

app/core/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Core utilities package

app/core/exceptions.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from fastapi import FastAPI, Request
2+
from fastapi.responses import HTMLResponse, JSONResponse
3+
from starlette.exceptions import HTTPException as StarletteHTTPException
4+
5+
from app.core.templates import render_template
6+
7+
8+
def register_exception_handlers(app: FastAPI) -> None:
9+
@app.exception_handler(404)
10+
async def not_found_handler(request: Request, exc: StarletteHTTPException):
11+
accept = request.headers.get("accept", "")
12+
detail = exc.detail if hasattr(exc, "detail") else "Not Found"
13+
if "application/json" in accept and "text/html" not in accept:
14+
return JSONResponse({"detail": detail}, status_code=404)
15+
detail_text = (
16+
detail
17+
if detail not in (None, "", "Not found", "Not Found")
18+
else "The resource you were looking for isn't here. It may have been removed or its link is outdated."
19+
)
20+
html = render_template("errors/404.html", {"detail": detail_text})
21+
return HTMLResponse(content=html, status_code=404)
File renamed without changes.

app/core/templates.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
from string import Template
5+
from typing import Any
6+
7+
8+
TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates"
9+
10+
11+
def render_template(filename: str, context: dict[str, Any]) -> str:
12+
path = TEMPLATE_DIR / filename
13+
if not path.is_file():
14+
return "<h1>Template missing</h1>"
15+
content = path.read_text(encoding="utf-8")
16+
template = Template(content)
17+
normalized = {key: "" if value is None else value for key, value in context.items()}
18+
return template.safe_substitute(normalized)

app/db.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from collections.abc import Iterator
2+
3+
from sqlmodel import Session, SQLModel, create_engine
4+
5+
from app.config import DB_CONNECT_ARGS, DB_URL
6+
7+
engine = create_engine(DB_URL, connect_args=DB_CONNECT_ARGS)
8+
9+
10+
def init_db() -> None:
11+
SQLModel.metadata.create_all(engine)
12+
13+
14+
def get_session() -> Iterator[Session]:
15+
with Session(engine) as session:
16+
yield session

0 commit comments

Comments
 (0)