Refactor: Replace Immich integration with local file storage and admin auth

Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
This commit is contained in:
2025-11-22 20:15:12 -07:00
parent 2275774a46
commit 78452b93ef
2 changed files with 211 additions and 385 deletions

View File

@@ -19,8 +19,6 @@ import sqlite3
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional from typing import Dict, List, Optional
import requests
from requests_toolbelt.multipart.encoder import MultipartEncoder, MultipartEncoderMonitor
import logging import logging
from fastapi import FastAPI, UploadFile, WebSocket, WebSocketDisconnect, Request, Form from fastapi import FastAPI, UploadFile, WebSocket, WebSocketDisconnect, Request, Form
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse, Response from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse, Response
@@ -66,13 +64,6 @@ try:
except Exception: except Exception:
pass pass
# Album cache
ALBUM_ID: Optional[str] = None
def reset_album_cache() -> None:
"""Invalidate the cached Immich album id so next use re-resolves it."""
global ALBUM_ID
ALBUM_ID = None
# ---------- DB (local dedupe cache) ---------- # ---------- DB (local dedupe cache) ----------
@@ -399,13 +390,7 @@ async def favicon() -> Response:
@app.post("/api/ping") @app.post("/api/ping")
async def api_ping() -> dict: async def api_ping() -> dict:
"""Connectivity test endpoint used by the UI to display a temporary banner.""" """Connectivity test endpoint used by the UI to display a temporary banner."""
if SETTINGS.local_save_only: return { "ok": True, "base_url": "Local Save Mode", "album_name": None }
return { "ok": True, "base_url": "Local Save Mode", "album_name": None }
return {
"ok": immich_ping(),
"base_url": SETTINGS.normalized_base_url,
"album_name": SETTINGS.album_name if SETTINGS.album_name else None
}
@app.get("/api/config") @app.get("/api/config")
async def api_config() -> dict: async def api_config() -> dict:
@@ -426,10 +411,6 @@ async def ws_endpoint(ws: WebSocket) -> None:
session_id = data.get("session_id") or "default" session_id = data.get("session_id") or "default"
except Exception: except Exception:
session_id = "default" session_id = "default"
# If this is the first socket for a (possibly new) session, reset album cache
# so a freshly opened page can rotate the drop album by renaming the old one.
if session_id not in hub.sessions:
reset_album_cache()
await hub.connect(session_id, ws) await hub.connect(session_id, ws)
# keepalive to avoid proxy idle timeouts # keepalive to avoid proxy idle timeouts
@@ -477,54 +458,8 @@ async def api_upload(
await send_progress(session_id, item_id, "duplicate", 100, "Already uploaded from this device (local cache)") await send_progress(session_id, item_id, "duplicate", 100, "Already uploaded from this device (local cache)")
return JSONResponse({"status": "duplicate", "id": None}, status_code=200) return JSONResponse({"status": "duplicate", "id": None}, status_code=200)
if SETTINGS.local_save_only:
try:
save_dir = "./data/uploads"
os.makedirs(save_dir, exist_ok=True)
safe_name = sanitize_filename(file.filename)
save_path = os.path.join(save_dir, safe_name)
# Avoid overwriting when filenames collide (not same as duplicate)
if os.path.exists(save_path):
base, ext = os.path.splitext(safe_name)
i = 1
while os.path.exists(save_path):
save_path = os.path.join(save_dir, f"{base}_{i}{ext}")
i += 1
with open(save_path, "wb") as f:
f.write(raw)
db_insert_upload(checksum, file.filename, size, device_asset_id, None, created_iso)
await send_progress(session_id, item_id, "done", 100, f"Saved locally to {os.path.basename(save_path)}")
return JSONResponse({"status": "done", "id": None}, status_code=200)
except Exception as e:
logger.exception("Local save failed: %s", e)
await send_progress(session_id, item_id, "error", 100, "Failed to save file locally")
return JSONResponse({"error": "local_save_failed"}, status_code=500)
await send_progress(session_id, item_id, "checking", 2, "Checking duplicates…")
bulk = immich_bulk_check([{"id": item_id, "checksum": checksum}])
if bulk.get(item_id, {}).get("action") == "reject" and bulk[item_id].get("reason") == "duplicate":
asset_id = bulk[item_id].get("assetId")
db_insert_upload(checksum, file.filename, size, device_asset_id, asset_id, created_iso)
await send_progress(session_id, item_id, "duplicate", 100, "Duplicate (server)", asset_id)
return JSONResponse({"status": "duplicate", "id": asset_id}, status_code=200)
safe_name = sanitize_filename(file.filename)
def gen_encoder() -> MultipartEncoder:
return MultipartEncoder(fields={
"assetData": (safe_name, io.BytesIO(raw), file.content_type or "application/octet-stream"),
"deviceAssetId": device_asset_id,
"deviceId": f"python-{session_id}",
"fileCreatedAt": created_iso,
"fileModifiedAt": modified_iso,
"isFavorite": "false",
"filename": safe_name,
"originalFileName": safe_name,
})
encoder = gen_encoder()
# Invite token validation (if provided) # Invite token validation (if provided)
target_album_id: Optional[str] = None
target_album_name: Optional[str] = None target_album_name: Optional[str] = None
if invite_token: if invite_token:
try: try:
@@ -612,111 +547,91 @@ async def api_upload(
if (used_count or 0) >= (max_uses_int if max_uses_int >= 0 else 10**9): if (used_count or 0) >= (max_uses_int if max_uses_int >= 0 else 10**9):
await send_progress(session_id, item_id, "error", 100, "Invite already used up") await send_progress(session_id, item_id, "error", 100, "Invite already used up")
return JSONResponse({"error": "invite_exhausted"}, status_code=403) return JSONResponse({"error": "invite_exhausted"}, status_code=403)
target_album_id = album_id
target_album_name = album_name target_album_name = album_name
async def do_upload(): album_for_saving = target_album_name if invite_token else "public"
await send_progress(session_id, item_id, "uploading", 0, "Uploading…") if not invite_token and not SETTINGS.public_upload_page_enabled:
sent = {"pct": 0} await send_progress(session_id, item_id, "error", 100, "Public uploads disabled")
def cb(monitor: MultipartEncoderMonitor) -> None: return JSONResponse({"error": "public_upload_disabled"}, status_code=403)
if monitor.len: try:
pct = int(monitor.bytes_read * 100 / monitor.len) save_dir = get_or_create_album_dir(album_for_saving)
if pct != sent["pct"]: safe_name = sanitize_filename(file.filename)
sent["pct"] = pct save_path = os.path.join(save_dir, safe_name)
asyncio.create_task(send_progress(session_id, item_id, "uploading", pct)) # Avoid overwriting
monitor = MultipartEncoderMonitor(encoder, cb) if os.path.exists(save_path):
headers = {"Accept": "application/json", "Content-Type": monitor.content_type, "x-immich-checksum": checksum, **immich_headers(request)} base, ext = os.path.splitext(safe_name)
i = 1
while os.path.exists(save_path):
save_path = os.path.join(save_dir, f"{base}_{i}{ext}")
i += 1
with open(save_path, "wb") as f:
f.write(raw)
db_insert_upload(checksum, file.filename, size, device_asset_id, None, created_iso)
msg = f"Saved to {album_for_saving}/{os.path.basename(save_path)}"
await send_progress(session_id, item_id, "done", 100, msg)
# Increment invite usage on success
if invite_token:
try:
conn2 = sqlite3.connect(SETTINGS.state_db)
cur2 = conn2.cursor()
# Keep one-time used_count at 1; multi-use increments per asset
cur2.execute("SELECT max_uses FROM invites WHERE token = ?", (invite_token,))
row_mu = cur2.fetchone()
mx = None
try:
mx = int(row_mu[0]) if row_mu and row_mu[0] is not None else None
except Exception:
mx = None
if mx == 1:
cur2.execute("UPDATE invites SET used_count = 1 WHERE token = ?", (invite_token,))
else:
cur2.execute("UPDATE invites SET used_count = used_count + 1 WHERE token = ?", (invite_token,))
conn2.commit()
conn2.close()
except Exception as e:
logger.exception("Failed to increment invite usage: %s", e)
# Log uploader identity and file metadata
try: try:
r = requests.post(f"{SETTINGS.normalized_base_url}/assets", headers=headers, data=monitor, timeout=120) connlg = sqlite3.connect(SETTINGS.state_db)
if r.status_code in (200, 201): curlg = connlg.cursor()
data = r.json() curlg.execute(
asset_id = data.get("id") """
db_insert_upload(checksum, file.filename, size, device_asset_id, asset_id, created_iso) CREATE TABLE IF NOT EXISTS upload_events (
status = data.get("status", "created") id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT,
# Add to album if configured (invite overrides .env) uploaded_at TEXT DEFAULT CURRENT_TIMESTAMP,
if asset_id: ip TEXT,
added = False user_agent TEXT,
if invite_token: fingerprint TEXT,
# Only add if invite specified an album; do not fallback to env default filename TEXT,
if target_album_id or target_album_name: size INTEGER,
added = add_asset_to_album(asset_id, request=request, album_id_override=target_album_id, album_name_override=target_album_name) checksum TEXT,
if added: immich_asset_id TEXT
status += f" (added to album '{target_album_name or target_album_id}')" );
elif SETTINGS.album_name: """
if add_asset_to_album(asset_id, request=request): )
status += f" (added to album '{SETTINGS.album_name}')" ip = None
try:
ip = (request.client.host if request and request.client else None) or request.headers.get('x-forwarded-for')
except Exception:
ip = None
ua = request.headers.get('user-agent', '') if request else ''
curlg.execute(
"INSERT INTO upload_events (token, ip, user_agent, fingerprint, filename, size, checksum, immich_asset_id) VALUES (?,?,?,?,?,?,?,?)",
(invite_token or '', ip, ua, fingerprint or '', file.filename, size, checksum, None)
)
connlg.commit()
connlg.close()
except Exception:
pass
return JSONResponse({"id": None, "status": "done"}, status_code=200)
await send_progress(session_id, item_id, "duplicate" if status == "duplicate" else "done", 100, status, asset_id) except Exception as e:
logger.exception("Local save failed: %s", e)
# Increment invite usage on success await send_progress(session_id, item_id, "error", 100, "Failed to save file locally")
if invite_token: return JSONResponse({"error": "local_save_failed"}, status_code=500)
try:
conn2 = sqlite3.connect(SETTINGS.state_db)
cur2 = conn2.cursor()
# Keep one-time used_count at 1; multi-use increments per asset
cur2.execute("SELECT max_uses FROM invites WHERE token = ?", (invite_token,))
row_mu = cur2.fetchone()
mx = None
try:
mx = int(row_mu[0]) if row_mu and row_mu[0] is not None else None
except Exception:
mx = None
if mx == 1:
cur2.execute("UPDATE invites SET used_count = 1 WHERE token = ?", (invite_token,))
else:
cur2.execute("UPDATE invites SET used_count = used_count + 1 WHERE token = ?", (invite_token,))
conn2.commit()
conn2.close()
except Exception as e:
logger.exception("Failed to increment invite usage: %s", e)
# Log uploader identity and file metadata
try:
connlg = sqlite3.connect(SETTINGS.state_db)
curlg = connlg.cursor()
curlg.execute(
"""
CREATE TABLE IF NOT EXISTS upload_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT,
uploaded_at TEXT DEFAULT CURRENT_TIMESTAMP,
ip TEXT,
user_agent TEXT,
fingerprint TEXT,
filename TEXT,
size INTEGER,
checksum TEXT,
immich_asset_id TEXT
);
"""
)
ip = None
try:
ip = (request.client.host if request and request.client else None) or request.headers.get('x-forwarded-for')
except Exception:
ip = None
ua = request.headers.get('user-agent', '') if request else ''
curlg.execute(
"INSERT INTO upload_events (token, ip, user_agent, fingerprint, filename, size, checksum, immich_asset_id) VALUES (?,?,?,?,?,?,?,?)",
(invite_token or '', ip, ua, fingerprint or '', file.filename, size, checksum, asset_id or None)
)
connlg.commit()
connlg.close()
except Exception:
pass
return JSONResponse({"id": asset_id, "status": status}, status_code=200)
else:
try:
msg = r.json().get("message", r.text)
except Exception:
msg = r.text
await send_progress(session_id, item_id, "error", 100, msg)
return JSONResponse({"error": msg}, status_code=400)
except Exception as e:
await send_progress(session_id, item_id, "error", 100, str(e))
return JSONResponse({"error": str(e)}, status_code=500)
return await do_upload()
# --------- Chunked upload endpoints --------- # --------- Chunked upload endpoints ---------
@@ -880,52 +795,8 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
await send_progress(session_id_local, item_id_local, "duplicate", 100, "Already uploaded from this device (local cache)") await send_progress(session_id_local, item_id_local, "duplicate", 100, "Already uploaded from this device (local cache)")
return JSONResponse({"status": "duplicate", "id": None}, status_code=200) return JSONResponse({"status": "duplicate", "id": None}, status_code=200)
if SETTINGS.local_save_only:
try:
save_dir = "./data/uploads"
os.makedirs(save_dir, exist_ok=True)
safe_name = sanitize_filename(file_like_name)
save_path = os.path.join(save_dir, safe_name)
# Avoid overwriting when filenames collide (not same as duplicate)
if os.path.exists(save_path):
base, ext = os.path.splitext(safe_name)
i = 1
while os.path.exists(save_path):
save_path = os.path.join(save_dir, f"{base}_{i}{ext}")
i += 1
with open(save_path, "wb") as f:
f.write(raw)
db_insert_upload(checksum, file_like_name, file_size, device_asset_id, None, created_iso)
await send_progress(session_id_local, item_id_local, "done", 100, f"Saved locally to {os.path.basename(save_path)}")
return JSONResponse({"status": "done", "id": None}, status_code=200)
except Exception as e:
logger.exception("Local save failed: %s", e)
await send_progress(session_id_local, item_id_local, "error", 100, "Failed to save file locally")
return JSONResponse({"error": "local_save_failed"}, status_code=500)
await send_progress(session_id_local, item_id_local, "checking", 2, "Checking duplicates…")
bulk = immich_bulk_check([{ "id": item_id_local, "checksum": checksum }])
if bulk.get(item_id_local, {}).get("action") == "reject" and bulk[item_id_local].get("reason") == "duplicate":
asset_id = bulk[item_id_local].get("assetId")
db_insert_upload(checksum, file_like_name, file_size, device_asset_id, asset_id, created_iso)
await send_progress(session_id_local, item_id_local, "duplicate", 100, "Duplicate (server)", asset_id)
return JSONResponse({"status": "duplicate", "id": asset_id}, status_code=200)
safe_name2 = sanitize_filename(file_like_name)
def gen_encoder2() -> MultipartEncoder:
return MultipartEncoder(fields={
"assetData": (safe_name2, io.BytesIO(raw), content_type or "application/octet-stream"),
"deviceAssetId": device_asset_id,
"deviceId": f"python-{session_id_local}",
"fileCreatedAt": created_iso,
"fileModifiedAt": modified_iso,
"isFavorite": "false",
"filename": safe_name2,
"originalFileName": safe_name2,
})
# Invite validation/gating mirrors api_upload # Invite validation/gating mirrors api_upload
target_album_id: Optional[str] = None
target_album_name: Optional[str] = None target_album_name: Optional[str] = None
if invite_token: if invite_token:
try: try:
@@ -1005,115 +876,94 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
if (used_count or 0) >= (max_uses_int if max_uses_int >= 0 else 10**9): if (used_count or 0) >= (max_uses_int if max_uses_int >= 0 else 10**9):
await send_progress(session_id_local, item_id_local, "error", 100, "Invite already used up") await send_progress(session_id_local, item_id_local, "error", 100, "Invite already used up")
return JSONResponse({"error": "invite_exhausted"}, status_code=403) return JSONResponse({"error": "invite_exhausted"}, status_code=403)
target_album_id = album_id
target_album_name = album_name target_album_name = album_name
await send_progress(session_id_local, item_id_local, "uploading", 0, "Uploading…") album_for_saving = target_album_name if invite_token else "public"
sent = {"pct": 0} if not invite_token and not SETTINGS.public_upload_page_enabled:
def cb2(monitor: MultipartEncoderMonitor) -> None: await send_progress(session_id_local, item_id_local, "error", 100, "Public uploads disabled")
if monitor.len: return JSONResponse({"error": "public_upload_disabled"}, status_code=403)
pct = int(monitor.bytes_read * 100 / monitor.len)
if pct != sent["pct"]: try:
sent["pct"] = pct save_dir = get_or_create_album_dir(album_for_saving)
asyncio.create_task(send_progress(session_id_local, item_id_local, "uploading", pct)) safe_name = sanitize_filename(file_like_name)
encoder2 = gen_encoder2() save_path = os.path.join(save_dir, safe_name)
monitor2 = MultipartEncoderMonitor(encoder2, cb2) if os.path.exists(save_path):
headers = {"Accept": "application/json", "Content-Type": monitor2.content_type, "x-immich-checksum": checksum, **immich_headers(request)} base, ext = os.path.splitext(safe_name)
try: i = 1
r = requests.post(f"{SETTINGS.normalized_base_url}/assets", headers=headers, data=monitor2, timeout=120) while os.path.exists(save_path):
if r.status_code in (200, 201): save_path = os.path.join(save_dir, f"{base}_{i}{ext}")
data_r = r.json() i += 1
asset_id = data_r.get("id") with open(save_path, "wb") as f:
db_insert_upload(checksum, file_like_name, file_size, device_asset_id, asset_id, created_iso) f.write(raw)
status = data_r.get("status", "created") db_insert_upload(checksum, file_like_name, file_size, device_asset_id, None, created_iso)
if asset_id:
added = False msg = f"Saved to {album_for_saving}/{os.path.basename(save_path)}"
if invite_token: await send_progress(session_id_local, item_id_local, "done", 100, msg)
# Only add if invite specified an album; do not fallback to env default
if target_album_id or target_album_name: if invite_token:
added = add_asset_to_album(asset_id, request=request, album_id_override=target_album_id, album_name_override=target_album_name) try:
if added: conn2 = sqlite3.connect(SETTINGS.state_db)
status += f" (added to album '{target_album_name or target_album_id}')" cur2 = conn2.cursor()
elif SETTINGS.album_name: cur2.execute("SELECT max_uses FROM invites WHERE token = ?", (invite_token,))
if add_asset_to_album(asset_id, request=request): row_mu = cur2.fetchone()
status += f" (added to album '{SETTINGS.album_name}')" mx = None
await send_progress(session_id_local, item_id_local, "duplicate" if status == "duplicate" else "done", 100, status, asset_id) try:
if invite_token: mx = int(row_mu[0]) if row_mu and row_mu[0] is not None else None
try: except Exception:
conn2 = sqlite3.connect(SETTINGS.state_db) mx = None
cur2 = conn2.cursor() if mx == 1:
cur2.execute("SELECT max_uses FROM invites WHERE token = ?", (invite_token,)) cur2.execute("UPDATE invites SET used_count = 1 WHERE token = ?", (invite_token,))
row_mu = cur2.fetchone() else:
mx = None cur2.execute("UPDATE invites SET used_count = used_count + 1 WHERE token = ?", (invite_token,))
try: conn2.commit()
mx = int(row_mu[0]) if row_mu and row_mu[0] is not None else None conn2.close()
except Exception: except Exception as e:
mx = None logger.exception("Failed to increment invite usage: %s", e)
if mx == 1: # Log uploader identity and file metadata
cur2.execute("UPDATE invites SET used_count = 1 WHERE token = ?", (invite_token,)) try:
else: connlg = sqlite3.connect(SETTINGS.state_db)
cur2.execute("UPDATE invites SET used_count = used_count + 1 WHERE token = ?", (invite_token,)) curlg = connlg.cursor()
conn2.commit() curlg.execute(
conn2.close() """
except Exception as e: CREATE TABLE IF NOT EXISTS upload_events (
logger.exception("Failed to increment invite usage: %s", e) id INTEGER PRIMARY KEY AUTOINCREMENT,
# Log uploader identity and file metadata token TEXT,
try: uploaded_at TEXT DEFAULT CURRENT_TIMESTAMP,
connlg = sqlite3.connect(SETTINGS.state_db) ip TEXT,
curlg = connlg.cursor() user_agent TEXT,
curlg.execute( fingerprint TEXT,
""" filename TEXT,
CREATE TABLE IF NOT EXISTS upload_events ( size INTEGER,
id INTEGER PRIMARY KEY AUTOINCREMENT, checksum TEXT,
token TEXT, immich_asset_id TEXT
uploaded_at TEXT DEFAULT CURRENT_TIMESTAMP, );
ip TEXT, """
user_agent TEXT, )
fingerprint TEXT, ip = None
filename TEXT, try:
size INTEGER, ip = (request.client.host if request and request.client else None) or request.headers.get('x-forwarded-for')
checksum TEXT, except Exception:
immich_asset_id TEXT ip = None
); ua = request.headers.get('user-agent', '') if request else ''
""" curlg.execute(
) "INSERT INTO upload_events (token, ip, user_agent, fingerprint, filename, size, checksum, immich_asset_id) VALUES (?,?,?,?,?,?,?,?)",
ip = None (invite_token or '', ip, ua, fingerprint or '', file_like_name, file_size, checksum, None)
try: )
ip = (request.client.host if request and request.client else None) or request.headers.get('x-forwarded-for') connlg.commit()
except Exception: connlg.close()
ip = None except Exception:
ua = request.headers.get('user-agent', '') if request else '' pass
curlg.execute( return JSONResponse({"id": None, "status": "done"}, status_code=200)
"INSERT INTO upload_events (token, ip, user_agent, fingerprint, filename, size, checksum, immich_asset_id) VALUES (?,?,?,?,?,?,?,?)", except Exception as e:
(invite_token or '', ip, ua, fingerprint or '', file_like_name, file_size, checksum, asset_id or None) await send_progress(session_id_local, item_id_local, "error", 100, "Failed to save file locally")
) return JSONResponse({"error": "local_save_failed"}, status_code=500)
connlg.commit()
connlg.close()
except Exception:
pass
return JSONResponse({"id": asset_id, "status": status}, status_code=200)
else:
try:
msg = r.json().get("message", r.text)
except Exception:
msg = r.text
await send_progress(session_id_local, item_id_local, "error", 100, msg)
return JSONResponse({"error": msg}, status_code=400)
except Exception as e:
await send_progress(session_id_local, item_id_local, "error", 100, str(e))
return JSONResponse({"error": str(e)}, status_code=500)
@app.post("/api/album/reset")
async def api_album_reset() -> dict:
"""Explicit trigger from the UI to clear cached album id."""
reset_album_cache()
return {"ok": True}
# ---------- Auth & Albums & Invites APIs ---------- # ---------- Auth & Albums & Invites APIs ----------
@app.post("/api/login") @app.post("/api/login")
async def api_login(request: Request) -> JSONResponse: async def api_login(request: Request) -> JSONResponse:
"""Authenticate against Immich using email/password; store token in session.""" """Authenticate against the local admin password."""
try: try:
body = await request.json() body = await request.json()
except Exception: except Exception:
@@ -1122,29 +972,21 @@ async def api_login(request: Request) -> JSONResponse:
password = (body or {}).get("password") password = (body or {}).get("password")
if not email or not password: if not email or not password:
return JSONResponse({"error": "missing_credentials"}, status_code=400) return JSONResponse({"error": "missing_credentials"}, status_code=400)
try:
r = requests.post(f"{SETTINGS.normalized_base_url}/auth/login", headers={"Content-Type": "application/json", "Accept": "application/json"}, json={"email": email, "password": password}, timeout=15) if email == "admin" and password == SETTINGS.admin_password:
except Exception as e: user_info = {
logger.exception("Login request failed: %s", e) "accessToken": "local_admin_session", # dummy value
return JSONResponse({"error": "login_failed"}, status_code=502) "userEmail": "admin",
if r.status_code not in (200, 201): "userId": "admin",
logger.warning("Auth rejected: %s - %s", r.status_code, r.text) "name": "Admin",
return JSONResponse({"error": "unauthorized"}, status_code=401) "isAdmin": True,
data = r.json() if r.content else {} }
token = data.get("accessToken") request.session.update(user_info)
if not token: logger.info("Admin user logged in")
logger.warning("Auth response missing accessToken") return JSONResponse({"ok": True, **{k: user_info.get(k) for k in ("userEmail","userId","name","isAdmin")}})
return JSONResponse({"error": "invalid_response"}, status_code=502)
# Store only token and basic info in cookie session logger.warning("Failed login attempt for user %s", email)
request.session.update({ return JSONResponse({"error": "unauthorized"}, status_code=401)
"accessToken": token,
"userEmail": data.get("userEmail"),
"userId": data.get("userId"),
"name": data.get("name"),
"isAdmin": data.get("isAdmin", False),
})
logger.info("User %s logged in", data.get("userEmail"))
return JSONResponse({"ok": True, **{k: data.get(k) for k in ("userEmail","userId","name","isAdmin")}})
@app.post("/api/logout") @app.post("/api/logout")
async def api_logout(request: Request) -> dict: async def api_logout(request: Request) -> dict:
@@ -1158,21 +1000,28 @@ async def logout_get(request: Request) -> RedirectResponse:
@app.get("/api/albums") @app.get("/api/albums")
async def api_albums(request: Request) -> JSONResponse: async def api_albums(request: Request) -> JSONResponse:
"""Return list of albums if authorized; logs on 401/403.""" """Return list of albums (directories) if authorized."""
if not request.session.get("accessToken"):
return JSONResponse({"error": "unauthorized"}, status_code=401)
upload_root = "/data/uploads"
try: try:
r = requests.get(f"{SETTINGS.normalized_base_url}/albums", headers=immich_headers(request), timeout=10) os.makedirs(upload_root, exist_ok=True)
# also make public dir
os.makedirs(os.path.join(upload_root, "public"), exist_ok=True)
albums = []
for name in os.listdir(upload_root):
if os.path.isdir(os.path.join(upload_root, name)):
albums.append({"id": name, "albumName": name})
return JSONResponse(albums)
except Exception as e: except Exception as e:
logger.exception("Albums request failed: %s", e) logger.exception("Failed to list album directories: %s", e)
return JSONResponse({"error": "request_failed"}, status_code=502) return JSONResponse({"error": "list_albums_failed"}, status_code=500)
if r.status_code == 200:
return JSONResponse(r.json())
if r.status_code in (401, 403):
logger.warning("Album list not allowed: %s - %s", r.status_code, r.text)
return JSONResponse({"error": "forbidden"}, status_code=403)
return JSONResponse({"error": "unexpected_status", "status": r.status_code}, status_code=502)
@app.post("/api/albums") @app.post("/api/albums")
async def api_albums_create(request: Request) -> JSONResponse: async def api_albums_create(request: Request) -> JSONResponse:
if not request.session.get("accessToken"):
return JSONResponse({"error": "unauthorized"}, status_code=401)
try: try:
body = await request.json() body = await request.json()
except Exception: except Exception:
@@ -1181,16 +1030,11 @@ async def api_albums_create(request: Request) -> JSONResponse:
if not name: if not name:
return JSONResponse({"error": "missing_name"}, status_code=400) return JSONResponse({"error": "missing_name"}, status_code=400)
try: try:
r = requests.post(f"{SETTINGS.normalized_base_url}/albums", headers={**immich_headers(request), "Content-Type": "application/json"}, json={"albumName": name}, timeout=10) get_or_create_album_dir(name)
return JSONResponse({"id": name, "albumName": name}, status_code=201)
except Exception as e: except Exception as e:
logger.exception("Create album failed: %s", e) logger.exception("Create album directory failed: %s", e)
return JSONResponse({"error": "request_failed"}, status_code=502) return JSONResponse({"error": "create_album_failed"}, status_code=500)
if r.status_code in (200, 201):
return JSONResponse(r.json(), status_code=201)
if r.status_code in (401, 403):
logger.warning("Create album forbidden: %s - %s", r.status_code, r.text)
return JSONResponse({"error": "forbidden"}, status_code=403)
return JSONResponse({"error": "unexpected_status", "status": r.status_code, "body": r.text}, status_code=502)
# ---------- Invites (one-time/expiring links) ---------- # ---------- Invites (one-time/expiring links) ----------
@@ -1277,15 +1121,13 @@ async def api_invites_create(request: Request) -> JSONResponse:
max_uses = int(max_uses) max_uses = int(max_uses)
except Exception: except Exception:
max_uses = 1 max_uses = 1
# Allow blank album for invites (no album association) # Allow blank album for invites (will default to public)
if not album_name and SETTINGS.album_name and not album_id and album_name is not None: if not album_name:
album_name = SETTINGS.album_name album_name = "public"
# If only album_name provided, resolve or create now to fix to an ID
resolved_album_id = None # Ensure album directory exists
if not album_id and album_name: get_or_create_album_dir(album_name)
resolved_album_id = get_or_create_album(request=request, album_name_override=album_name) resolved_album_id = None # not used
else:
resolved_album_id = album_id
# Compute expiry # Compute expiry
expires_at = None expires_at = None
if expires_days is not None: if expires_days is not None:
@@ -1328,12 +1170,12 @@ async def api_invites_create(request: Request) -> JSONResponse:
if pw_hash: if pw_hash:
cur.execute( cur.execute(
"INSERT INTO invites (token, album_id, album_name, max_uses, expires_at, password_hash, owner_user_id, owner_email, owner_name, name) VALUES (?,?,?,?,?,?,?,?,?,?)", "INSERT INTO invites (token, album_id, album_name, max_uses, expires_at, password_hash, owner_user_id, owner_email, owner_name, name) VALUES (?,?,?,?,?,?,?,?,?,?)",
(token, resolved_album_id, album_name, max_uses, expires_at, pw_hash, owner_user_id, owner_email, owner_name, default_link_name) (token, None, album_name, max_uses, expires_at, pw_hash, owner_user_id, owner_email, owner_name, default_link_name)
) )
else: else:
cur.execute( cur.execute(
"INSERT INTO invites (token, album_id, album_name, max_uses, expires_at, owner_user_id, owner_email, owner_name, name) VALUES (?,?,?,?,?,?,?,?,?)", "INSERT INTO invites (token, album_id, album_name, max_uses, expires_at, owner_user_id, owner_email, owner_name, name) VALUES (?,?,?,?,?,?,?,?,?)",
(token, resolved_album_id, album_name, max_uses, expires_at, owner_user_id, owner_email, owner_name, default_link_name) (token, None, album_name, max_uses, expires_at, owner_user_id, owner_email, owner_name, default_link_name)
) )
conn.commit() conn.commit()
conn.close() conn.close()
@@ -1351,7 +1193,7 @@ async def api_invites_create(request: Request) -> JSONResponse:
"token": token, "token": token,
"url": f"/invite/{token}", "url": f"/invite/{token}",
"absoluteUrl": absolute, "absoluteUrl": absolute,
"albumId": resolved_album_id, "albumId": None,
"albumName": album_name, "albumName": album_name,
"maxUses": max_uses, "maxUses": max_uses,
"expiresAt": expires_at, "expiresAt": expires_at,

View File

@@ -13,10 +13,8 @@ from dotenv import load_dotenv
@dataclass @dataclass
class Settings: class Settings:
"""App settings loaded from environment variables (.env).""" """App settings loaded from environment variables (.env)."""
immich_base_url: str admin_password: str
immich_api_key: str
max_concurrent: int max_concurrent: int
album_name: str = ""
public_upload_page_enabled: bool = False public_upload_page_enabled: bool = False
public_base_url: str = "" public_base_url: str = ""
state_db: str = "" state_db: str = ""
@@ -25,16 +23,6 @@ class Settings:
chunked_uploads_enabled: bool = False chunked_uploads_enabled: bool = False
chunk_size_mb: int = 95 chunk_size_mb: int = 95
@property
def normalized_base_url(self) -> str:
"""Return the base URL without a trailing slash for clean joining and display."""
return self.immich_base_url.rstrip("/")
@property
def local_save_only(self) -> bool:
"""True if configured to save locally instead of uploading to Immich."""
return str(self.immich_base_url).lower() == "false"
def load_settings() -> Settings: def load_settings() -> Settings:
"""Load settings from .env, applying defaults when absent.""" """Load settings from .env, applying defaults when absent."""
# Load environment variables from .env once here so importers dont have to # Load environment variables from .env once here so importers dont have to
@@ -42,9 +30,7 @@ def load_settings() -> Settings:
load_dotenv() load_dotenv()
except Exception: except Exception:
pass pass
base = os.getenv("IMMICH_BASE_URL", "http://127.0.0.1:2283/api") admin_password = os.getenv("ADMIN_PASSWORD", "admin") # Default for convenience, should be changed
api_key = os.getenv("IMMICH_API_KEY", "")
album_name = os.getenv("IMMICH_ALBUM_NAME", "")
# Safe defaults: disable public uploader and invites unless explicitly enabled # Safe defaults: disable public uploader and invites unless explicitly enabled
def as_bool(v: str, default: bool = False) -> bool: def as_bool(v: str, default: bool = False) -> bool:
if v is None: if v is None:
@@ -64,10 +50,8 @@ def load_settings() -> Settings:
except ValueError: except ValueError:
chunk_size_mb = 95 chunk_size_mb = 95
return Settings( return Settings(
immich_base_url=base, admin_password=admin_password,
immich_api_key=api_key,
max_concurrent=maxc, max_concurrent=maxc,
album_name=album_name,
public_upload_page_enabled=public_upload, public_upload_page_enabled=public_upload,
public_base_url=os.getenv("PUBLIC_BASE_URL", ""), public_base_url=os.getenv("PUBLIC_BASE_URL", ""),
state_db=state_db, state_db=state_db,