import io import os import re import shutil import zipfile from pathlib import Path from typing import List from fastapi import FastAPI, HTTPException, UploadFile, File from fastapi.responses import FileResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel PROJECTS_DIR = Path(os.environ.get("PROJECTS_DIR", "/data/projects")).resolve() PROJECTS_DIR.mkdir(parents=True, exist_ok=True) ALLOWED_EXTENSIONS = {".stl", ".3mf"} NAME_PATTERN = re.compile(r"^[A-Za-z0-9 _\-]+$") app = FastAPI(title="3D Project Manager") def safe_project_path(name: str) -> Path: """Validate a project name and return its resolved path, guarding against path traversal / invalid characters.""" if not name or not NAME_PATTERN.match(name): raise HTTPException(400, "Invalid project name") path = (PROJECTS_DIR / name).resolve() if path.parent != PROJECTS_DIR: raise HTTPException(400, "Invalid project name") return path def safe_filename(filename: str) -> str: name = Path(filename).name if not name or name in (".", ".."): raise HTTPException(400, "Invalid file name") return name class ProjectCreate(BaseModel): name: str @app.get("/api/projects") def list_projects(): projects = [] for entry in sorted(PROJECTS_DIR.iterdir()): if entry.is_dir(): file_count = sum( 1 for f in entry.iterdir() if f.is_file() and f.suffix.lower() in ALLOWED_EXTENSIONS ) projects.append({"name": entry.name, "file_count": file_count}) return projects @app.post("/api/projects") def create_project(payload: ProjectCreate): path = safe_project_path(payload.name) if path.exists(): raise HTTPException(409, "A project with this name already exists") path.mkdir(parents=True) return {"name": payload.name} @app.delete("/api/projects/{name}") def delete_project(name: str): path = safe_project_path(name) if not path.exists(): raise HTTPException(404, "Project not found") shutil.rmtree(path) return {"status": "deleted"} @app.get("/api/projects/{name}/files") def list_files(name: str): path = safe_project_path(name) if not path.exists(): raise HTTPException(404, "Project not found") files = [] for f in sorted(path.iterdir()): if f.is_file() and f.suffix.lower() in ALLOWED_EXTENSIONS: files.append({ "name": f.name, "size": f.stat().st_size, "type": f.suffix.lower().lstrip("."), }) return files @app.post("/api/projects/{name}/upload") async def upload_files(name: str, files: List[UploadFile] = File(...)): path = safe_project_path(name) if not path.exists(): raise HTTPException(404, "Project not found") saved, skipped = [], [] for upload in files: ext = Path(upload.filename).suffix.lower() if ext not in ALLOWED_EXTENSIONS: skipped.append(upload.filename) continue filename = safe_filename(upload.filename) dest = path / filename with open(dest, "wb") as out: shutil.copyfileobj(upload.file, out) saved.append(filename) return {"saved": saved, "skipped": skipped} @app.get("/api/projects/{name}/files/{filename}") def get_file(name: str, filename: str): path = safe_project_path(name) target = path / safe_filename(filename) if not target.exists(): raise HTTPException(404, "File not found") return FileResponse(target) @app.get("/api/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: for project in projects: for f in project.iterdir(): if f.is_file() and f.suffix.lower() in ALLOWED_EXTENSIONS: zf.write(f, arcname=f"{project.name}/{f.name}") files_found = True if not files_found: raise HTTPException(404, "No files to download") buffer.seek(0) return StreamingResponse( buffer, media_type="application/zip", headers={"Content-Disposition": 'attachment; filename="alle-projekte.zip"'}, ) @app.get("/api/projects/{name}/download") def download_project(name: str): path = safe_project_path(name) if not path.exists(): raise HTTPException(404, "Project not found") files = [f for f in path.iterdir() if f.is_file() and f.suffix.lower() in ALLOWED_EXTENSIONS] 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: for f in files: zf.write(f, arcname=f.name) buffer.seek(0) zip_filename = f"{name}.zip" return StreamingResponse( buffer, media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'}, ) @app.delete("/api/projects/{name}/files/{filename}") def delete_file(name: str, filename: str): path = safe_project_path(name) target = path / safe_filename(filename) if not target.exists(): raise HTTPException(404, "File not found") target.unlink() return {"status": "deleted"} # Serve the frontend last, so it doesn't shadow the /api routes above. app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")