Files
3D-Organizer/backend/main.py
2026-09-07 11:53:19 +02:00

276 lines
8.5 KiB
Python

import hashlib
import hmac
import os
import re
import secrets
import shutil
import tempfile
import time
import zipfile
from pathlib import Path
from typing import List
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, UploadFile, File
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from starlette.background import BackgroundTask
from starlette.concurrency import run_in_threadpool
from starlette.middleware.sessions import SessionMiddleware
PROJECTS_DIR = Path(os.environ.get("PROJECTS_DIR", "/data/projects")).resolve()
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
SETTINGS_DIR = Path(os.environ.get("SETTINGS_DIR", "/data/settings")).resolve()
SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_EXTENSIONS = {".stl", ".3mf"}
NAME_PATTERN = re.compile(r"^[A-Za-z0-9 _\-]+$")
PASSWORD_SHA256 = os.environ.get("PASSWORD_SHA256", "").strip().lower()
if not PASSWORD_SHA256:
raise RuntimeError(
"PASSWORD_SHA256 ist nicht gesetzt. Bitte in der .env / docker-compose.yml "
"einen SHA256-Hash des gewünschten Passworts hinterlegen, z.B. mit:\n"
" echo -n 'meinPasswort' | sha256sum"
)
# The session-signing secret lives in the settings volume (not the projects
# folder) so sessions survive container restarts but stay separate from the
# user's files.
SECRET_KEY_FILE = SETTINGS_DIR / "secret_key"
if SECRET_KEY_FILE.exists():
SECRET_KEY = SECRET_KEY_FILE.read_text().strip()
else:
SECRET_KEY = secrets.token_hex(32)
SECRET_KEY_FILE.write_text(SECRET_KEY)
# Simple in-memory brute-force throttle (single-user app, resets on restart).
_failed_attempts = {"count": 0, "last": 0.0}
app = FastAPI(title="3D Project Manager")
app.add_middleware(
SessionMiddleware,
secret_key=SECRET_KEY,
session_cookie="session",
max_age=60 * 60 * 24 * 30, # 30 days
same_site="lax",
)
def require_auth(request: Request):
if not request.session.get("authenticated"):
raise HTTPException(401, "Not authenticated")
class LoginPayload(BaseModel):
password: str
@app.post("/api/login")
async def login(payload: LoginPayload, request: Request):
# Basic throttle: after repeated failures, add an increasing delay.
if _failed_attempts["count"] >= 5 and time.time() - _failed_attempts["last"] < 30:
raise HTTPException(429, "Zu viele Fehlversuche, bitte kurz warten")
submitted_hash = hashlib.sha256(payload.password.encode("utf-8")).hexdigest()
if hmac.compare_digest(submitted_hash, PASSWORD_SHA256):
_failed_attempts["count"] = 0
request.session["authenticated"] = True
return {"authenticated": True}
_failed_attempts["count"] += 1
_failed_attempts["last"] = time.time()
raise HTTPException(401, "Falsches Passwort")
@app.post("/api/logout")
async def logout(request: Request):
request.session.clear()
return {"authenticated": False}
@app.get("/api/me", dependencies=[Depends(require_auth)])
async def me():
return {"authenticated": True}
# All project/file routes below require a valid session.
api = APIRouter(dependencies=[Depends(require_auth)])
def safe_project_path(name: str) -> Path:
"""Validate a project name and return its resolved path, guarding
against path traversal / invalid characters."""
if not name or not NAME_PATTERN.match(name):
raise HTTPException(400, "Invalid project name")
path = (PROJECTS_DIR / name).resolve()
if path.parent != PROJECTS_DIR:
raise HTTPException(400, "Invalid project name")
return path
def safe_filename(filename: str) -> str:
name = Path(filename).name
if not name or name in (".", ".."):
raise HTTPException(400, "Invalid file name")
return name
class ProjectCreate(BaseModel):
name: str
@api.get("/projects")
def list_projects():
projects = []
for entry in sorted(PROJECTS_DIR.iterdir()):
if entry.is_dir():
file_count = sum(
1 for f in entry.iterdir()
if f.is_file() and f.suffix.lower() in ALLOWED_EXTENSIONS
)
projects.append({"name": entry.name, "file_count": file_count})
return projects
@api.post("/projects")
def create_project(payload: ProjectCreate):
path = safe_project_path(payload.name)
if path.exists():
raise HTTPException(409, "A project with this name already exists")
path.mkdir(parents=True)
return {"name": payload.name}
@api.delete("/projects/{name}")
def delete_project(name: str):
path = safe_project_path(name)
if not path.exists():
raise HTTPException(404, "Project not found")
shutil.rmtree(path)
return {"status": "deleted"}
@api.get("/projects/{name}/files")
def list_files(name: str):
path = safe_project_path(name)
if not path.exists():
raise HTTPException(404, "Project not found")
files = []
for f in sorted(path.iterdir()):
if f.is_file() and f.suffix.lower() in ALLOWED_EXTENSIONS:
files.append({
"name": f.name,
"size": f.stat().st_size,
"type": f.suffix.lower().lstrip("."),
})
return files
@api.post("/projects/{name}/upload")
async def upload_files(name: str, files: List[UploadFile] = File(...)):
path = safe_project_path(name)
if not path.exists():
raise HTTPException(404, "Project not found")
saved, skipped = [], []
for upload in files:
ext = Path(upload.filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
skipped.append(upload.filename)
continue
filename = safe_filename(upload.filename)
dest = path / filename
# Run the actual (blocking) disk copy in a worker thread so a large
# file doesn't freeze the whole server for the duration of the copy.
await run_in_threadpool(_copy_upload_to_disk, upload.file, dest)
saved.append(filename)
return {"saved": saved, "skipped": skipped}
def _copy_upload_to_disk(src_file, dest_path: Path) -> None:
with open(dest_path, "wb") as out:
shutil.copyfileobj(src_file, out, length=4 * 1024 * 1024)
@api.get("/projects/{name}/files/{filename}")
def get_file(name: str, filename: str):
path = safe_project_path(name)
target = path / safe_filename(filename)
if not target.exists():
raise HTTPException(404, "File not found")
return FileResponse(target)
@api.get("/download-all")
def download_all_projects():
projects = [p for p in PROJECTS_DIR.iterdir() if p.is_dir()]
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
tmp_path = Path(tmp.name)
tmp.close()
files_found = False
with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
for project in projects:
for f in project.iterdir():
if f.is_file() and f.suffix.lower() in ALLOWED_EXTENSIONS:
zf.write(f, arcname=f"{project.name}/{f.name}")
files_found = True
if not files_found:
tmp_path.unlink(missing_ok=True)
raise HTTPException(404, "No files to download")
return FileResponse(
tmp_path,
media_type="application/zip",
filename="alle-projekte.zip",
background=BackgroundTask(lambda: tmp_path.unlink(missing_ok=True)),
)
@api.get("/projects/{name}/download")
def download_project(name: str):
path = safe_project_path(name)
if not path.exists():
raise HTTPException(404, "Project not found")
files = [f for f in path.iterdir() if f.is_file() and f.suffix.lower() in ALLOWED_EXTENSIONS]
if not files:
raise HTTPException(404, "Project has no files to download")
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
tmp_path = Path(tmp.name)
tmp.close()
with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
for f in files:
zf.write(f, arcname=f.name)
return FileResponse(
tmp_path,
media_type="application/zip",
filename=f"{name}.zip",
background=BackgroundTask(lambda: tmp_path.unlink(missing_ok=True)),
)
@api.delete("/projects/{name}/files/{filename}")
def delete_file(name: str, filename: str):
path = safe_project_path(name)
target = path / safe_filename(filename)
if not target.exists():
raise HTTPException(404, "File not found")
target.unlink()
return {"status": "deleted"}
app.include_router(api, prefix="/api")
# Serve the frontend last, so it doesn't shadow the /api routes above.
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")