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:
574
app/app.py
574
app/app.py
@@ -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,
|
||||||
|
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, None)
|
||||||
|
)
|
||||||
|
connlg.commit()
|
||||||
|
connlg.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return JSONResponse({"id": None, "status": "done"}, status_code=200)
|
||||||
|
|
||||||
# Add to album if configured (invite overrides .env)
|
except Exception as e:
|
||||||
if asset_id:
|
logger.exception("Local save failed: %s", e)
|
||||||
added = False
|
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)
|
||||||
# Only add if invite specified an album; do not fallback to env default
|
|
||||||
if target_album_id or target_album_name:
|
|
||||||
added = add_asset_to_album(asset_id, request=request, album_id_override=target_album_id, album_name_override=target_album_name)
|
|
||||||
if added:
|
|
||||||
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}')"
|
|
||||||
|
|
||||||
await send_progress(session_id, item_id, "duplicate" if status == "duplicate" else "done", 100, status, asset_id)
|
|
||||||
|
|
||||||
# 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:
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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 don’t have to
|
# Load environment variables from .env once here so importers don’t 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user