diff --git a/README.md b/README.md index e5279d2..dcdd333 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,25 @@ Browser als 3D-Vorschau betrachten. ## Setup -1. `.env.example` nach `.env` kopieren und `PROJECTS_DIR` auf einen Ordner - auf deiner Festplatte setzen, z. B.: +1. `.env.example` nach `.env` kopieren und zwei Werte setzen: - ``` - PROJECTS_DIR=/home/deinname/3d-projects - ``` + - `PROJECTS_DIR`: Ordner auf deiner Festplatte, in dem deine Projekte als + normale Unterordner angelegt werden — den kannst du auch direkt im + Dateimanager oder Slicer öffnen. - Das ist der Ordner, in dem deine Projekte als normale Unterordner - angelegt werden — den kannst du auch direkt im Dateimanager oder Slicer - öffnen. + ``` + PROJECTS_DIR=/home/deinname/3d-projects + ``` + + - `PASSWORD_SHA256`: SHA256-Hash deines gewünschten Login-Passworts. + Erzeugen z. B. mit: + + ``` + echo -n "meinPasswort" | sha256sum + ``` + + Nur den resultierenden Hex-Hash in die `.env` eintragen, nicht das + Passwort selbst. 2. Container bauen und starten: @@ -23,7 +32,23 @@ Browser als 3D-Vorschau betrachten. docker compose up --build -d ``` -3. Im Browser öffnen: [http://localhost:8080](http://localhost:8080) +3. Im Browser öffnen: [http://localhost:8080](http://localhost:8080) — + du wirst zur Login-Seite weitergeleitet und musst das Passwort eingeben. + +## Login + +- Es gibt nur ein Passwort, keinen Nutzernamen. Wer das Passwort kennt, hat + Zugriff. +- Nach erfolgreichem Login wird eine signierte, `HttpOnly`-Session-Cookie + gesetzt (30 Tage gültig). Der Signierschlüssel liegt im + `app-settings`-Volume und bleibt über Container-Neustarts hinweg gleich, + d. h. du bleibst eingeloggt. +- "Abmelden" oben rechts löscht die Session. +- Nach 5 Fehlversuchen in Folge wird der Login für 30 Sekunden gesperrt. +- **Wichtig:** Diese Anwendung ist für den Einsatz auf deinem eigenen + Desktop/Heimnetz gedacht. Für Zugriff über das Internet solltest du + zusätzlich HTTPS (z. B. über einen Reverse Proxy) einrichten, da das + Passwort sonst unverschlüsselt übertragen wird. ## Wie es funktioniert diff --git a/a.env.example b/a.env.example new file mode 100644 index 0000000..26635f1 --- /dev/null +++ b/a.env.example @@ -0,0 +1,10 @@ +# Copy this file to .env and set the absolute path to the folder on your +# desktop where your projects (and their STL/3MF files) should live. +# +# Linux/macOS example: +# PROJECTS_DIR=/home/yourname/3d-projects +# +# Windows (Docker Desktop) example: +# PROJECTS_DIR=C:/Users/yourname/3d-projects + +PROJECTS_DIR=/home/yourname/3d-projects diff --git a/backend/main.py b/backend/main.py index 74a66dd..421a480 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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") + diff --git a/backend/requirements.txt b/backend/requirements.txt index ec09873..1d9b379 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,3 +1,4 @@ fastapi==0.115.0 uvicorn[standard]==0.30.6 python-multipart==0.0.9 +itsdangerous==2.2.0 diff --git a/docker-compose.yml b/docker-compose.yml index 4e73ba9..d4ea744 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,8 @@ services: container_name: 3d-project-manager ports: - "8080:8080" + environment: + - PASSWORD_SHA256=${PASSWORD_SHA256} volumes: # Your projects live here as plain folders on your desktop. # Set PROJECTS_DIR in the .env file to an absolute path, e.g. diff --git a/static/app.js b/static/app.js index f29fd1e..fcd8370 100644 --- a/static/app.js +++ b/static/app.js @@ -6,6 +6,7 @@ import { ThreeMFLoader } from "three/addons/loaders/3MFLoader.js"; const view = document.getElementById("view"); const crumbs = document.getElementById("crumbs"); const homeLink = document.getElementById("home-link"); +const logoutBtn = document.getElementById("logout-btn"); const stlLoader = new STLLoader(); const mfLoader = new ThreeMFLoader(); @@ -37,6 +38,10 @@ function navigateToProject(name) { async function api(path, options = {}) { const res = await fetch(path, options); + if (res.status === 401) { + window.location.href = "/login.html"; + throw new Error("Nicht angemeldet"); + } if (!res.ok) { let detail = res.statusText; try { @@ -75,9 +80,6 @@ async function renderDashboard() {