""" Upload router — lets users drop files (or whole folders) from their desktop into a destination Folder, preserving any sub-folder structure they bring with them. Each POST handles one file. The frontend fans out many parallel requests per drop, giving it per-file progress without the server having to invent a chunking protocol. For folder uploads, the browser passes `webkitRelativePath` under the `relative_path` field; any leading sub-directories there are materialised on disk (and as Folder rows) under the destination. Uploaded files are placed under the destination folder on the owner's media mount, indexed immediately (Photo row created), and queued for the same thumb + metadata pipeline that the scanner uses. An optional `heap_id` also drops them into a heap in the same request. """ import hashlib import logging import os import tempfile from pathlib import Path from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from sqlalchemy import insert, select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.dependencies import get_current_user from app.models import Folder, Heap, Photo, SourceRoot from app.models.heaps import heap_photos from app.models.user import User from app.services.date_guess import has_date_warning from app.services.nextcloud_dav import ( ensure_parents_for_user, is_nextcloud_path, put_for_user, ) from app.tasks.scan import SUPPORTED_EXTENSIONS, get_media_type from app.tasks.thumbs import generate_thumbnails from app.services.metadata import extract_metadata logger = logging.getLogger(__name__) router = APIRouter() MAX_UPLOAD_BYTES = 500 * 1024 * 1024 # 500 MB per file cap. def _validate_segment(segment: str) -> str: """Reject path segments that would escape the destination directory.""" segment = segment.strip() if not segment or segment in ('.', '..') or '/' in segment or '\\' in segment: raise HTTPException(status_code=400, detail=f"Invalid path segment: {segment!r}") return segment def _sanitize_relative_path(rel: Optional[str]) -> list[str]: """Split `relative_path` into safe segments (dirs + filename). Empty or missing → []. Any absolute path, backslash, or `..` segment raises 400 — we never want an upload to escape the destination. """ if not rel: return [] # Normalise backslashes to forward slashes; browsers on Windows send # webkitRelativePath with forward slashes anyway, but defend in depth. rel = rel.replace('\\', '/').strip('/') if not rel: return [] segs = [_validate_segment(s) for s in rel.split('/') if s] return segs async def _resolve_destination( folder_id: str, user: User, db: AsyncSession, ) -> Folder: """Resolve `folder_id` to a concrete Folder row the user owns. Accepts both Folder ids and SourceRoot ids (for source roots, we return the Folder row at the mount path — the scanner creates one for every source root it walks). Raises 404 if neither matches. """ folder = (await db.execute( select(Folder).where(Folder.id == folder_id, Folder.user_id == user.id) )).scalar_one_or_none() if folder is not None: return folder sr = (await db.execute( select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == user.id) )).scalar_one_or_none() if sr is None: raise HTTPException(status_code=404, detail="Destination folder not found") root_folder = (await db.execute( select(Folder).where( Folder.source_root_id == sr.id, Folder.user_id == user.id, Folder.path == os.path.normpath(sr.path), ) )).scalar_one_or_none() if root_folder is None: # First-time source root with no walk yet — create the row now so # uploads work even before the initial scan has run. root_folder = Folder( name=sr.name or os.path.basename(sr.path), path=os.path.normpath(sr.path), source_root_id=sr.id, user_id=user.id, ) os.makedirs(root_folder.path, exist_ok=True) db.add(root_folder) await db.flush() return root_folder async def _ensure_subfolder( parent: Folder, name: str, user: User, db: AsyncSession, ) -> Folder: """Return (or create) a Folder row named `name` under `parent`. Also mkdirs the directory on disk. For Nextcloud-rooted paths the directory is created via WebDAV MKCOL so Nextcloud's `oc_filecache` knows about it; otherwise plain `os.makedirs`. Idempotent — safe to call for a path segment that already exists as a Folder row or directory. """ child_path = os.path.normpath(os.path.join(parent.path, name)) existing = (await db.execute( select(Folder).where( Folder.path == child_path, Folder.user_id == user.id, ) )).scalar_one_or_none() if existing is not None: # Materialise the directory if it wasn't already. if is_nextcloud_path(child_path): ensure_parents_for_user(user, child_path) # Also ensure the leaf collection exists; ensure_parents # only handles intermediate dirs. from app.services.nextcloud_dav import mkcol_for_user mkcol_for_user(user, child_path) else: os.makedirs(child_path, exist_ok=True) return existing if is_nextcloud_path(child_path): ensure_parents_for_user(user, child_path) from app.services.nextcloud_dav import mkcol_for_user mkcol_for_user(user, child_path) else: os.makedirs(child_path, exist_ok=True) child = Folder( name=name, path=child_path, parent_id=parent.id, source_root_id=parent.source_root_id, user_id=user.id, is_hidden=parent.is_hidden, ) db.add(child) await db.flush() return child def _unique_path(target_dir: str, filename: str) -> tuple[str, str]: """Return a (filepath, filename) that doesn't collide with an existing file on disk. Suffixes " (2)", " (3)", ... until a free slot is found. Prevents upload-over-existing and keeps the user's original file intact. """ base, ext = os.path.splitext(filename) candidate = os.path.join(target_dir, filename) n = 2 while os.path.exists(candidate): new_name = f"{base} ({n}){ext}" candidate = os.path.join(target_dir, new_name) n += 1 return candidate, os.path.basename(candidate) @router.post("") async def upload_file( file: UploadFile = File(...), destination_folder_id: str = Form(...), relative_path: Optional[str] = Form(None), heap_id: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Upload a single file into a destination folder (and optionally a heap). For folder uploads, `relative_path` carries the sub-folder chain from the browser's `webkitRelativePath`, and we materialise it under the destination on disk + as Folder rows. Returns the created photo's id on success. 4xx on unsupported file type, bad path, missing destination, or too-large file. """ # --- validate inputs ------------------------------------------------- raw_name = file.filename or '' if not raw_name: raise HTTPException(status_code=400, detail="Missing filename") # Prefer the leaf of relative_path when present (it contains the # original filename as the browser saw it inside the picked folder). segs = _sanitize_relative_path(relative_path) if segs: leaf = segs[-1] subdirs = segs[:-1] else: leaf = _validate_segment(os.path.basename(raw_name)) subdirs = [] ext = Path(leaf).suffix.lower() if ext not in SUPPORTED_EXTENSIONS: raise HTTPException( status_code=400, detail=f"Unsupported file type: {ext or '(none)'}", ) dest_folder = await _resolve_destination(destination_folder_id, current_user, db) target_folder = dest_folder for seg in subdirs: target_folder = await _ensure_subfolder(target_folder, seg, current_user, db) target_dir = target_folder.path nc_managed = is_nextcloud_path(target_dir) if not nc_managed: os.makedirs(target_dir, exist_ok=True) # else: target_dir was created via WebDAV MKCOL by _ensure_subfolder filepath, final_name = _unique_path(target_dir, leaf) # --- stream the upload to a tempfile, hashing as we go --------------- # For Nextcloud-managed destinations we then PUT the tempfile to # WebDAV so Nextcloud's oc_filecache + sharing/comment metadata stay # consistent. For local destinations we just rename the tempfile # into place. Either way the hash + size are computed once. hasher = hashlib.sha256() total = 0 tmp_dir = os.path.dirname(filepath) if not nc_managed else None # NamedTemporaryFile in the same directory as filepath when local # (so the final rename is atomic on the same filesystem). For NC, # use the system tmpdir — we re-upload via HTTP either way. tmp = tempfile.NamedTemporaryFile( delete=False, dir=tmp_dir, suffix=".part" ) tmp_path = tmp.name try: try: while True: chunk = await file.read(1024 * 1024) if not chunk: break total += len(chunk) if total > MAX_UPLOAD_BYTES: raise HTTPException( status_code=413, detail=f"File exceeds {MAX_UPLOAD_BYTES // (1024*1024)}MB limit", ) hasher.update(chunk) tmp.write(chunk) finally: tmp.close() if nc_managed: # PUT to Nextcloud WebDAV. The PUT lands the bytes on # `/mnt/library/homecloud//files/` AND # registers the file in oc_filecache, so the desktop sync # client and Nextcloud's web UI both see it. with open(tmp_path, "rb") as body: put_for_user(current_user, filepath, body) else: os.replace(tmp_path, filepath) tmp_path = None # consumed except HTTPException: raise except Exception as e: logger.error(f"Upload write failed for {filepath}: {e}") raise HTTPException(status_code=500, detail=f"Upload failed: {e}") finally: if tmp_path and os.path.exists(tmp_path): try: os.unlink(tmp_path) except OSError: pass if not os.path.exists(filepath): # WebDAV wrote it; the bind mount should reflect it. If it # doesn't, surface a clean error rather than building a Photo # row that points at a missing file. raise HTTPException( status_code=502, detail="Nextcloud accepted the upload but the file isn't visible on the mount yet.", ) file_hash = hasher.hexdigest() # --- validate heap before committing the DB row --------------------- if heap_id: heap = (await db.execute( select(Heap).where(Heap.id == heap_id, Heap.user_id == current_user.id) )).scalar_one_or_none() if heap is None: # Destination heap vanished — still keep the file + photo row, # but tell the caller so the UI can surface the mismatch. heap_id = None # --- create Photo row ------------------------------------------------ mtime_dt = datetime.fromtimestamp(os.stat(filepath).st_mtime) photo = Photo( filepath=filepath, filename=final_name, folder_id=target_folder.id, user_id=current_user.id, file_hash=file_hash, media_type=get_media_type(filepath), original_format=Path(filepath).suffix.upper()[1:], file_size=total, taken_at=mtime_dt, taken_at_source='filesystem', has_date_warning=has_date_warning(filepath, mtime_dt), is_hidden=bool(target_folder.is_hidden), processing_status='pending', ) db.add(photo) await db.flush() if heap_id: await db.execute( insert(heap_photos), [{"heap_id": heap_id, "photo_id": photo.id}], ) await db.commit() # Queue the same background work the scanner does so thumbnails + # EXIF show up without the user having to trigger a rescan. try: generate_thumbnails.delay(photo.id) extract_metadata.delay(photo.id) except Exception as e: logger.warning(f"Failed to queue post-upload tasks for {photo.id}: {e}") return { "photo_id": photo.id, "filename": final_name, "folder_id": target_folder.id, "folder_path": target_folder.path, "heap_id": heap_id, }