This commit is contained in:
2026-09-07 11:39:39 +02:00
parent 59722ee255
commit d0e022484e
10 changed files with 440 additions and 30 deletions

View File

@@ -1,23 +1,100 @@
import hashlib
import hmac
import io
import os
import re
import secrets
import shutil
import time
import zipfile
from pathlib import Path
from typing import List
from fastapi import FastAPI, HTTPException, UploadFile, File
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, UploadFile, File
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
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:
@@ -42,7 +119,7 @@ class ProjectCreate(BaseModel):
name: str
@app.get("/api/projects")
@api.get("/projects")
def list_projects():
projects = []
for entry in sorted(PROJECTS_DIR.iterdir()):
@@ -55,7 +132,7 @@ def list_projects():
return projects
@app.post("/api/projects")
@api.post("/projects")
def create_project(payload: ProjectCreate):
path = safe_project_path(payload.name)
if path.exists():
@@ -64,7 +141,7 @@ def create_project(payload: ProjectCreate):
return {"name": payload.name}
@app.delete("/api/projects/{name}")
@api.delete("/projects/{name}")
def delete_project(name: str):
path = safe_project_path(name)
if not path.exists():
@@ -73,7 +150,7 @@ def delete_project(name: str):
return {"status": "deleted"}
@app.get("/api/projects/{name}/files")
@api.get("/projects/{name}/files")
def list_files(name: str):
path = safe_project_path(name)
if not path.exists():
@@ -89,7 +166,7 @@ def list_files(name: str):
return files
@app.post("/api/projects/{name}/upload")
@api.post("/projects/{name}/upload")
async def upload_files(name: str, files: List[UploadFile] = File(...)):
path = safe_project_path(name)
if not path.exists():
@@ -110,7 +187,7 @@ async def upload_files(name: str, files: List[UploadFile] = File(...)):
return {"saved": saved, "skipped": skipped}
@app.get("/api/projects/{name}/files/{filename}")
@api.get("/projects/{name}/files/{filename}")
def get_file(name: str, filename: str):
path = safe_project_path(name)
target = path / safe_filename(filename)
@@ -119,7 +196,7 @@ def get_file(name: str, filename: str):
return FileResponse(target)
@app.get("/api/download-all")
@api.get("/download-all")
def download_all_projects():
projects = [p for p in PROJECTS_DIR.iterdir() if p.is_dir()]
files_found = False
@@ -143,7 +220,7 @@ def download_all_projects():
)
@app.get("/api/projects/{name}/download")
@api.get("/projects/{name}/download")
def download_project(name: str):
path = safe_project_path(name)
if not path.exists():
@@ -167,7 +244,7 @@ def download_project(name: str):
)
@app.delete("/api/projects/{name}/files/{filename}")
@api.delete("/projects/{name}/files/{filename}")
def delete_file(name: str, filename: str):
path = safe_project_path(name)
target = path / safe_filename(filename)
@@ -177,5 +254,8 @@ def delete_file(name: str, filename: str):
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")

View File

@@ -1,3 +1,4 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
python-multipart==0.0.9
itsdangerous==2.2.0