Freeze-Fixed

This commit is contained in:
2026-09-07 11:53:19 +02:00
parent d0e022484e
commit f38da358ad
5 changed files with 122 additions and 19 deletions

View File

@@ -80,6 +80,20 @@ Reine Ordner mit deinen Dateien — du kannst sie auch außerhalb der App
über den Dateimanager wirken sich beim nächsten Laden der Seite ebenfalls über den Dateimanager wirken sich beim nächsten Laden der Seite ebenfalls
auf die App aus. auf die App aus.
## Große Dateien
- Uploads laufen serverseitig in einem Hintergrund-Thread, damit auch bei
sehr großen Dateien (mehrere GB) der Rest der App währenddessen normal
bedienbar bleibt.
- ZIP-Downloads (einzelnes Projekt oder alle Projekte) werden auf eine
temporäre Datei auf der Festplatte geschrieben statt komplett im
Arbeitsspeicher gehalten zu werden, und danach automatisch wieder
gelöscht.
- Für Dateien über **150 MB** wird die 3D-Vorschau nicht automatisch
geladen (das würde bei sehr großen Modellen den Browser-Tab einfrieren
lassen), sondern zeigt einen "Vorschau laden"-Button an — du entscheidest
bewusst, ob der Browser versuchen soll, das Modell zu parsen.
## Bekannte Grenzen (erster Wurf) ## Bekannte Grenzen (erster Wurf)
- Single-User, kein Login. - Single-User, kein Login.

25
This.env.example Normal file
View File

@@ -0,0 +1,25 @@
# 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
# SHA256 hash of the login password. Generate it with (no quotes around the
# password, mind trailing newlines/spaces):
#
# Linux/macOS:
# echo -n "meinPasswort" | sha256sum
#
# Windows (PowerShell):
# $hash = [System.Security.Cryptography.SHA256]::Create().ComputeHash([System.Text.Encoding]::UTF8.GetBytes("meinPasswort"))
# ($hash | ForEach-Object { $_.ToString("x2") }) -join ""
#
# Paste only the resulting hex hash below, e.g.:
# PASSWORD_SHA256=5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d
PASSWORD_SHA256=changeme

View File

