Skip to content

Commit 91dafcd

Browse files
committed
Harden API security, add tests, and document setup
1 parent 04d140d commit 91dafcd

File tree

11 files changed

+252
-0
lines changed

11 files changed

+252
-0
lines changed

.env-sample

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
API_KEY=

.gitignore

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Bytecode / cache
2+
__pycache__/
3+
*.py[cod]
4+
.Python
5+
6+
# Environments
7+
env/
8+
venv/
9+
.venv/
10+
11+
# Testing
12+
.pytest_cache/
13+
.coverage
14+
coverage.xml
15+
16+
# Editors
17+
.vscode/
18+
.idea/
19+
*.swp
20+
21+
# Local data
22+
uploads/
23+
*.db
24+
.env

app/__init__.py

Whitespace-only changes.

app/cleaner.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from apscheduler.schedulers.background import BackgroundScheduler
2+
from app.storage import delete_expired_files
3+
4+
5+
def start_cleaner(engine):
6+
scheduler = BackgroundScheduler()
7+
scheduler.add_job(lambda: delete_expired_files(engine), "interval", hours=1)
8+
scheduler.start()
9+
return scheduler

app/config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import os
2+
from dotenv import load_dotenv
3+
4+
load_dotenv()
5+
6+
API_KEY = os.getenv("API_KEY")
7+
if not API_KEY:
8+
raise RuntimeError("API_KEY environment variable must be set")
9+
10+
UPLOAD_DIR = os.getenv("UPLOAD_DIR", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "uploads")))
11+
DB_URL = os.getenv("DB_URL", "sqlite:///./cdn.db")
12+
DELETE_AFTER_HOURS = int(os.getenv("DELETE_AFTER_HOURS", "72"))
13+
CORS_ORIGINS = os.getenv("CORS_ORIGINS", "*")
14+
15+
DB_CONNECT_ARGS = {"check_same_thread": False} if DB_URL.startswith("sqlite") else {}
16+
ENABLE_CLEANER = os.getenv("ENABLE_CLEANER", "true").lower() in {"true", "1", "yes"}

app/main.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from fastapi import FastAPI, UploadFile, File, HTTPException, Header, Depends
2+
from fastapi.responses import FileResponse
3+
from fastapi.middleware.cors import CORSMiddleware
4+
from sqlmodel import SQLModel, create_engine, Session, select
5+
from urllib.parse import quote
6+
from pathlib import Path
7+
from app.config import API_KEY, DB_URL, UPLOAD_DIR, CORS_ORIGINS, DB_CONNECT_ARGS, ENABLE_CLEANER
8+
from app.models import File as FileModel
9+
from app.storage import save_file
10+
from app.cleaner import start_cleaner
11+
12+
app = FastAPI(title="AlterBase CDN API", version="1.0")
13+
14+
# CORS
15+
origins = [o.strip() for o in CORS_ORIGINS.split(",")]
16+
app.add_middleware(
17+
CORSMiddleware,
18+
allow_origins=origins,
19+
allow_methods=["*"],
20+
allow_headers=["*"],
21+
)
22+
23+
engine = create_engine(DB_URL, connect_args=DB_CONNECT_ARGS)
24+
SQLModel.metadata.create_all(engine)
25+
if ENABLE_CLEANER:
26+
start_cleaner(engine) # background scheduler
27+
28+
# --- API Key guard ---
29+
async def require_api_key(x_api_key: str | None = Header(default=None)):
30+
if API_KEY and x_api_key != API_KEY:
31+
raise HTTPException(status_code=401, detail="Invalid or missing API Key")
32+
33+
# --- Routes ---
34+
35+
@app.post("/upload", dependencies=[Depends(require_api_key)])
36+
async def upload(file: UploadFile = File(...)):
37+
if not file.filename:
38+
raise HTTPException(status_code=400, detail="Missing filename")
39+
data = await file.read()
40+
stored_name, size_bytes = save_file(data, file.filename, file.content_type or "application/octet-stream")
41+
file_id = stored_name.split(".")[0]
42+
43+
with Session(engine) as session:
44+
rec = FileModel(
45+
id=file_id,
46+
original_name=file.filename,
47+
stored_name=stored_name,
48+
content_type=file.content_type or "application/octet-stream",
49+
size_bytes=size_bytes,
50+
)
51+
session.add(rec)
52+
session.commit()
53+
54+
return {
55+
"id": file_id,
56+
"url": f"/{quote(stored_name)}",
57+
"size": size_bytes,
58+
"type": file.content_type,
59+
}
60+
61+
@app.get("/list", dependencies=[Depends(require_api_key)])
62+
def list_files():
63+
with Session(engine) as session:
64+
files = session.exec(select(FileModel).order_by(FileModel.created_at.desc())).all()
65+
return [
66+
{
67+
"id": f.id,
68+
"url": f"/{quote(f.stored_name)}",
69+
"name": f.original_name,
70+
"size": f.size_bytes,
71+
"created_at": f.created_at,
72+
} for f in files
73+
]
74+
75+
@app.get("/{filename}")
76+
def serve_file(filename: str):
77+
upload_root = Path(UPLOAD_DIR).resolve()
78+
try:
79+
path = (upload_root / filename).resolve()
80+
path.relative_to(upload_root)
81+
except (ValueError, RuntimeError):
82+
raise HTTPException(status_code=404, detail="Not found")
83+
if not path.is_file():
84+
raise HTTPException(status_code=404, detail="Not found")
85+
return FileResponse(path)

