Freeze-Fixed
This commit is contained in:
14
README.md
14
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.
|
||||
|
||||
25
This.env.example
Normal file
25
This.env.example
Normal 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
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,8 +380,30 @@ 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 = `
|
||||
<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 ----------
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user