@@ -1,19 +1,21 @@
import hashlib import hashlib
import hmac import hmac
import io
import os import os
import re import re
import secrets import secrets
import shutil import shutil
import tempfile
import time 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 APIRouter, Depends, FastAPI, HTTPException, Request, UploadFile, File from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, UploadFile, File
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
from starlette.background import BackgroundTask
from starlette.concurrency import run_in_threadpool
from starlette.middleware.sessions import SessionMiddleware 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()
@@ -180,13 +182,19 @@ async def upload_files(name: str, files: List[UploadFile] = File(...)):
continue continue
filename = safe_filename(upload.filename) filename = safe_filename(upload.filename)
dest = path / filename dest = path / filename
with open(dest, "wb") as out: # Run the actual (blocking) disk copy in a worker thread so a large
shutil.copyfileobj(upload.file, out) # file doesn't freeze the whole server for the duration of the copy.
await run_in_threadpool(_copy_upload_to_disk, upload.file, dest)
saved.append(filename) saved.append(filename)
return {"saved": saved, "skipped": skipped} return {"saved": saved, "skipped": skipped}
def _copy_upload_to_disk(src_file, dest_path: Path) -> None:
with open(dest_path, "wb") as out:
shutil.copyfileobj(src_file, out, length=4 * 1024 * 1024)
@api.get("/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)
@@ -199,10 +207,13 @@ def get_file(name: str, filename: str):
@api.get("/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
buffer = io.BytesIO() tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: tmp_path = Path(tmp.name)
tmp.close()
files_found = False
with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
for project in projects: for project in projects:
for f in project.iterdir(): for f in project.iterdir():
if f.is_file() and f.suffix.lower() in ALLOWED_EXTENSIONS: if f.is_file() and f.suffix.lower() in ALLOWED_EXTENSIONS:
@@ -210,13 +221,14 @@ def download_all_projects():
files_found = True files_found = True
if not files_found: if not files_found:
tmp_path.unlink(missing_ok=True)
raise HTTPException(404, "No files to download") raise HTTPException(404, "No files to download")
buffer.seek(0) return FileResponse(
return StreamingResponse( tmp_path,
buffer,
media_type="application/zip", media_type="application/zip",
headers={"Content-Disposition": 'attachment; filename="alle-projekte.zip"'}, filename="alle-projekte.zip",
background=BackgroundTask(lambda: tmp_path.unlink(missing_ok=True)),
) )
@@ -230,17 +242,19 @@ def download_project(name: str):
if not files: if not files:
raise HTTPException(404, "Project has no files to download") raise HTTPException(404, "Project has no files to download")
buffer = io.BytesIO() tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: tmp_path = Path(tmp.name)
tmp.close()
with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
for f in files: for f in files:
zf.write(f, arcname=f.name) zf.write(f, arcname=f.name)
buffer.seek(0)
zip_filename = f"{name}.zip" return FileResponse(
return StreamingResponse( tmp_path,
buffer,
media_type="application/zip", media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'}, filename=f"{name}.zip",
background=BackgroundTask(lambda: tmp_path.unlink(missing_ok=True)),
) )

View File

@@ -11,6 +11,11 @@ const logoutBtn = document.getElementById("logout-btn");
const stlLoader = new STLLoader(); const stlLoader = new STLLoader();
const mfLoader = new ThreeMFLoader(); const mfLoader = new ThreeMFLoader();
// Files above this size are not auto-loaded into a 3D preview, since
// parsing a huge mesh can freeze the browser tab. The user can opt in
// via a button instead.
const PREVIEW_SIZE_LIMIT = 150 * 1024 * 1024; // 150 MB
let currentProject = null; let currentProject = null;
const activeViewers = []; // track for cleanup / resize const activeViewers = []; // track for cleanup / resize
@@ -375,8 +380,30 @@ async function loadFileGrid(projectName, container) {
grid.appendChild(card); grid.appendChild(card);
const canvasHost = card.querySelector(".file-card-canvas"); const canvasHost = card.querySelector(".file-card-canvas");
initViewer(canvasHost, `/api/projects/${encodeURIComponent(projectName)}/files/${encodeURIComponent(file.name)}`, file.type); const fileUrlForViewer = `/api/projects/${encodeURIComponent(projectName)}/files/${encodeURIComponent(file.name)}`;
if (file.size > PREVIEW_SIZE_LIMIT) {
renderLazyPreviewPlaceholder(canvasHost, fileUrlForViewer, file.type, file.size);
} else {
initViewer(canvasHost, fileUrlForViewer, file.type);
} }
}
}
function renderLazyPreviewPlaceholder(host, url, type, size) {
host.style.position = "relative";
const wrap = document.createElement("div");
wrap.className = "preview-placeholder";
wrap.innerHTML = `
<span class="preview-placeholder-text">Große Datei (${formatSize(size)})<br />keine automatische Vorschau</span>
<button class="btn preview-load-btn" type="button">Vorschau laden</button>
`;
host.appendChild(wrap);
wrap.querySelector(".preview-load-btn").addEventListener("click", () => {
wrap.remove();
initViewer(host, url, type);
});
} }
// ---------- Three.js viewer ---------- // ---------- Three.js viewer ----------

View File

@@ -348,6 +348,29 @@ button {
height: 100%; height: 100%;
} }
.preview-placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 16px;
text-align: center;
}
.preview-placeholder-text {
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
}
.preview-load-btn {
padding: 7px 14px;
font-size: 13px;
}
.file-card-info { .file-card-info {
padding: 12px 14px; padding: 12px 14px;
display: flex; display: flex;