From f38da358ad6361967d11402940dfec58d7c0eac6 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 7 Sep 2026 11:53:19 +0200 Subject: [PATCH] Freeze-Fixed --- README.md | 14 ++++++++++++++ This.env.example | 25 ++++++++++++++++++++++++ backend/main.py | 50 +++++++++++++++++++++++++++++++----------------- static/app.js | 29 +++++++++++++++++++++++++++- static/style.css | 23 ++++++++++++++++++++++ 5 files changed, 122 insertions(+), 19 deletions(-) create mode 100644 This.env.example diff --git a/README.md b/README.md index dcdd333..0c8e674 100644 --- a/README.md +++ b/README.md @@ -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 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) - Single-User, kein Login. diff --git a/This.env.example b/This.env.example new file mode 100644 index 0000000..ab39d6e --- /dev/null +++ b/This.env.example @@ -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 diff --git a/backend/main.py b/backend/main.py index 421a480..97b39f9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,19 +1,21 @@ import hashlib import hmac -import io import os import re import secrets import shutil +import tempfile import time import zipfile from pathlib import Path from typing import List 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 pydantic import BaseModel +from starlette.background import BackgroundTask +from starlette.concurrency import run_in_threadpool from starlette.middleware.sessions import SessionMiddleware 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 filename = safe_filename(upload.filename) dest = path / filename - with open(dest, "wb") as out: - shutil.copyfileobj(upload.file, out) + # Run the actual (blocking) disk copy in a worker thread so a large + # 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) 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}") def get_file(name: str, filename: str): path = safe_project_path(name) @@ -199,10 +207,13 @@ def get_file(name: str, filename: str): @api.get("/download-all") def download_all_projects(): projects = [p for p in PROJECTS_DIR.iterdir() if p.is_dir()] - files_found = False - buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".zip") + 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 f in project.iterdir(): if f.is_file() and f.suffix.lower() in ALLOWED_EXTENSIONS: @@ -210,13 +221,14 @@ def download_all_projects(): files_found = True if not files_found: + tmp_path.unlink(missing_ok=True) raise HTTPException(404, "No files to download") - buffer.seek(0) - return StreamingResponse( - buffer, + return FileResponse( + tmp_path, 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: raise HTTPException(404, "Project has no files to download") - buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".zip") + tmp_path = Path(tmp.name) + tmp.close() + + with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf: for f in files: zf.write(f, arcname=f.name) - buffer.seek(0) - zip_filename = f"{name}.zip" - return StreamingResponse( - buffer, + return FileResponse( + tmp_path, 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)), ) diff --git a/static/app.js b/static/app.js index fcd8370..64cec96 100644 --- a/static/app.js +++ b/static/app.js @@ -11,6 +11,11 @@ const logoutBtn = document.getElementById("logout-btn"); const stlLoader = new STLLoader(); 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; const activeViewers = []; // track for cleanup / resize @@ -375,10 +380,32 @@ async function loadFileGrid(projectName, container) { grid.appendChild(card); 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 = ` + Große Datei (${formatSize(size)})
keine automatische Vorschau
+ + `; + host.appendChild(wrap); + + wrap.querySelector(".preview-load-btn").addEventListener("click", () => { + wrap.remove(); + initViewer(host, url, type); + }); +} + // ---------- Three.js viewer ---------- function initViewer(host, url, type) { diff --git a/static/style.css b/static/style.css index 603ddd3..6aecb2d 100644 --- a/static/style.css +++ b/static/style.css @@ -348,6 +348,29 @@ button { 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 { padding: 12px 14px; display: flex;