feat: Add directory upload support with path preservation

Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
This commit is contained in:
2026-01-20 22:05:32 -07:00
parent 5749597408
commit cc95608364
3 changed files with 63 additions and 14 deletions

View File

@@ -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)