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

@@ -6,16 +6,25 @@ Browser als 3D-Vorschau betrachten.
## Setup ## Setup
1. `.env.example` nach `.env` kopieren und `PROJECTS_DIR` auf einen Ordner 1. `.env.example` nach `.env` kopieren und zwei Werte setzen:
auf deiner Festplatte setzen, z. B.:
``` - `PROJECTS_DIR`: Ordner auf deiner Festplatte, in dem deine Projekte als
PROJECTS_DIR=/home/deinname/3d-projects 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 PROJECTS_DIR=/home/deinname/3d-projects
öffnen. ```
- `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: 2. Container bauen und starten:
@@ -23,7 +32,23 @@ Browser als 3D-Vorschau betrachten.
docker compose up --build -d 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 ## Wie es funktioniert

10
a.env.example Normal file
View File

@@ -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

View File

@@ -1,23 +1,100 @@
import hashlib
import hmac
import io import io
import os import os
import re import re
import secrets
import shutil import shutil
import time
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import List 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.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
from starlette.middleware.sessions import SessionMiddleware
PROJECTS_DIR = Path(os.environ.get("PROJECTS_DIR", "/data/projects")).resolve() PROJECTS_DIR = Path(os.environ.get("PROJECTS_DIR", "/data/projects")).resolve()
PROJECTS_DIR.mkdir(parents=True, exist_ok=True) 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"} ALLOWED_EXTENSIONS = {".stl", ".3mf"}
NAME_PATTERN = re.compile(r"^[A-Za-z0-9 _\-]+$") 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 = 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: def safe_project_path(name: str) -> Path:
@@ -42,7 +119,7 @@ class ProjectCreate(BaseModel):
name: str name: str
@app.get("/api/projects") @api.get("/projects")
def list_projects(): def list_projects():
projects = [] projects = []
for entry in sorted(PROJECTS_DIR.iterdir()): for entry in sorted(PROJECTS_DIR.iterdir()):
@@ -55,7 +132,7 @@ def list_projects():
return projects return projects
@app.post("/api/projects") @api.post("/projects")
def create_project(payload: ProjectCreate): def create_project(payload: ProjectCreate):
path = safe_project_path(payload.name) path = safe_project_path(payload.name)
if path.exists(): if path.exists():
@@ -64,7 +141,7 @@ def create_project(payload: ProjectCreate):
return {"name": payload.name} return {"name": payload.name}
@app.delete("/api/projects/{name}") @api.delete("/projects/{name}")
def delete_project(name: str): def delete_project(name: str):
path = safe_project_path(name) path = safe_project_path(name)
if not path.exists(): if not path.exists():
@@ -73,7 +150,7 @@ def delete_project(name: str):
return {"status": "deleted"} return {"status": "deleted"}
@app.get("/api/projects/{name}/files") @api.get("/projects/{name}/files")
def list_files(name: str): def list_files(name: str):
path = safe_project_path(name) path = safe_project_path(name)
if not path.exists(): if not path.exists():
@@ -89,7 +166,7 @@ def list_files(name: str):
return files return files
@app.post("/api/projects/{name}/upload") @api.post("/projects/{name}/upload")
async def upload_files(name: str, files: List[UploadFile] = File(...)): async def upload_files(name: str, files: List[UploadFile] = File(...)):
path = safe_project_path(name) path = safe_project_path(name)
if not path.exists(): if not path.exists():
@@ -110,7 +187,7 @@ async def upload_files(name: str, files: List[UploadFile] = File(...)):
return {"saved": saved, "skipped": skipped} 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): def get_file(name: str, filename: str):
path = safe_project_path(name) path = safe_project_path(name)
target = path / safe_filename(filename) target = path / safe_filename(filename)
@@ -119,7 +196,7 @@ def get_file(name: str, filename: str):
return FileResponse(target) return FileResponse(target)
@app.get("/api/download-all") @api.get("/download-all")
def download_all_projects(): def download_all_projects():
projects = [p for p in PROJECTS_DIR.iterdir() if p.is_dir()] projects = [p for p in PROJECTS_DIR.iterdir() if p.is_dir()]
files_found = False 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): def download_project(name: str):
path = safe_project_path(name) path = safe_project_path(name)
if not path.exists(): 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): def delete_file(name: str, filename: str):
path = safe_project_path(name) path = safe_project_path(name)
target = path / safe_filename(filename) target = path / safe_filename(filename)
@@ -177,5 +254,8 @@ def delete_file(name: str, filename: str):
return {"status": "deleted"} return {"status": "deleted"}
app.include_router(api, prefix="/api")
# Serve the frontend last, so it doesn't shadow the /api routes above. # Serve the frontend last, so it doesn't shadow the /api routes above.
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static") app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")

View File

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

View File

@@ -4,6 +4,8 @@ services:
container_name: 3d-project-manager container_name: 3d-project-manager
ports: ports:
- "8080:8080" - "8080:8080"
environment:
- PASSWORD_SHA256=${PASSWORD_SHA256}
volumes: volumes:
# Your projects live here as plain folders on your desktop. # Your projects live here as plain folders on your desktop.
# Set PROJECTS_DIR in the .env file to an absolute path, e.g. # Set PROJECTS_DIR in the .env file to an absolute path, e.g.

View File

@@ -6,6 +6,7 @@ import { ThreeMFLoader } from "three/addons/loaders/3MFLoader.js";
const view = document.getElementById("view"); const view = document.getElementById("view");
const crumbs = document.getElementById("crumbs"); const crumbs = document.getElementById("crumbs");
const homeLink = document.getElementById("home-link"); const homeLink = document.getElementById("home-link");
const logoutBtn = document.getElementById("logout-btn");
const stlLoader = new STLLoader(); const stlLoader = new STLLoader();
const mfLoader = new ThreeMFLoader(); const mfLoader = new ThreeMFLoader();
@@ -37,6 +38,10 @@ function navigateToProject(name) {
async function api(path, options = {}) { async function api(path, options = {}) {
const res = await fetch(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) { if (!res.ok) {
let detail = res.statusText; let detail = res.statusText;
try { try {
@@ -75,9 +80,6 @@ async function renderDashboard() {
<h1 class="view-title">Projekte</h1> <h1 class="view-title">Projekte</h1>
<div class="view-subtitle">Lege ein Projekt an, um STL- und 3MF-Dateien zu verwalten.</div> <div class="view-subtitle">Lege ein Projekt an, um STL- und 3MF-Dateien zu verwalten.</div>
</div> </div>
<div class="header-actions">
<a class="btn" href="/api/download-all" download>Alle Projekte herunterladen</a>
</div>
`; `;
view.appendChild(header); view.appendChild(header);
@@ -225,33 +227,102 @@ async function renderProject(name) {
await loadFileGrid(name, gridContainer); await loadFileGrid(name, gridContainer);
} }
function createProgressBar(afterNode, label) {
const wrap = document.createElement("div");
wrap.className = "upload-progress";
wrap.innerHTML = `
<div class="upload-progress-row">
<span class="upload-progress-name"></span>
<div class="upload-progress-track"><div class="upload-progress-fill"></div></div>
<span class="upload-progress-pct">0%</span>
</div>
`;
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) { async function uploadFiles(projectName, fileList) {
const formData = new FormData(); const formData = new FormData();
let hasValid = false; const names = [];
for (const file of fileList) { for (const file of fileList) {
const ext = "." + file.name.split(".").pop().toLowerCase(); const ext = "." + file.name.split(".").pop().toLowerCase();
if (ext === ".stl" || ext === ".3mf") { if (ext === ".stl" || ext === ".3mf") {
formData.append("files", file); 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); showToast("Nur .stl und .3mf Dateien werden unterstützt.", true);
return; return;
} }
const dropzone = document.querySelector(".dropzone");
const label = names.length === 1 ? names[0] : `${names.length} Dateien`;
const progress = createProgressBar(dropzone, label);
try { try {
const result = await api(`/api/projects/${encodeURIComponent(projectName)}/upload`, { const result = await uploadWithProgress(projectName, formData, (percent) => progress.update(percent));
method: "POST", progress.done();
body: formData,
});
showToast(`${result.saved.length} Datei(en) hochgeladen`); showToast(`${result.saved.length} Datei(en) hochgeladen`);
setTimeout(() => progress.remove(), 600);
const container = document.getElementById("file-grid-container"); const container = document.getElementById("file-grid-container");
if (container) await loadFileGrid(projectName, container); if (container) await loadFileGrid(projectName, container);
} catch (err) { } catch (err) {
progress.remove();
showToast("Upload fehlgeschlagen: " + err.message, true); 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) { async function loadFileGrid(projectName, container) {
disposeViewers(); disposeViewers();
container.innerHTML = ""; container.innerHTML = "";
@@ -436,4 +507,19 @@ function disposeViewers() {
// ---------- Init ---------- // ---------- 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
}
})();

View File

@@ -21,6 +21,7 @@
<span class="wordmark-mark"></span> 3D Project Manager <span class="wordmark-mark"></span> 3D Project Manager
</a> </a>
<nav class="crumbs" id="crumbs"></nav> <nav class="crumbs" id="crumbs"></nav>
<button class="logout-link" id="logout-btn" type="button" style="margin-left:auto;">Abmelden</button>
</div> </div>
</header> </header>

22
static/login.html Normal file
View File

@@ -0,0 +1,22 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Login · 3D Project Manager</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<div class="login-wrap">
<form class="login-card" id="login-form" autocomplete="off">
<div class="login-title"><span class="wordmark-mark"></span> 3D Project Manager</div>
<label class="login-label" for="password">Passwort</label>
<input class="login-input" type="password" id="password" name="password" autofocus required />
<button class="btn login-submit" type="submit">Anmelden</button>
<div class="login-error" id="login-error"></div>
</form>
</div>
<script type="module" src="/login.js"></script>
</body>
</html>

41
static/login.js Normal file
View File

@@ -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;
}
});

View File

@@ -257,6 +257,63 @@ button {
background: rgba(255, 106, 53, 0.06); 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 */
.file-grid { .file-grid {
@@ -380,3 +437,88 @@ button {
border-color: var(--danger); border-color: var(--danger);
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);
}