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;