Skip to content

Commit 8b95e43

Browse files
committed
Add password-protected admin dashboard with lockout and file controls
1 parent da681af commit 8b95e43

9 files changed

Lines changed: 527 additions & 6 deletions

File tree

.env-sample

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,10 @@
1-
API_KEY=
1+
UPLOAD_DIR=uploads
2+
DB_URL=sqlite:///./cdn.db
3+
DELETE_AFTER_HOURS=72
4+
CORS_ORIGINS=*
5+
ENABLE_CLEANER=true
6+
MAX_FILE_SIZE_BYTES=10485760
7+
RATE_LIMIT_PER_MINUTE=60
8+
CACHE_MAX_AGE_SECONDS=3600
9+
ADMIN_PASSWORD=change-me
10+
ADMIN_LOCK_STEP_SECONDS=300

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ FastAPI-powered micro CDN for storing and serving uploaded assets. Files are wri
88
- In-memory rate limiting (per-client/minute) to prevent abuse.
99
- Automatic cleanup job that prunes files after the configured retention window.
1010
- Live metrics (uploads, downloads, cleanups) exposed on the home page.
11+
- Password-protected admin dashboard to inspect uploads, storage usage, and prune files with lockout protection.
1112
- SQLite by default, with environment overrides for production databases.
1213

1314
## Live Demo
@@ -27,13 +28,15 @@ Optional environment variables:
2728
| Variable | Default | Description |
2829
|----------|---------|-------------|
2930
| `UPLOAD_DIR` | `./uploads` | Directory for stored files. Created automatically. |
30-
| `DB_URL` | `sqlite:///./cdn.db` | SQLModel database URL. Use PostgreSQL/MySQL for multi-worker deployments. |
31+
| `DB_URL` | `sqlite:///./cdn.db` | SQLModel database URL. Use PostgreSQL/MySQL for multi-worker deployments. Use `postgresql+psycopg2://user:pass@host:5432/db` inside Docker. |
3132
| `DELETE_AFTER_HOURS` | `72` | Retention window for the cleaner job. |
3233
| `CORS_ORIGINS` | `*` | Comma-separated list of allowed origins. |
3334
| `ENABLE_CLEANER` | `true` | Disable (`false`) to skip scheduling the cleanup job. |
3435
| `MAX_FILE_SIZE_BYTES` | `10485760` | Max upload size in bytes (default 10 MB). |
3536
| `RATE_LIMIT_PER_MINUTE` | `60` | Allowed requests per client per minute. |
3637
| `CACHE_MAX_AGE_SECONDS` | `3600` | Cache lifetime used for served files. |
38+
| `ADMIN_PASSWORD` | `admin-dev-password` | Password required to access the `/admin` dashboard. |
39+
| `ADMIN_LOCK_STEP_SECONDS` | `300` | Lock duration increment (in seconds) after repeated failed admin logins. |
3740

3841
Run the API:
3942

@@ -64,6 +67,8 @@ For a Postgres-backed setup (with uploads persisted to a Docker volume), use the
6467
docker compose up --build
6568
```
6669

70+
The compose file maps uploads into a named volume at `/data/uploads`, points the API at the bundled Postgres (`postgresql+psycopg2://cdn:cdn@db:5432/cdn`), and loads overrides from `.env`.
71+
6772
Key environment variables for production deployments:
6873

6974
| Variable | Description |
@@ -75,17 +80,22 @@ Key environment variables for production deployments:
7580
| `CACHE_MAX_AGE_SECONDS` | Tune cache headers for served files. |
7681
| `CORS_ORIGINS` | Lock down origins for production frontends. |
7782
| `ENABLE_CLEANER` | Keep true to remove stale files automatically. |
83+
| `ADMIN_PASSWORD` | Password provided via header/form/query for `/admin`. |
84+
| `ADMIN_LOCK_STEP_SECONDS` | Lock duration increment (seconds) after failed admin logins. |
7885

7986
## API Overview
8087

8188
| Method | Path | Description |
8289
|--------|------|-------------|
8390
| `POST` | `/upload` | Accepts multipart file upload (respecting the configured size limit) and returns metadata (`id`, `url`, `size`, `type`). |
84-
| `GET` | `/list` | Returns a JSON array of stored files ordered by newest first. |
8591
| `GET` | `/{filename}` | Serves a stored file by UUID filename and includes `Cache-Control` headers. |
8692

8793
Returned `url` values are relative (e.g. `/3d4d...jpg`), suitable for prefixing with your CDN/API host.
8894

95+
### Admin Dashboard
96+
- Visit `/admin` with the header `X-Admin-Password: <ADMIN_PASSWORD>` (or include `password` in the query/form) to view uploads, downloads, cleanup counts, recent files, and trigger per-file or bulk deletions.
97+
- After three failed attempts the admin login is locked; each lock adds `ADMIN_LOCK_STEP_SECONDS` (default 5 minutes) to the wait time.
98+
8999
## Cleaning Expired Files
90100

