2026-09-07 11:39:39 +02:00
|
|
|
import hashlib
|
|
|
|
|
import hmac
|
2026-09-07 08:29:52 +02:00
|
|
|
import io
|
2026-09-07 08:19:24 +02:00
|
|
|
import os
|
|
|
|
|
import re
|
2026-09-07 11:39:39 +02:00
|
|
|
import secrets
|
2026-09-07 08:19:24 +02:00
|
|
|
import shutil
|
2026-09-07 11:39:39 +02:00
|
|
|
import time
|
2026-09-07 08:29:52 +02:00
|
|
|
import zipfile
|
2026-09-07 08:19:24 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import List
|
|
|
|
|
|
2026-09-07 11:39:39 +02:00
|
|
|
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, UploadFile, File
|
2026-09-07 08:29:52 +02:00
|
|
|
from fastapi.responses import FileResponse, StreamingResponse
|
2026-09-07 08:19:24 +02:00
|
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
|
from pydantic import BaseModel
|
2026-09-07 11:39:39 +02:00
|
|
|
from starlette.middleware.sessions import SessionMiddleware
|
2026-09-07 08:19:24 +02:00
|
|
|
|
|
|
|
|
PROJECTS_DIR = Path(os.environ.get("PROJECTS_DIR", "/data/projects")).resolve()
|
|
|
|
|
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
2026-09-07 11:39:39 +02:00
|
|
|
SETTINGS_DIR = Path(os.environ.get("SETTINGS_DIR", "/data/settings")).resolve()
|
|
|
|
|
SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
2026-09-07 08:19:24 +02:00
|
|
|
ALLOWED_EXTENSIONS = {".stl", ".3mf"}
|
|
|
|
|
NAME_PATTERN = re.compile(r"^[A-Za-z0-9 _\-]+$")
|
|
|
|
|
|
2026-09-07 11:39:39 +02:00
|
|
|
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}
|
|
|
|
|
|
2026-09-07 08:19:24 +02:00
|
|
|
app = FastAPI(title="3D Project Manager")
|
2026-09-07 11:39:39 +02:00
|
|
|
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)])
|
2026-09-07 08:19:24 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 11:39:39 +02:00
|
|
|
@api.get("/projects")
|
2026-09-07 08:19:24 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 11:39:39 +02:00
|
|
|
@api.post("/projects")
|
2026-09-07 08:19:24 +02:00
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 11:39:39 +02:00
|
|
|
@api.delete("/projects/{name}")
|
2026-09-07 08:19:24 +02:00
|
|
|
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"}
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 11:39:39 +02:00
|
|
|
@api.get("/projects/{name}/files")
|
2026-09-07 08:19:24 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 11:39:39 +02:00
|
|
|
@api.post("/projects/{name}/upload")
|
2026-09-07 08:19:24 +02:00
|
|
|
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
|
|
|
|
|
with open(dest, "wb") as out:
|
|
|
|
|
shutil.copyfileobj(upload.file, out)
|
|
|
|
|
saved.append(filename)
|
|
|
|
|
|
|
|
|
|
return {"saved": saved, "skipped": skipped}
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 11:39:39 +02:00
|
|
|
@api.get("/projects/{name}/files/{filename}")
|
2026-09-07 08:19:24 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 11:39:39 +02:00
|
|
|
@api.get("/download-all")
|
2026-09-07 08:29:52 +02:00
|
|
|
def download_all_projects():
|
|
|
|
|
projects = [p for p in PROJECTS_DIR.iterdir() if p.is_dir()]
|
|
|
|
|
files_found = False
|
|
|
|
|
|
|
|
|
|
buffer = io.BytesIO()
|
|
|
|
|
with zipfile.ZipFile(buffer, "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:
|
|
|
|
|
raise HTTPException(404, "No files to download")
|
|
|
|
|
|
|
|
|
|
buffer.seek(0)
|
|
|
|
|
return StreamingResponse(
|
|
|
|
|
buffer,
|
|
|
|
|
media_type="application/zip",
|
|
|
|
|
headers={"Content-Disposition": 'attachment; filename="alle-projekte.zip"'},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 11:39:39 +02:00
|
|
|
@api.get("/projects/{name}/download")
|
2026-09-07 08:29:52 +02:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
buffer = io.BytesIO()
|
|
|
|
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
|
|
|
for f in files:
|
|
|
|
|
zf.write(f, arcname=f.name)
|
|
|
|
|
buffer.seek(0)
|
|
|
|
|
|
|
|
|
|
zip_filename = f"{name}.zip"
|
|
|
|
|
return StreamingResponse(
|
|
|
|
|
buffer,
|
|
|
|
|
media_type="application/zip",
|
|
|
|
|
headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 11:39:39 +02:00
|
|
|
@api.delete("/projects/{name}/files/{filename}")
|
2026-09-07 08:19:24 +02:00
|
|
|
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"}
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 11:39:39 +02:00
|
|
|
app.include_router(api, prefix="/api")
|
|
|
|
|
|
2026-09-07 08:19:24 +02:00
|
|
|
# Serve the frontend last, so it doesn't shadow the /api routes above.
|
|
|
|
|
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
|
2026-09-07 11:39:39 +02:00
|
|
|
|