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:
64
app/app.py
64
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)
|
||||
|
||||
@@ -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(){
|
||||
<div class="rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 shadow-sm transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-medium">${it.name} <span class="text-xs text-gray-500 dark:text-gray-400">(${human(it.size)})</span></div>
|
||||
<div class="truncate font-medium">${it.relativePath || it.name} <span class="text-xs text-gray-500 dark:text-gray-400">(${human(it.size)})</span></div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
${it.message ? `<span>${it.message}</span>` : ''}
|
||||
</div>
|
||||
@@ -223,6 +223,7 @@ async function uploadWhole(next){
|
||||
form.append('item_id', next.id);
|
||||
form.append('session_id', sessionId);
|
||||
form.append('last_modified', next.file.lastModified || '');
|
||||
if (next.relativePath) form.append('relative_path', next.relativePath);
|
||||
if (INVITE_TOKEN) form.append('invite_token', INVITE_TOKEN);
|
||||
const publicFolderName = document.getElementById('publicFolderName')?.value?.trim();
|
||||
if (publicFolderName) form.append('public_folder_name', publicFolderName);
|
||||
@@ -254,6 +255,7 @@ async function uploadChunked(next){
|
||||
item_id: next.id,
|
||||
session_id: sessionId,
|
||||
name: next.file.name,
|
||||
relative_path: next.relativePath || '',
|
||||
size: next.file.size,
|
||||
last_modified: next.file.lastModified || '',
|
||||
invite_token: INVITE_TOKEN || '',
|
||||
|
||||
@@ -34,16 +34,17 @@
|
||||
<!-- upload icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 16V8m0 0l-3 3m3-3 3 3M4 16a4 4 0 0 0 4 4h8a4 4 0 0 0 4-4v-1a1 1 0 1 0-2 0v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-1a1 1 0 1 0-2 0v1z"/></svg>
|
||||
</div>
|
||||
<p class="mt-3 font-medium hidden md:block">Drop files here</p>
|
||||
<p class="mt-3 font-medium hidden md:block">Drop files or a folder here</p>
|
||||
<p class="mb-5 text-sm text-gray-600 dark:text-gray-400 hidden md:block">...or</p>
|
||||
|
||||
<!-- Mobile-safe choose control: label wraps the hidden input -->
|
||||
<div class="relative inline-block">
|
||||
<label class="rounded-2xl bg-black text-white dark:bg-white dark:text-black px-5 py-3 hover:opacity-90 cursor-pointer select-none transition-colors" aria-label="Choose files">
|
||||
Choose files
|
||||
<label class="rounded-2xl bg-black text-white dark:bg-white dark:text-black px-5 py-3 hover:opacity-90 cursor-pointer select-none transition-colors" aria-label="Choose files or a folder">
|
||||
Choose files or a folder
|
||||
<input id="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
webkitdirectory
|
||||
class="absolute inset-0 opacity-0 cursor-pointer" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user