91101
If `ENABLE_CLEANER` is `true`, a background APScheduler job runs hourly to delete files older than `DELETE_AFTER_HOURS`. Each run emits structured logs and increments the on-page cleanup counter.

app/api/routes.py

Lines changed: 195 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
from __future__ import annotations
22

33
import logging
4-
from datetime import datetime
4+
import html
5+
from datetime import datetime, timedelta
56
from pathlib import Path
67
from urllib.parse import quote
78

89
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
910
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
1011
from sqlmodel import Session, select
1112

12-
from app.config import CACHE_MAX_AGE_SECONDS, MAX_FILE_SIZE, RATE_LIMIT_PER_MINUTE, UPLOAD_DIR
13+
from app.config import (
14+
ADMIN_LOCK_STEP_SECONDS,
15+
ADMIN_PASSWORD,
16+
CACHE_MAX_AGE_SECONDS,
17+
MAX_FILE_SIZE,
18+
RATE_LIMIT_PER_MINUTE,
19+
UPLOAD_DIR,
20+
)
1321
from app.core.metrics import metrics
1422
from app.core.rate_limit import RateLimiter
1523
from app.core.templates import render_template
@@ -25,6 +33,7 @@
2533
rate_limiter = RateLimiter(RATE_LIMIT_PER_MINUTE)
2634
MAX_FILE_SIZE_MB = MAX_FILE_SIZE / (1024 * 1024)
2735
UPLOAD_ROOT = Path(UPLOAD_DIR).resolve()
36+
_admin_attempts: dict[str, dict] = {}
2837

2938

3039
async def enforce_rate_limit(request: Request):
@@ -68,6 +77,190 @@ async def api_info():
6877
return HTMLResponse(content=html)
6978

7079

