feat: Add optional public upload folder naming
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
This commit is contained in:
27
app/app.py
27
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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<label for="publicFolderName" class="font-medium">Optional folder name:</label>
|
||||
<input type="text" id="publicFolderName" name="publicFolderName" maxlength="64" placeholder="e.g., Soccer game" class="mt-1 mx-auto block w-full max-w-sm rounded-lg border border-gray-300 bg-gray-50 p-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user