Login
This commit is contained in:
37
README.md
37
README.md
@@ -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
|
||||||
|
normale Unterordner angelegt werden — den kannst du auch direkt im
|
||||||
|
Dateimanager oder Slicer öffnen.
|
||||||
|
|
||||||
```
|
```
|
||||||
PROJECTS_DIR=/home/deinname/3d-projects
|
PROJECTS_DIR=/home/deinname/3d-projects
|
||||||
```
|
```
|
||||||
|
|
||||||
Das ist der Ordner, in dem deine Projekte als normale Unterordner
|
- `PASSWORD_SHA256`: SHA256-Hash deines gewünschten Login-Passworts.
|
||||||
angelegt werden — den kannst du auch direkt im Dateimanager oder Slicer
|
Erzeugen z. B. mit:
|
||||||
öffnen.
|
|
||||||
|
```
|
||||||
|
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
10
a.env.example
Normal 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
|
||||||
100
backend/main.py
100
backend/main.py
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
106
static/app.js
106
static/app.js
@@ -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 ----------
|
||||||
|
|
||||||
|
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();
|
renderDashboard();
|
||||||
|
} catch (err) {
|
||||||
|
// api() already redirects to /login.html on 401
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|||||||
@@ -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
22
static/login.html
Normal 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
41
static/login.js
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
142
static/style.css
142
static/style.css
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user