diff --git a/app/app.py b/app/app.py index bde758d..35e856f 100644 --- a/app/app.py +++ b/app/app.py @@ -200,6 +200,46 @@ def sanitize_filename(name: Optional[str]) -> str: cleaned = ''.join(cleaned_chars).strip() return cleaned or "file" + +def get_safe_subpath(relative_path: Optional[str]) -> str: + """ + From a relative path from the client, return a safe subpath for the filesystem. + This removes the filename, and sanitizes each directory component. + e.g., 'foo/bar/baz.jpg' -> 'foo/bar' + e.g., '../../foo/bar.jpg' -> 'foo' + """ + if not relative_path: + return "" + + # We only want the directory part. + directory_path = os.path.dirname(relative_path) + if not directory_path or directory_path == '.': + return "" + + # Normalize path, especially for windows clients sending '\' + normalized_path = os.path.normpath(directory_path.replace('\\', '/')) + + # Split into components + parts = normalized_path.split('/') + + # Sanitize and filter components + safe_parts = [] + for part in parts: + # No empty parts, no current/parent dir references + if not part or part == '.' or part == '..': + continue + # Remove potentially harmful characters from each part of the path + safe_part = re.sub(r'[<>:"|?*]', '', part).strip().replace('/', '_') + if safe_part: + safe_parts.append(safe_part) + + if not safe_parts: + return "" + + # Using os.path.join to be OS-agnostic for the server's filesystem + return os.path.join(*safe_parts) + + def read_exif_datetimes(file_bytes: bytes): """ Extract EXIF DateTimeOriginal / ModifyDate values when possible. @@ -268,8 +308,8 @@ def _verify_password(stored: str, pw: Optional[str]) -> bool: except Exception: return False -def get_or_create_album_dir(album_name: str, public_subfolder: Optional[str] = None) -> str: - """Get or create a directory for an album. Returns the path.""" +def get_or_create_album_dir(album_name: str, public_subfolder: Optional[str] = None, relative_path: Optional[str] = None) -> str: + """Get or create a directory for an album, including subdirectories. Returns the path.""" if not album_name or not isinstance(album_name, str): album_name = "public" @@ -281,13 +321,16 @@ def get_or_create_album_dir(album_name: str, public_subfolder: Optional[str] = N slug = slugify(public_subfolder) if slug: today = f"{today}-{slug}" - save_dir = os.path.join("./data/uploads", "public", today) + base_save_dir = os.path.join("./data/uploads", "public", today) except Exception as e: logger.warning("Timezone logic failed, falling back to 'public' album. Timezone: %s. Error: %s", SETTINGS.timezone, e) - save_dir = os.path.join("./data/uploads", "public") + base_save_dir = os.path.join("./data/uploads", "public") else: safe_album_name = sanitize_filename(album_name) - save_dir = os.path.join("./data/uploads", "albums", safe_album_name) + base_save_dir = os.path.join("./data/uploads", "albums", safe_album_name) + + safe_subpath = get_safe_subpath(relative_path) + save_dir = os.path.join(base_save_dir, safe_subpath) if safe_subpath else base_save_dir os.makedirs(save_dir, exist_ok=True) return save_dir @@ -380,6 +423,7 @@ async def api_upload( item_id: str = Form(...), session_id: str = Form(...), last_modified: Optional[int] = Form(None), + relative_path: Optional[str] = Form(None), invite_token: Optional[str] = Form(None), fingerprint: Optional[str] = Form(None), public_folder_name: Optional[str] = Form(None), @@ -500,8 +544,8 @@ async def api_upload( await send_progress(session_id, item_id, "error", 100, "Public uploads disabled") return JSONResponse({"error": "public_upload_disabled"}, status_code=403) try: - save_dir = get_or_create_album_dir(album_for_saving, public_folder_name) - safe_name = sanitize_filename(file.filename) + save_dir = get_or_create_album_dir(album_for_saving, public_folder_name, relative_path) + safe_name = sanitize_filename(os.path.basename(file.filename or "file")) save_path = os.path.join(save_dir, safe_name) # Avoid overwriting if os.path.exists(save_path): @@ -604,6 +648,7 @@ async def api_upload_chunk_init(request: Request) -> JSONResponse: meta = { "name": (data or {}).get("name"), "size": (data or {}).get("size"), + "relative_path": (data or {}).get("relative_path"), "last_modified": (data or {}).get("last_modified"), "invite_token": (data or {}).get("invite_token"), "content_type": (data or {}).get("content_type") or "application/octet-stream", @@ -680,6 +725,7 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse: except Exception: meta = {} public_folder_name = meta.get("public_folder_name") + relative_path = meta.get("relative_path") total_chunks = int(meta.get("total_chunks") or (data or {}).get("total_chunks") or 0) if total_chunks <= 0: return JSONResponse({"error": "missing_total"}, status_code=400) @@ -832,8 +878,8 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse: return JSONResponse({"error": "public_upload_disabled"}, status_code=403) try: - save_dir = get_or_create_album_dir(album_for_saving, public_folder_name) - safe_name = sanitize_filename(file_like_name) + save_dir = get_or_create_album_dir(album_for_saving, public_folder_name, relative_path) + safe_name = sanitize_filename(os.path.basename(file_like_name or "file")) save_path = os.path.join(save_dir, safe_name) if os.path.exists(save_path): base, ext = os.path.splitext(safe_name) diff --git a/frontend/app.js b/frontend/app.js index 6168db2..f6a85c6 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -89,7 +89,7 @@ function human(bytes){ function addItem(file){ const id = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(36).slice(2)); - const it = { id, file, name: file.name, size: file.size, status: 'queued', progress: 0 }; + const it = { id, file, name: file.name, size: file.size, status: 'queued', progress: 0, relativePath: file.webkitRelativePath || '' }; items.unshift(it); render(); } @@ -105,7 +105,7 @@ function render(){