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
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`: Ordner auf deiner Festplatte, 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
```
Das ist der Ordner, in dem deine Projekte als normale Unterordner
angelegt werden — den kannst du auch direkt im Dateimanager oder Slicer
ö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:
@@ -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

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

View File

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

View File

@@ -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() {
<h1 class="view-title">Projekte</h1>
<div class="view-subtitle">Lege ein Projekt an, um STL- und 3MF-Dateien zu verwalten.</div>
</div>
<div class="header-actions">
<a class="btn" href="/api/download-all" download>Alle Projekte herunterladen</a>
</div>
`;
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 = `
<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) {
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 ----------
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
</a>
<nav class="crumbs" id="crumbs"></nav>
<button class="logout-link" id="logout-btn" type="button" style="margin-left:auto;">Abmelden</button>
</div>
</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);
}
/* 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);
}