80+
def _render_admin_table(files: list[FileModel]) -> str:
81+
rows: list[str] = []
82+
for file in files:
83+
preview = f"<img src='/{quote(file.stored_name)}' alt='preview' loading='lazy' />"
84+
rows.append(
85+
"<tr>"
86+
f"<td>{html.escape(file.id)}</td>"
87+
f"<td class='preview-cell'>{preview}</td>"
88+
f"<td>{html.escape(file.original_name)}</td>"
89+
f"<td>{file.size_bytes} B</td>"
90+
f"<td>{file.created_at}</td>"
91+
"<td>"
92+
"<form method='post' action='/admin/delete' class='inline'>"
93+
f"<input type='hidden' name='file_id' value='{html.escape(file.id)}' />"
94+
"<input type='password' name='password' placeholder='Admin password' required />"
95+
"<button type='submit'>Delete</button>"
96+
"</form>"
97+
"</td>"
98+
"</tr>"
99+
)
100+
return "".join(rows) or "<tr><td colspan='5'>No files yet</td></tr>"
101+
102+
103+
def _human_bytes(value: int) -> str:
104+
units = ["B", "KB", "MB", "GB", "TB"]
105+
size = float(max(value, 0))
106+
for unit in units:
107+
if size < 1024 or unit == units[-1]:
108+
formatted = f"{size:.1f}".rstrip("0").rstrip(".")
109+
return f"{formatted or '0'} {unit}"
110+
size /= 1024
111+
return "0 B"
112+
113+
114+
def _render_admin_login(message: str | None = None) -> str:
115+
flash_html = f"<div class='flash'>{html.escape(message)}</div>" if message else ""
116+
return render_template("pages/admin_login.html", {"flash_message": flash_html})
117+
118+
119+
def _render_admin_page(session: Session, message: str | None = None) -> str:
120+
totals = fetch_storage_totals(session)
121+
snapshot = metrics.snapshot()
122+
stmt = select(FileModel).order_by(FileModel.created_at.desc()).limit(50)
123+
files = session.exec(stmt).all()
124+
flash_html = f"<div class='flash'>{html.escape(message)}</div>" if message else ""
125+
return render_template(
126+
"pages/admin.html",
127+
{
128+
"uploads": totals["total_files"],
129+
"downloads": snapshot.get("downloads", 0),
130+
"deleted": snapshot.get("deleted", 0),
131+
"storage_human": _human_bytes(totals["total_bytes"]),
132+
"table_rows": _render_admin_table(files),
133+
"flash_message": flash_html,
134+
},
135+
)
136+
137+
138+
async def _get_admin_password(request: Request) -> str | None:
139+
password = request.headers.get("x-admin-password") or request.query_params.get("password")
140+
if password:
141+
return password
142+
if request.method in {"POST", "PUT", "DELETE"}:
143+
form_data = getattr(request.state, "admin_form", None)
144+
if form_data is None:
145+
try:
146+
form_data = await request.form()
147+
except Exception:
148+
form_data = None
149+
else:
150+
request.state.admin_form = form_data
151+
if form_data:
152+
return form_data.get("password")
153+
return None
154+
155+
156+
async def _auth_admin(request: Request, allow_blank: bool):
157+
client = request.client.host if request.client else "unknown"
158+
state = _admin_attempts.setdefault(client, {"failures": 0, "penalty": 0, "lock_until": None})
159+
now = datetime.utcnow()
160+
lock_until = state.get("lock_until")
161+
if lock_until and now < lock_until:
162+
remaining = lock_until - now
163+
minutes = max(1, int(remaining.total_seconds() // 60) + 1)
164+
msg = f"Too many attempts. Try again in {minutes} minutes."
165+
if allow_blank:
166+
return False, msg, True
167+
raise HTTPException(status_code=429, detail=msg)
168+
if lock_until and now >= lock_until:
169+
state["lock_until"] = None
170+
171+
password = await _get_admin_password(request)
172+
if not password:
173+
if allow_blank:
174+
return False, None, False
175+
raise HTTPException(status_code=401, detail="Admin password required")
176+
177+
if ADMIN_PASSWORD and password == ADMIN_PASSWORD:
178+
state["failures"] = 0
179+
return True, None, False
180+
181+
state["failures"] = state.get("failures", 0) + 1
182+
if state["failures"] >= 3:
183+
state["failures"] = 0
184+
state["penalty"] = state.get("penalty", 0) + 1
185+
duration = state["penalty"] * ADMIN_LOCK_STEP_SECONDS
186+
state["lock_until"] = now + timedelta(seconds=duration)
187+
minutes = max(1, duration // 60)
188+
msg = f"Too many failures. Locked for {minutes} minutes."
189+
if allow_blank:
190+
return False, msg, True
191+
raise HTTPException(status_code=429, detail=msg)
192+
msg = "Invalid password"
193+
if allow_blank:
194+
return False, msg, False
195+
raise HTTPException(status_code=401, detail=msg)
196+
197+
198+
async def require_admin(request: Request) -> str:
199+
success, _, _ = await _auth_admin(request, allow_blank=False)
200+
if success:
201+
return ADMIN_PASSWORD
202+
raise HTTPException(status_code=500, detail="Admin authentication failed")
203+
204+
205+
def _remove_file_from_disk(stored_name: str) -> None:
206+
try:
207+
path = (UPLOAD_ROOT / stored_name).resolve()
208+
path.relative_to(UPLOAD_ROOT)
209+
except (ValueError, RuntimeError):
210+
return
211+
path.unlink(missing_ok=True)
212+
213+
214+
@router.api_route("/admin", methods=["GET", "POST"], response_class=HTMLResponse)
215+
async def admin_dashboard(request: Request, session: Session = Depends(get_session)):
216+
success, message, locked = await _auth_admin(request, allow_blank=True)
217+
if success:
218+
html = _render_admin_page(session, message)
219+
return HTMLResponse(content=html)
220+
html = _render_admin_login(message)
221+
status = 429 if locked and message else 200
222+
return HTMLResponse(content=html, status_code=status)
223+
224+
225+
@router.post("/admin/delete", response_class=HTMLResponse)
226+
async def admin_delete_file(
227+
request: Request,
228+
session: Session = Depends(get_session),
229+
_: str = Depends(require_admin),
230+
):
231+
form = getattr(request.state, "admin_form", None) or await request.form()
232+
file_id = form.get("file_id")
233+
if not file_id:
234+
raise HTTPException(status_code=400, detail="Missing file_id")
235+
file = session.get(FileModel, file_id)
236+
if not file:
237+
html = _render_admin_page(session, "File not found.")
238+
return HTMLResponse(content=html, status_code=404)
239+
240+
_remove_file_from_disk(file.stored_name)
241+
session.delete(file)
242+
session.commit()
243+
html = _render_admin_page(session, "File deleted.")
244+
return HTMLResponse(content=html)
245+
246+
247+
@router.post("/admin/delete-all", response_class=HTMLResponse)
248+
async def admin_delete_all(
249+
request: Request,
250+
session: Session = Depends(get_session),
251+
_: str = Depends(require_admin),
252+
):
253+
files = session.exec(select(FileModel)).all()
254+
deleted = 0
255+
for file in files:
256+
_remove_file_from_disk(file.stored_name)
257+
session.delete(file)
258+
deleted += 1
259+
session.commit()
260+
html = _render_admin_page(session, f"Deleted {deleted} files.")
261+
return HTMLResponse(content=html)
262+
263+
71264
@router.get("/list", dependencies=[Depends(enforce_rate_limit)])
72265
def list_files(session: Session = Depends(get_session)):
73266
files = session.exec(select(FileModel).order_by(FileModel.created_at.desc())).all()

app/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,5 @@
1515
MAX_FILE_SIZE = int(os.getenv("MAX_FILE_SIZE_BYTES", str(10 * 1024 * 1024)))
1616
RATE_LIMIT_PER_MINUTE = int(os.getenv("RATE_LIMIT_PER_MINUTE", "60"))
1717
CACHE_MAX_AGE_SECONDS = int(os.getenv("CACHE_MAX_AGE_SECONDS", "3600"))
18+
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin-dev-password")
19+
ADMIN_LOCK_STEP_SECONDS = int(os.getenv("ADMIN_LOCK_STEP_SECONDS", str(5 * 60)))

0 commit comments

Comments
 (0)