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
|
ü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
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 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)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +380,31 @@ 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 ----------
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user