From 1004b4ab7f2c1dd4c947a4cca3b9f82e81798e50 Mon Sep 17 00:00:00 2001 From: Tanner Collin Date: Tue, 6 Jan 2026 16:15:46 -0700 Subject: [PATCH] feat: Add optional public upload folder naming Co-authored-by: aider (gemini/gemini-2.5-pro) --- app/app.py | 27 ++++++++++++++++++++++++--- frontend/app.js | 6 +++++- frontend/index.html | 2 ++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/app/app.py b/app/app.py index 7c9c309..bde758d 100644 --- a/app/app.py +++ b/app/app.py @@ -13,6 +13,7 @@ from __future__ import annotations import asyncio import io import json +import re import hashlib import os import sqlite3 @@ -226,6 +227,19 @@ def read_exif_datetimes(file_bytes: bytes): return created, modified +def slugify(value: Optional[str]) -> str: + """ + Normalizes string, converts to lowercase, removes non-alpha characters, + and converts spaces to hyphens. Max length is 64. + """ + if not value: + return "" + value = str(value).strip()[:64] + value = re.sub(r'[^\w\s-]', '', value).strip().lower() + value = re.sub(r'[-\s]+', '-', value) + return value.strip('-') + + def _hash_password(pw: str) -> str: """Return PBKDF2-SHA256 hash of a password.""" try: @@ -254,7 +268,7 @@ def _verify_password(stored: str, pw: Optional[str]) -> bool: except Exception: return False -def get_or_create_album_dir(album_name: str) -> str: +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.""" if not album_name or not isinstance(album_name, str): album_name = "public" @@ -263,6 +277,10 @@ def get_or_create_album_dir(album_name: str) -> str: try: tz = pytz.timezone(SETTINGS.timezone) today = datetime.now(tz).strftime('%Y-%m-%d') + if public_subfolder: + slug = slugify(public_subfolder) + if slug: + today = f"{today}-{slug}" 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) @@ -364,6 +382,7 @@ async def api_upload( last_modified: Optional[int] = Form(None), invite_token: Optional[str] = Form(None), fingerprint: Optional[str] = Form(None), + public_folder_name: Optional[str] = Form(None), ): """Receive a file, check duplicates, forward to Immich; stream progress via WS.""" raw = await file.read() @@ -481,7 +500,7 @@ 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) + save_dir = get_or_create_album_dir(album_for_saving, public_folder_name) safe_name = sanitize_filename(file.filename) save_path = os.path.join(save_dir, safe_name) # Avoid overwriting @@ -588,6 +607,7 @@ async def api_upload_chunk_init(request: Request) -> JSONResponse: "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", + "public_folder_name": (data or {}).get("public_folder_name"), "created_at": datetime.utcnow().isoformat(), } with open(os.path.join(d, "meta.json"), "w", encoding="utf-8") as f: @@ -659,6 +679,7 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse: meta = json.load(f) except Exception: meta = {} + public_folder_name = meta.get("public_folder_name") 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) @@ -811,7 +832,7 @@ 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) + save_dir = get_or_create_album_dir(album_for_saving, public_folder_name) safe_name = sanitize_filename(file_like_name) save_path = os.path.join(save_dir, safe_name) if os.path.exists(save_path): diff --git a/frontend/app.js b/frontend/app.js index 5f1a7df..0f8cfe0 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -219,6 +219,8 @@ async function uploadWhole(next){ form.append('session_id', sessionId); form.append('last_modified', next.file.lastModified || ''); if (INVITE_TOKEN) form.append('invite_token', INVITE_TOKEN); + const publicFolderName = document.getElementById('publicFolderName')?.value?.trim(); + if (publicFolderName) form.append('public_folder_name', publicFolderName); form.append('fingerprint', FINGERPRINT); const res = await fetch('/api/upload', { method:'POST', body: form }); const body = await res.json().catch(()=>({})); @@ -240,6 +242,7 @@ async function uploadWhole(next){ async function uploadChunked(next){ const chunkBytes = Math.max(1, CFG.chunk_size_mb|0) * 1024 * 1024; const total = Math.ceil(next.file.size / chunkBytes) || 1; + const publicFolderName = document.getElementById('publicFolderName')?.value?.trim(); // init try { await fetch('/api/upload/chunk/init', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({ @@ -250,7 +253,8 @@ async function uploadChunked(next){ last_modified: next.file.lastModified || '', invite_token: INVITE_TOKEN || '', content_type: next.file.type || 'application/octet-stream', - fingerprint: FINGERPRINT + fingerprint: FINGERPRINT, + public_folder_name: publicFolderName || '' }) }); } catch {} // upload parts diff --git a/frontend/index.html b/frontend/index.html index 4230ead..4abfb6a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -45,6 +45,8 @@
+ +