app/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from datetime import datetime
2+
from sqlmodel import SQLModel, Field
3+
4+
class File(SQLModel, table=True):
5+
id: str = Field(primary_key=True)
6+
original_name: str
7+
stored_name: str
8+
content_type: str
9+
size_bytes: int
10+
created_at: datetime = Field(default_factory=datetime.utcnow)

app/storage.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import os
2+
import uuid
3+
from datetime import datetime, timedelta
4+
from sqlmodel import Session, select
5+
from app.config import UPLOAD_DIR, DELETE_AFTER_HOURS
6+
from app.models import File
7+
8+
os.makedirs(UPLOAD_DIR, exist_ok=True)
9+
10+
def save_file(file_bytes: bytes, original_name: str, content_type: str) -> str:
11+
ext = os.path.splitext(original_name)[1] or ".bin"
12+
file_id = str(uuid.uuid4())
13+
stored_name = f"{file_id}{ext}"
14+
path = os.path.join(UPLOAD_DIR, stored_name)
15+
with open(path, "wb") as f:
16+
f.write(file_bytes)
17+
return stored_name, len(file_bytes)
18+
19+
def delete_expired_files(engine):
20+
from datetime import datetime
21+
with Session(engine) as session:
22+
cutoff = datetime.utcnow() - timedelta(hours=DELETE_AFTER_HOURS)
23+
stmt = select(File).where(File.created_at < cutoff)
24+
old_files = session.exec(stmt).all()
25+
for f in old_files:
26+
try:
27+
os.remove(os.path.join(UPLOAD_DIR, f.stored_name))
28+
except FileNotFoundError:
29+
pass
30+
session.delete(f)
31+
session.commit()
32+
return len(old_files)

requirements.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
fastapi==0.115.4
2+
uvicorn==0.32.0
3+
sqlmodel==0.0.22
4+
python-multipart==0.0.9
5+
pillow==10.4.0
6+
python-dotenv==1.0.1
7+
apscheduler==3.10.4
8+
pytest==7.4.4
9+
httpx==0.27.2

run.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/usr/bin/env bash
2+
set -e
3+
export APP_HOST=${APP_HOST:-0.0.0.0}
4+
export APP_PORT=${APP_PORT:-8000}
5+
uvicorn app.main:app --host "$APP_HOST" --port "$APP_PORT" --workers 2 --proxy-headers

0 commit comments

Comments
 (0)