""" 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 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.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. 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: os.makedirs(child_path, exist_ok=True) return existing 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 os.makedirs(target_dir, exist_ok=True) filepath, final_name = _unique_path(target_dir, leaf) # --- stream to disk, hash as we go ---------------------------------- hasher = hashlib.sha256() total = 0 try: with open(filepath, 'wb') as out: while True: chunk = await file.read(1024 * 1024) if not chunk: break total += len(chunk) if total > MAX_UPLOAD_BYTES: out.close() os.unlink(filepath) raise HTTPException( status_code=413, detail=f"File exceeds {MAX_UPLOAD_BYTES // (1024*1024)}MB limit", ) hasher.update(chunk) out.write(chunk) except HTTPException: raise except Exception as e: logger.error(f"Upload write failed for {filepath}: {e}") if os.path.exists(filepath): try: os.unlink(filepath) except OSError: pass raise HTTPException(status_code=500, detail=f"Upload failed: {e}") 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, }