From d0e022484ec8ff9f6a1e7982569d2fd9ac9f91f8 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 7 Sep 2026 11:39:39 +0200 Subject: [PATCH] Login --- README.md | 43 +++++++++--- a.env.example | 10 +++ backend/main.py | 100 ++++++++++++++++++++++++--- backend/requirements.txt | 1 + docker-compose.yml | 2 + static/app.js | 108 ++++++++++++++++++++++++++--- static/index.html | 1 + static/login.html | 22 ++++++ static/login.js | 41 +++++++++++ static/style.css | 142 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 440 insertions(+), 30 deletions(-) create mode 100644 a.env.example create mode 100644 static/login.html create mode 100644 static/login.js 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() {

Projekte

Lege ein Projekt an, um STL- und 3MF-Dateien zu verwalten.
-
- Alle Projekte herunterladen -
`; view.appendChild(header); @@ -225,33 +227,102 @@ async function renderProject(name) { await loadFileGrid(name, gridContainer); } +function createProgressBar(afterNode, label) { + const wrap = document.createElement("div"); + wrap.className = "upload-progress"; + wrap.innerHTML = ` +
+ +
+ 0% +
+ `; + wrap.querySelector(".upload-progress-name").textContent = label; + afterNode.insertAdjacentElement("afterend", wrap); + + const fill = wrap.querySelector(".upload-progress-fill"); + const pct = wrap.querySelector(".upload-progress-pct"); + + return { + update(percent) { + fill.style.width = `${percent}%`; + pct.textContent = `${percent}%`; + }, + done() { + fill.style.width = "100%"; + fill.classList.add("is-done"); + pct.textContent = "100%"; + }, + remove() { + wrap.remove(); + }, + }; +} + async function uploadFiles(projectName, fileList) { const formData = new FormData(); - let hasValid = false; + const names = []; for (const file of fileList) { const ext = "." + file.name.split(".").pop().toLowerCase(); if (ext === ".stl" || ext === ".3mf") { formData.append("files", file); - hasValid = true; + names.push(file.name); } } - if (!hasValid) { + if (!names.length) { showToast("Nur .stl und .3mf Dateien werden unterstützt.", true); return; } + + const dropzone = document.querySelector(".dropzone"); + const label = names.length === 1 ? names[0] : `${names.length} Dateien`; + const progress = createProgressBar(dropzone, label); + try { - const result = await api(`/api/projects/${encodeURIComponent(projectName)}/upload`, { - method: "POST", - body: formData, - }); + const result = await uploadWithProgress(projectName, formData, (percent) => progress.update(percent)); + progress.done(); showToast(`${result.saved.length} Datei(en) hochgeladen`); + setTimeout(() => progress.remove(), 600); const container = document.getElementById("file-grid-container"); if (container) await loadFileGrid(projectName, container); } catch (err) { + progress.remove(); showToast("Upload fehlgeschlagen: " + err.message, true); } } +function uploadWithProgress(projectName, formData, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", `/api/projects/${encodeURIComponent(projectName)}/upload`); + + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable) { + onProgress(Math.round((e.loaded / e.total) * 100)); + } + }); + + xhr.addEventListener("load", () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(JSON.parse(xhr.responseText)); + } catch (err) { + reject(new Error("Ungültige Antwort vom Server")); + } + } else { + let message = xhr.statusText; + try { + message = JSON.parse(xhr.responseText).detail || message; + } catch (_) {} + reject(new Error(message)); + } + }); + + xhr.addEventListener("error", () => reject(new Error("Netzwerkfehler"))); + xhr.send(formData); + }); +} + async function loadFileGrid(projectName, container) { disposeViewers(); container.innerHTML = ""; @@ -436,4 +507,19 @@ function disposeViewers() { // ---------- Init ---------- -renderDashboard(); +logoutBtn.addEventListener("click", async () => { + try { + await fetch("/api/logout", { method: "POST" }); + } finally { + window.location.href = "/login.html"; + } +}); + +(async function init() { + try { + await api("/api/me"); + renderDashboard(); + } catch (err) { + // api() already redirects to /login.html on 401 + } +})(); diff --git a/static/index.html b/static/index.html index 4339058..cc73935 100644 --- a/static/index.html +++ b/static/index.html @@ -21,6 +21,7 @@ 3D Project Manager + diff --git a/static/login.html b/static/login.html new file mode 100644 index 0000000..bad22d2 --- /dev/null +++ b/static/login.html @@ -0,0 +1,22 @@ + + + + + + Login · 3D Project Manager + + + +
+ +
+ + + + diff --git a/static/login.js b/static/login.js new file mode 100644 index 0000000..3edf90b --- /dev/null +++ b/static/login.js @@ -0,0 +1,41 @@ +const form = document.getElementById("login-form"); +const passwordInput = document.getElementById("password"); +const errorEl = document.getElementById("login-error"); + +// If already logged in, skip straight to the app. +fetch("/api/me").then((res) => { + if (res.ok) window.location.href = "/"; +}); + +form.addEventListener("submit", async (e) => { + e.preventDefault(); + errorEl.textContent = ""; + + const submitBtn = form.querySelector(".login-submit"); + submitBtn.disabled = true; + + try { + const res = await fetch("/api/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: passwordInput.value }), + }); + + if (res.ok) { + window.location.href = "/"; + return; + } + + if (res.status === 429) { + errorEl.textContent = "Zu viele Fehlversuche. Bitte kurz warten."; + } else { + errorEl.textContent = "Falsches Passwort."; + } + passwordInput.value = ""; + passwordInput.focus(); + } catch (err) { + errorEl.textContent = "Verbindung fehlgeschlagen."; + } finally { + submitBtn.disabled = false; + } +}); diff --git a/static/style.css b/static/style.css index 775a9f3..603ddd3 100644 --- a/static/style.css +++ b/static/style.css @@ -257,6 +257,63 @@ button { background: rgba(255, 106, 53, 0.06); } +/* Upload progress */ + +.upload-progress { + margin-bottom: 24px; +} + +.upload-progress-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.upload-progress-row:last-child { + margin-bottom: 0; +} + +.upload-progress-name { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-muted); + width: 160px; + flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.upload-progress-track { + flex: 1; + height: 6px; + border-radius: 3px; + background: var(--panel-alt); + border: 1px solid var(--border); + overflow: hidden; +} + +.upload-progress-fill { + height: 100%; + width: 0%; + background: var(--accent); + transition: width 0.15s ease; +} + +.upload-progress-fill.is-done { + background: var(--accent-teal, #3fa796); +} + +.upload-progress-pct { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-muted); + width: 38px; + text-align: right; + flex-shrink: 0; +} + /* File grid */ .file-grid { @@ -380,3 +437,88 @@ button { border-color: var(--danger); color: var(--danger); } + +/* Login page */ + +.login-wrap { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.login-card { + width: 100%; + max-width: 320px; + border: 1px solid var(--border); + background: var(--panel); + border-radius: var(--radius); + padding: 28px 24px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.login-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 6px; +} + +.login-label { + font-size: 13px; + color: var(--text-muted); +} + +.login-input { + background: var(--panel-alt); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + padding: 10px 12px; + font-size: 14px; + font-family: inherit; +} + +.login-input:focus { + outline: none; + border-color: var(--accent); +} + +.login-submit { + margin-top: 6px; + background: var(--accent); + border-color: var(--accent); + color: #14171a; + font-weight: 600; +} + +.login-submit:hover { + background: var(--accent); + opacity: 0.9; +} + +.login-submit:disabled { + opacity: 0.6; + cursor: default; +} + +.login-error { + color: var(--danger); + font-size: 13px; + min-height: 16px; +} + +.logout-link { + color: var(--text-muted); + font-size: 13px; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + padding: 6px 12px; +} + +.logout-link:hover { + color: var(--text); + border-color: var(--accent); +}