upload unique links + default upload page + login

This commit is contained in:
MEGASOL\simon.adams
2025-09-01 18:48:01 +02:00
parent 6a4e7fdb65
commit 43d5e0c0ff
11 changed files with 1005 additions and 63 deletions

View File

@@ -7,7 +7,10 @@ IMMICH_BASE_URL=http://127.0.0.1:2283/api
IMMICH_API_KEY=ADD-YOUR-API-KEY #key needs asset.upload (default functions)
MAX_CONCURRENT=3
# Optional: Album name for auto-adding uploads (creates if doesn't exist)
# Optional: Public upload page
PUBLIC_UPLOAD_PAGE_ENABLED=true
# Optional: Album name for public upload page
IMMICH_ALBUM_NAME=dead-drop #key needs album.create,album.read,albumAsset.create (extended functions)
# Data path inside the container

View File

@@ -21,22 +21,21 @@ from typing import Dict, List, Optional
import requests
from requests_toolbelt.multipart.encoder import MultipartEncoder, MultipartEncoderMonitor
import logging
from fastapi import FastAPI, UploadFile, WebSocket, WebSocketDisconnect, Request, Form
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.websockets import WebSocketState
from starlette.middleware.sessions import SessionMiddleware
from PIL import Image, ExifTags
from dotenv import load_dotenv
try:
import qrcode
except Exception:
qrcode = None
from app.config import Settings, load_settings
# ---- Load environment / defaults ----
load_dotenv()
HOST = os.getenv("HOST", "127.0.0.1")
PORT = int(os.getenv("PORT", "8080"))
STATE_DB = os.getenv("STATE_DB", "./state.db")
# ---- App & static ----
app = FastAPI(title="Immich Drop Uploader (Python)")
app.add_middleware(
@@ -47,12 +46,19 @@ app.add_middleware(
allow_headers=["*"],
)
FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend")
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
# Global settings (read-only at runtime)
SETTINGS: Settings = load_settings()
# Basic logging setup using settings
logging.basicConfig(level=SETTINGS.log_level, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("immich_drop")
# Cookie-based session for short-lived auth token storage (no persistence)
app.add_middleware(SessionMiddleware, secret_key=SETTINGS.session_secret, same_site="lax")
FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend")
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
# Album cache
ALBUM_ID: Optional[str] = None
@@ -65,7 +71,7 @@ def reset_album_cache() -> None:
def db_init() -> None:
"""Create the local SQLite table used for duplicate checks (idempotent)."""
conn = sqlite3.connect(STATE_DB)
conn = sqlite3.connect(SETTINGS.state_db)
cur = conn.cursor()
cur.execute(
"""
@@ -86,7 +92,7 @@ def db_init() -> None:
def db_lookup_checksum(checksum: str) -> Optional[dict]:
"""Return a record for the given checksum if seen before (None if not)."""
conn = sqlite3.connect(STATE_DB)
conn = sqlite3.connect(SETTINGS.state_db)
cur = conn.cursor()
cur.execute("SELECT checksum, immich_asset_id FROM uploads WHERE checksum = ?", (checksum,))
row = cur.fetchone()
@@ -97,7 +103,7 @@ def db_lookup_checksum(checksum: str) -> Optional[dict]:
def db_lookup_device_asset(device_asset_id: str) -> bool:
"""True if a deviceAssetId has been uploaded by this service previously."""
conn = sqlite3.connect(STATE_DB)
conn = sqlite3.connect(SETTINGS.state_db)
cur = conn.cursor()
cur.execute("SELECT 1 FROM uploads WHERE device_asset_id = ?", (device_asset_id,))
row = cur.fetchone()
@@ -106,7 +112,7 @@ def db_lookup_device_asset(device_asset_id: str) -> bool:
def db_insert_upload(checksum: str, filename: str, size: int, device_asset_id: str, immich_asset_id: Optional[str], created_at: str) -> None:
"""Insert a newly-uploaded asset into the local cache (ignore on duplicates)."""
conn = sqlite3.connect(STATE_DB)
conn = sqlite3.connect(SETTINGS.state_db)
cur = conn.cursor()
cur.execute(
"INSERT OR IGNORE INTO uploads (checksum, filename, size, device_asset_id, immich_asset_id, created_at) VALUES (?,?,?,?,?,?)",
@@ -197,67 +203,87 @@ def read_exif_datetimes(file_bytes: bytes):
pass
return created, modified
def immich_headers() -> dict:
"""Headers for Immich API calls (keeps key server-side)."""
return {"Accept": "application/json", "x-api-key": SETTINGS.immich_api_key}
def immich_headers(request: Optional[Request] = None) -> dict:
"""Headers for Immich API calls using either session access token or API key."""
headers = {"Accept": "application/json"}
token = None
try:
if request is not None:
token = request.session.get("accessToken")
except Exception:
token = None
if token:
headers["Authorization"] = f"Bearer {token}"
elif SETTINGS.immich_api_key:
headers["x-api-key"] = SETTINGS.immich_api_key
return headers
def get_or_create_album() -> Optional[str]:
def get_or_create_album(request: Optional[Request] = None, album_name_override: Optional[str] = None) -> Optional[str]:
"""Get existing album by name or create a new one. Returns album ID or None."""
global ALBUM_ID
album_name = album_name_override if album_name_override is not None else SETTINGS.album_name
# Skip if no album name configured
if not SETTINGS.album_name:
if not album_name:
return None
# Return cached album ID if already fetched
if ALBUM_ID:
# Return cached album ID if already fetched and using default settings name
if album_name_override is None and ALBUM_ID:
return ALBUM_ID
try:
# First, try to find existing album
url = f"{SETTINGS.normalized_base_url}/albums"
r = requests.get(url, headers=immich_headers(), timeout=10)
r = requests.get(url, headers=immich_headers(request), timeout=10)
if r.status_code == 200:
albums = r.json()
for album in albums:
if album.get("albumName") == SETTINGS.album_name:
ALBUM_ID = album.get("id")
print(f"Found existing album '{SETTINGS.album_name}' with ID: {ALBUM_ID}")
return ALBUM_ID
if album.get("albumName") == album_name:
found_id = album.get("id")
if album_name_override is None:
ALBUM_ID = found_id
logger.info(f"Found existing album '%s' with ID: %s", album_name, ALBUM_ID)
return ALBUM_ID
else:
return found_id
# Album doesn't exist, create it
create_url = f"{SETTINGS.normalized_base_url}/albums"
payload = {
"albumName": SETTINGS.album_name,
"albumName": album_name,
"description": "Auto-created album for Immich Drop uploads"
}
r = requests.post(create_url, headers={**immich_headers(), "Content-Type": "application/json"},
json=payload, timeout=10)
r = requests.post(create_url, headers={**immich_headers(request), "Content-Type": "application/json"},
json=payload, timeout=10)
if r.status_code in (200, 201):
data = r.json()
ALBUM_ID = data.get("id")
print(f"Created new album '{SETTINGS.album_name}' with ID: {ALBUM_ID}")
return ALBUM_ID
new_id = data.get("id")
if album_name_override is None:
ALBUM_ID = new_id
logger.info("Created new album '%s' with ID: %s", album_name, ALBUM_ID)
return ALBUM_ID
else:
logger.info("Created new album '%s' with ID: %s", album_name, new_id)
return new_id
else:
print(f"Failed to create album: {r.status_code} - {r.text}")
logger.warning("Failed to create album: %s - %s", r.status_code, r.text)
except Exception as e:
print(f"Error managing album: {e}")
logger.exception("Error managing album: %s", e)
return None
def add_asset_to_album(asset_id: str) -> bool:
def add_asset_to_album(asset_id: str, request: Optional[Request] = None, album_id_override: Optional[str] = None, album_name_override: Optional[str] = None) -> bool:
"""Add an asset to the configured album. Returns True on success."""
album_id = get_or_create_album()
album_id = album_id_override
if not album_id:
album_id = get_or_create_album(request=request, album_name_override=album_name_override)
if not album_id or not asset_id:
return False
try:
url = f"{SETTINGS.normalized_base_url}/albums/{album_id}/assets"
payload = {"ids": [asset_id]}
r = requests.put(url, headers={**immich_headers(), "Content-Type": "application/json"},
json=payload, timeout=10)
r = requests.put(url, headers={**immich_headers(request), "Content-Type": "application/json"},
json=payload, timeout=10)
if r.status_code == 200:
results = r.json()
@@ -270,7 +296,7 @@ def add_asset_to_album(asset_id: str) -> bool:
return True
return False
except Exception as e:
print(f"Error adding asset to album: {e}")
logger.exception("Error adding asset to album: %s", e)
return False
def immich_ping() -> bool:
@@ -312,10 +338,24 @@ async def send_progress(session_id: str, item_id: str, status: str, progress: in
# ---------- Routes ----------
@app.get("/", response_class=HTMLResponse)
async def index(_: Request) -> HTMLResponse:
"""Serve the SPA (frontend/index.html)."""
async def index(request: Request) -> HTMLResponse:
"""Serve the SPA (frontend/index.html) or redirect to login if disabled."""
if not SETTINGS.public_upload_page_enabled:
return RedirectResponse(url="/login")
return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
@app.get("/login", response_class=HTMLResponse)
async def login_page(_: Request) -> HTMLResponse:
"""Serve the login page."""
return FileResponse(os.path.join(FRONTEND_DIR, "login.html"))
@app.get("/menu", response_class=HTMLResponse)
async def menu_page(request: Request) -> HTMLResponse:
"""Serve the menu page for creating invite links. Requires login."""
if not request.session.get("accessToken"):
return RedirectResponse(url="/login")
return FileResponse(os.path.join(FRONTEND_DIR, "menu.html"))
@app.post("/api/ping")
async def api_ping() -> dict:
"""Connectivity test endpoint used by the UI to display a temporary banner."""
@@ -325,6 +365,13 @@ async def api_ping() -> dict:
"album_name": SETTINGS.album_name if SETTINGS.album_name else None
}
@app.get("/api/config")
async def api_config() -> dict:
"""Expose minimal public configuration flags for the frontend."""
return {
"public_upload_page_enabled": SETTINGS.public_upload_page_enabled,
}
@app.websocket("/ws")
async def ws_endpoint(ws: WebSocket) -> None:
"""WebSocket endpoint for pushing per-item upload progress."""
@@ -358,11 +405,12 @@ async def ws_endpoint(ws: WebSocket) -> None:
@app.post("/api/upload")
async def api_upload(
_: Request,
request: Request,
file: UploadFile,
item_id: str = Form(...),
session_id: str = Form(...),
last_modified: Optional[int] = Form(None),
invite_token: Optional[str] = Form(None),
):
"""Receive a file, check duplicates, forward to Immich; stream progress via WS."""
raw = await file.read()
@@ -405,6 +453,65 @@ async def api_upload(
encoder = gen_encoder()
# Invite token validation (if provided)
target_album_id: Optional[str] = None
target_album_name: Optional[str] = None
if invite_token:
try:
conn = sqlite3.connect(SETTINGS.state_db)
cur = conn.cursor()
cur.execute("SELECT token, album_id, album_name, max_uses, used_count, expires_at, COALESCE(claimed,0), claimed_by_session FROM invites WHERE token = ?", (invite_token,))
row = cur.fetchone()
conn.close()
except Exception as e:
logger.exception("Invite lookup error: %s", e)
row = None
if not row:
await send_progress(session_id, item_id, "error", 100, "Invalid invite token")
return JSONResponse({"error": "invalid_invite"}, status_code=403)
_, album_id, album_name, max_uses, used_count, expires_at, claimed, claimed_by_session = row
# Expiry check
if expires_at:
try:
if datetime.utcnow() > datetime.fromisoformat(expires_at):
await send_progress(session_id, item_id, "error", 100, "Invite expired")
return JSONResponse({"error": "invite_expired"}, status_code=403)
except Exception:
pass
# One-time claim or multi-use enforcement
try:
max_uses_int = int(max_uses) if max_uses is not None else -1
except Exception:
max_uses_int = -1
if max_uses_int == 1:
if claimed and claimed_by_session and claimed_by_session != session_id:
await send_progress(session_id, item_id, "error", 100, "Invite already used")
return JSONResponse({"error": "invite_claimed"}, status_code=403)
# Atomically claim the one-time invite to prevent concurrent use
try:
connc = sqlite3.connect(SETTINGS.state_db)
curc = connc.cursor()
curc.execute(
"UPDATE invites SET claimed = 1, claimed_at = CURRENT_TIMESTAMP, claimed_by_session = ? WHERE token = ? AND (claimed IS NULL OR claimed = 0)",
(session_id, invite_token)
)
connc.commit()
changed = connc.total_changes
connc.close()
if changed == 0 and (claimed_by_session or claimed):
await send_progress(session_id, item_id, "error", 100, "Invite already used")
return JSONResponse({"error": "invite_claimed"}, status_code=403)
except Exception as e:
logger.exception("Invite claim failed: %s", e)
return JSONResponse({"error": "invite_claim_failed"}, status_code=500)
else:
# Usage check for multi-use (max_uses < 0 => indefinite)
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")
return JSONResponse({"error": "invite_exhausted"}, status_code=403)
target_album_id = album_id
target_album_name = album_name
async def do_upload():
await send_progress(session_id, item_id, "uploading", 0, "Uploading…")
sent = {"pct": 0}
@@ -415,7 +522,7 @@ async def api_upload(
sent["pct"] = pct
asyncio.create_task(send_progress(session_id, item_id, "uploading", pct))
monitor = MultipartEncoderMonitor(encoder, cb)
headers = {"Accept": "application/json", "Content-Type": monitor.content_type, "x-immich-checksum": checksum, **immich_headers()}
headers = {"Accept": "application/json", "Content-Type": monitor.content_type, "x-immich-checksum": checksum, **immich_headers(request)}
try:
r = requests.post(f"{SETTINGS.normalized_base_url}/assets", headers=headers, data=monitor, timeout=120)
if r.status_code in (200, 201):
@@ -424,12 +531,40 @@ async def api_upload(
db_insert_upload(checksum, file.filename, size, device_asset_id, asset_id, created_iso)
status = data.get("status", "created")
# Add to album if configured
if SETTINGS.album_name and asset_id:
if add_asset_to_album(asset_id):
status += f" (added to album '{SETTINGS.album_name}')"
# Add to album if configured (invite overrides .env)
if asset_id:
added = False
if invite_token:
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)
return JSONResponse({"id": asset_id, "status": status}, status_code=200)
else:
try:
@@ -450,6 +585,278 @@ async def api_album_reset() -> dict:
reset_album_cache()
return {"ok": True}
# ---------- Auth & Albums & Invites APIs ----------
@app.post("/api/login")
async def api_login(request: Request) -> JSONResponse:
"""Authenticate against Immich using email/password; store token in session."""
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "invalid_json"}, status_code=400)
email = (body or {}).get("email")
password = (body or {}).get("password")
if not email or not password:
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)
except Exception as e:
logger.exception("Login request failed: %s", e)
return JSONResponse({"error": "login_failed"}, status_code=502)
if r.status_code not in (200, 201):
logger.warning("Auth rejected: %s - %s", r.status_code, r.text)
return JSONResponse({"error": "unauthorized"}, status_code=401)
data = r.json() if r.content else {}
token = data.get("accessToken")
if not token:
logger.warning("Auth response missing accessToken")
return JSONResponse({"error": "invalid_response"}, status_code=502)
# Store only token and basic info in cookie session
request.session.update({
"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")
async def api_logout(request: Request) -> dict:
request.session.clear()
return {"ok": True}
@app.get("/logout")
async def logout_get(request: Request) -> RedirectResponse:
request.session.clear()
return RedirectResponse(url="/login")
@app.get("/api/albums")
async def api_albums(request: Request) -> JSONResponse:
"""Return list of albums if authorized; logs on 401/403."""
try:
r = requests.get(f"{SETTINGS.normalized_base_url}/albums", headers=immich_headers(request), timeout=10)
except Exception as e:
logger.exception("Albums request failed: %s", e)
return JSONResponse({"error": "request_failed"}, status_code=502)
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")
async def api_albums_create(request: Request) -> JSONResponse:
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "invalid_json"}, status_code=400)
name = (body or {}).get("name")
if not name:
return JSONResponse({"error": "missing_name"}, status_code=400)
try:
r = requests.post(f"{SETTINGS.normalized_base_url}/albums", headers={**immich_headers(request), "Content-Type": "application/json"}, json={"albumName": name}, timeout=10)
except Exception as e:
logger.exception("Create album failed: %s", e)
return JSONResponse({"error": "request_failed"}, status_code=502)
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) ----------
def ensure_invites_table() -> None:
try:
conn = sqlite3.connect(SETTINGS.state_db)
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS invites (
token TEXT PRIMARY KEY,
album_id TEXT,
album_name TEXT,
max_uses INTEGER DEFAULT 1,
used_count INTEGER DEFAULT 0,
expires_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
)
# Attempt to add new columns for claiming semantics
try:
cur.execute("ALTER TABLE invites ADD COLUMN claimed INTEGER DEFAULT 0")
except Exception:
pass
try:
cur.execute("ALTER TABLE invites ADD COLUMN claimed_at TEXT")
except Exception:
pass
try:
cur.execute("ALTER TABLE invites ADD COLUMN claimed_by_session TEXT")
except Exception:
pass
conn.commit()
conn.close()
except Exception as e:
logger.exception("Failed to ensure invites table: %s", e)
ensure_invites_table()
@app.post("/api/invites")
async def api_invites_create(request: Request) -> JSONResponse:
"""Create an invite link for uploads with optional expiry and max uses."""
# Require a logged-in session to create invites
if not request.session.get("accessToken"):
return JSONResponse({"error": "unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "invalid_json"}, status_code=400)
album_id = (body or {}).get("albumId")
album_name = (body or {}).get("albumName")
max_uses = (body or {}).get("maxUses", 1)
expires_days = (body or {}).get("expiresDays")
# Normalize max_uses
try:
max_uses = int(max_uses)
except Exception:
max_uses = 1
if not album_id and not album_name and not SETTINGS.album_name:
return JSONResponse({"error": "missing_album"}, status_code=400)
if not album_name and SETTINGS.album_name:
album_name = SETTINGS.album_name
# If only album_name provided, resolve or create now to fix to an ID
resolved_album_id = None
if not album_id and album_name:
resolved_album_id = get_or_create_album(request=request, album_name_override=album_name)
else:
resolved_album_id = album_id
# Compute expiry
expires_at = None
if expires_days is not None:
try:
days = int(expires_days)
expires_at = (datetime.utcnow()).replace(microsecond=0).isoformat()
# Use timedelta
from datetime import timedelta
expires_at = (datetime.utcnow() + timedelta(days=days)).replace(microsecond=0).isoformat()
except Exception:
expires_at = None
# Generate token
import uuid
token = uuid.uuid4().hex
try:
conn = sqlite3.connect(SETTINGS.state_db)
cur = conn.cursor()
cur.execute(
"INSERT INTO invites (token, album_id, album_name, max_uses, expires_at) VALUES (?,?,?,?,?)",
(token, resolved_album_id, album_name, max_uses, expires_at)
)
conn.commit()
conn.close()
except Exception as e:
logger.exception("Failed to create invite: %s", e)
return JSONResponse({"error": "db_error"}, status_code=500)
# Build absolute URL using PUBLIC_BASE_URL if set, else request base
try:
base_url = SETTINGS.public_base_url.strip().rstrip('/') if SETTINGS.public_base_url else str(request.base_url).rstrip('/')
except Exception:
base_url = str(request.base_url).rstrip('/')
absolute = f"{base_url}/invite/{token}"
return JSONResponse({
"ok": True,
"token": token,
"url": f"/invite/{token}",
"absoluteUrl": absolute,
"albumId": resolved_album_id,
"albumName": album_name,
"maxUses": max_uses,
"expiresAt": expires_at
})
@app.get("/invite/{token}", response_class=HTMLResponse)
async def invite_page(token: str, request: Request) -> HTMLResponse:
# If public invites disabled and no user session, require login
#if not request.session.get("accessToken"):
# return RedirectResponse(url="/login")
return FileResponse(os.path.join(FRONTEND_DIR, "invite.html"))
@app.get("/api/invite/{token}")
async def api_invite_info(token: str) -> JSONResponse:
try:
conn = sqlite3.connect(SETTINGS.state_db)
cur = conn.cursor()
cur.execute("SELECT token, album_id, album_name, max_uses, used_count, expires_at, COALESCE(claimed,0), claimed_at FROM invites WHERE token = ?", (token,))
row = cur.fetchone()
conn.close()
except Exception as e:
logger.exception("Invite info error: %s", e)
return JSONResponse({"error": "db_error"}, status_code=500)
if not row:
return JSONResponse({"error": "not_found"}, status_code=404)
_, album_id, album_name, max_uses, used_count, expires_at, claimed, claimed_at = row
# compute remaining
remaining = None
try:
if max_uses is not None and int(max_uses) >= 0:
remaining = int(max_uses) - int(used_count or 0)
except Exception:
remaining = None
# compute state flags
try:
one_time = (int(max_uses) == 1)
except Exception:
one_time = False
expired = False
if expires_at:
try:
expired = datetime.utcnow() > datetime.fromisoformat(expires_at)
except Exception:
expired = False
deactivated = False
if one_time and claimed:
deactivated = True
elif remaining is not None and remaining <= 0:
deactivated = True
if expired:
deactivated = True
active = not deactivated
return JSONResponse({
"token": token,
"albumId": album_id,
"albumName": album_name,
"maxUses": max_uses,
"used": used_count or 0,
"remaining": remaining,
"expiresAt": expires_at,
"oneTime": one_time,
"claimed": bool(claimed),
"claimedAt": claimed_at,
"expired": expired,
"active": active,
})
@app.get("/api/qr", response_model=None)
async def api_qr(request: Request):
"""Generate a QR code PNG for a given text (query param 'text')."""
text = request.query_params.get("text")
if not text:
return JSONResponse({"error": "missing_text"}, status_code=400)
if qrcode is None:
logger.warning("qrcode library not installed; cannot generate QR")
return JSONResponse({"error": "qr_not_available"}, status_code=501)
import io as _io
img = qrcode.make(text)
buf = _io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return Response(content=buf.read(), media_type="image/png")
"""
Note: Do not run this module directly. Use `python main.py` from
project root, which starts `uvicorn app.app:app` with reload.

View File

@@ -6,6 +6,8 @@ Reads ONLY from .env; there is NO runtime mutation from the UI.
from __future__ import annotations
import os
from dataclasses import dataclass
import secrets
from dotenv import load_dotenv
@dataclass
@@ -15,6 +17,11 @@ class Settings:
immich_api_key: str
max_concurrent: int = 3
album_name: str = ""
public_upload_page_enabled: bool = False
public_base_url: str = ""
state_db: str = "./state.db"
session_secret: str = ""
log_level: str = "INFO"
@property
def normalized_base_url(self) -> str:
@@ -23,11 +30,35 @@ class Settings:
def load_settings() -> Settings:
"""Load settings from .env, applying defaults when absent."""
# Load environment variables from .env once here so importers dont have to
try:
load_dotenv()
except Exception:
pass
base = os.getenv("IMMICH_BASE_URL", "http://127.0.0.1:2283/api")
api_key = os.getenv("IMMICH_API_KEY", "")
album_name = os.getenv("IMMICH_ALBUM_NAME", "")
# Safe defaults: disable public uploader and invites unless explicitly enabled
def as_bool(v: str, default: bool = False) -> bool:
if v is None:
return default
return str(v).strip().lower() in {"1","true","yes","on"}
public_upload = as_bool(os.getenv("PUBLIC_UPLOAD_PAGE_ENABLED", "false"), False)
try:
maxc = int(os.getenv("MAX_CONCURRENT", "3"))
except ValueError:
maxc = 3
return Settings(immich_base_url=base, immich_api_key=api_key, max_concurrent=maxc, album_name=album_name)
state_db = os.getenv("STATE_DB", "./state.db")
session_secret = os.getenv("SESSION_SECRET") or secrets.token_hex(32)
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
return Settings(
immich_base_url=base,
immich_api_key=api_key,
max_concurrent=maxc,
album_name=album_name,
public_upload_page_enabled=public_upload,
public_base_url=os.getenv("PUBLIC_BASE_URL", ""),
state_db=state_db,
session_secret=session_secret,
log_level=log_level,
)

View File

@@ -1,5 +1,13 @@
// Frontend logic (mobile-safe picker; no settings UI)
const sessionId = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(36).slice(2));
// Detect invite token from URL path /invite/{token}
let INVITE_TOKEN = null;
try {
const parts = (window.location.pathname || '').split('/').filter(Boolean);
if (parts[0] === 'invite' && parts[1]) {
INVITE_TOKEN = parts[1];
}
} catch {}
let items = [];
let socket;
@@ -24,8 +32,10 @@ function toggleDarkMode() {
function updateThemeIcon() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('iconLight').classList.toggle('hidden', !isDark);
document.getElementById('iconDark').classList.toggle('hidden', isDark);
const light = document.getElementById('iconLight');
const dark = document.getElementById('iconDark');
if (light && light.classList) light.classList.toggle('hidden', !isDark);
if (dark && dark.classList) dark.classList.toggle('hidden', isDark);
}
initDarkMode();
@@ -131,6 +141,7 @@ async function runQueue(){
form.append('item_id', next.id);
form.append('session_id', sessionId);
form.append('last_modified', next.file.lastModified || '');
if (INVITE_TOKEN) form.append('invite_token', INVITE_TOKEN);
const res = await fetch('/api/upload', { method:'POST', body: form });
const body = await res.json().catch(()=>({}));
if(!res.ok && next.status!=='error'){
@@ -184,7 +195,7 @@ function showBanner(text, kind='ok'){
}
// --- Connection test with ephemeral banner ---
btnPing.onclick = async () => {
if (btnPing) btnPing.onclick = async () => {
pingStatus.textContent = 'checking…';
try{
const r = await fetch('/api/ping', { method:'POST' });
@@ -204,6 +215,21 @@ btnPing.onclick = async () => {
}
};
// If on invite page, fetch invite info and show context banner
(async function initInviteBanner(){
if (!INVITE_TOKEN) return;
try {
const r = await fetch(`/api/invite/${INVITE_TOKEN}`);
if (!r.ok) return;
const j = await r.json();
const parts = [];
if (j.albumName) parts.push(`Uploading to album: "${j.albumName}"`);
if (j.expiresAt) parts.push(`Expires: ${new Date(j.expiresAt).toLocaleString()}`);
if (typeof j.remaining === 'number') parts.push(`Uses left: ${j.remaining}`);
if (parts.length) showBanner(parts.join(' | '), 'ok');
} catch {}
})();
// --- Drag & drop (no click-to-open on touch) ---
['dragenter','dragover'].forEach(ev => dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.add('border-blue-500','bg-blue-50','dark:bg-blue-900','dark:bg-opacity-20'); }));
['dragleave','drop'].forEach(ev => dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.remove('border-blue-500','bg-blue-50','dark:bg-blue-900','dark:bg-opacity-20'); }));
@@ -267,4 +293,4 @@ btnClearAll.onclick = ()=>{
};
// --- Dark mode toggle ---
btnTheme.onclick = toggleDarkMode;
if (btnTheme) btnTheme.onclick = toggleDarkMode;

94
frontend/header.js Normal file
View File

@@ -0,0 +1,94 @@
// Shared header utilities: theme + ping + ephemeral banner
(function(){
const doc = document;
const root = doc.documentElement;
const banner = doc.getElementById('topBanner');
function updateThemeIcon(){
const isDark = root.classList.contains('dark');
const light = doc.getElementById('iconLight');
const dark = doc.getElementById('iconDark');
if (light && light.classList) light.classList.toggle('hidden', !isDark);
if (dark && dark.classList) dark.classList.toggle('hidden', isDark);
}
function initDarkMode(){
try{
const stored = localStorage.getItem('theme');
if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}catch{}
updateThemeIcon();
}
function toggleDarkMode(){
const isDark = root.classList.toggle('dark');
try{ localStorage.setItem('theme', isDark ? 'dark' : 'light'); }catch{}
updateThemeIcon();
}
function showBanner(text, kind='ok'){
if(!banner) return;
banner.textContent = text;
banner.className = 'rounded-2xl p-3 text-center transition-colors ' + (
kind==='ok' ? 'border border-green-200 bg-green-50 text-green-700 dark:bg-green-900 dark:border-green-700 dark:text-green-300'
: kind==='warn' ? 'border border-amber-200 bg-amber-50 text-amber-700 dark:bg-amber-900 dark:border-amber-700 dark:text-amber-300'
: 'border border-red-200 bg-red-50 text-red-700 dark:bg-red-900 dark:border-red-700 dark:text-red-300'
);
banner.classList.remove('hidden');
setTimeout(() => banner.classList.add('hidden'), 3000);
}
function wire(){
const btnTheme = doc.getElementById('btnTheme');
const btnPing = doc.getElementById('btnPing');
const pingStatus = doc.getElementById('pingStatus');
const linkPublic = doc.getElementById('linkPublicUploader');
const linkHome = doc.getElementById('linkHome');
if (btnTheme) btnTheme.onclick = toggleDarkMode;
if (btnPing) btnPing.onclick = async () => {
if (pingStatus) pingStatus.textContent = 'checking…';
try{
const r = await fetch('/api/ping', { method:'POST' });
const j = await r.json();
if (pingStatus) {
pingStatus.textContent = j.ok ? 'Connected' : 'No connection';
pingStatus.className = 'ml-2 text-sm ' + (j.ok ? 'text-green-600' : 'text-red-600');
}
if(j.ok){
let text = `Connected to Immich at ${j.base_url}`;
if (j.album_name) text += ` | Uploading to album: "${j.album_name}"`;
showBanner(text, 'ok');
}
}catch{
if (pingStatus) {
pingStatus.textContent = 'No connection';
pingStatus.className='ml-2 text-sm text-red-600';
}
}
};
// Hide public uploader links unless enabled
(async ()=>{
try{
const r = await fetch('/api/config');
const j = await r.json();
const enabled = !!(j && j.public_upload_page_enabled);
if (linkPublic) linkPublic.classList.toggle('hidden', !enabled);
if (linkHome) linkHome.classList.toggle('hidden', !enabled);
}catch{
if (linkPublic) linkPublic.classList.add('hidden');
if (linkHome) linkHome.classList.add('hidden');
}
})();
}
initDarkMode();
wire();
// Expose for other scripts if needed
window.__header = { toggleDarkMode, showBanner };
})();

View File

@@ -19,6 +19,7 @@
<header class="flex items-center justify-between">
<h1 class="text-2xl font-semibold tracking-tight">Immich Drop Uploader</h1>
<div class="flex items-center gap-2">
<a href="/login" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600 dark:hover:bg-gray-800 hover:bg-gray-100 transition-colors">Login</a>
<button id="btnTheme" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600 dark:hover:bg-gray-800 hover:bg-gray-100 transition-colors" title="Toggle dark mode">
<svg id="iconLight" class="w-4 h-4 hidden" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"/>

131
frontend/invite.html Normal file
View File

@@ -0,0 +1,131 @@
<!doctype html>
<html lang="en" xml:lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Immich Drop Uploader (Invite)</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { darkMode: 'class' };
</script>
<style> body { transition: background-color .2s ease, color .2s ease; } </style>
<meta name="robots" content="noindex, nofollow" />
<meta http-equiv="Cache-Control" content="no-store" />
<meta http-equiv="Pragma" content="no-cache" />
</head>
<body class="min-h-screen bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<div class="mx-auto max-w-4xl p-6 space-y-6">
<div id="topBanner" class="hidden rounded-2xl border border-green-200 bg-green-50 p-3 text-green-700 text-center dark:bg-green-900 dark:border-green-700 dark:text-green-300"></div>
<header class="flex items-center justify-between">
<h1 class="text-2xl font-semibold tracking-tight">Immich Drop Uploader</h1>
<div class="flex items-center gap-2">
<a id="linkHome" href="/" class="hidden rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Home</a>
<button id="btnTheme" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600" title="Toggle dark mode">
<svg id="iconLight" class="w-4 h-4 hidden" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414z" clip-rule="evenodd"/>
</svg>
<svg id="iconDark" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>
</button>
<button id="btnPing" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Test connection</button>
<span id="pingStatus" class="ml-2 text-sm text-gray-500 dark:text-gray-400"></span>
</div>
</header>
<!-- Link information -->
<section id="linkInfo" class="rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 text-sm">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div>
<div><b>Status:</b> <span id="liStatus">Loading…</span></div>
<div><b>Album:</b> <span id="liAlbum"></span></div>
</div>
<div class="grid grid-cols-2 gap-x-6 gap-y-1">
<div><b>Type:</b> <span id="liType"></span></div>
<div><b>Uses left:</b> <span id="liUses"></span></div>
<div><b>Expires:</b> <span id="liExpires"></span></div>
<div><b>Claimed:</b> <span id="liClaimed"></span></div>
</div>
</div>
</section>
<!-- Dropzone and queue copied from index.html -->
<section id="dropzone" class="rounded-2xl border-2 border-dashed p-10 text-center bg-white dark:bg-gray-800 dark:border-gray-600">
<div class="mx-auto h-12 w-12 opacity-70">
<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">Drop images or videos here</p>
<p class="text-sm text-gray-600 dark:text-gray-400">...or</p>
<div class="mt-3 relative inline-block">
<label class="rounded-2xl bg-black text-white dark:bg-white dark:text-black px-4 py-2 hover:opacity-90 cursor-pointer select-none">
Choose files
<input id="fileInput" type="file" multiple accept="image/*,video/*" class="absolute inset-0 opacity-0 cursor-pointer" />
</label>
</div>
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
Files will be uploaded to the selected album for this invite.
</div>
</section>
<section id="summary" class="rounded-2xl border bg-white p-4 shadow-sm dark:bg-gray-800 dark:border-gray-700">
<div class="flex items-center justify-between text-sm">
<div class="flex gap-4">
<span>Queued/Processing: <b id="countQueued">0</b></span>
<span>Uploading: <b id="countUploading">0</b></span>
<span>Done: <b id="countDone">0</b></span>
<span>Duplicates: <b id="countDup">0</b></span>
<span>Errors: <b id="countErr">0</b></span>
</div>
</div>
</section>
<section id="items" class="space-y-3"></section>
<footer class="pt-4 pb-10 text-center text-xs text-gray-500 dark:text-gray-400">
Invite upload page
</footer>
</div>
<script src="/static/header.js"></script>
<script src="/static/app.js"></script>
</body>
<script>
// Hide ping and theme controls if referenced by app.js
// Standardized header includes ping + theme; no-op guards in app.js
// Populate link info section
(async function(){
try {
const parts = (location.pathname || '').split('/').filter(Boolean);
const token = parts[1];
const r = await fetch(`/api/invite/${token}`);
const j = await r.json();
const fmt = (s)=> s ? new Date(s).toLocaleString() : '—';
document.getElementById('liAlbum').textContent = j.albumName || j.albumId || '—';
document.getElementById('liType').textContent = j.oneTime ? 'One-time' : (j.maxUses < 0 ? 'Indefinite' : `Up to ${j.maxUses} uses`);
document.getElementById('liUses').textContent = (typeof j.remaining==='number') ? String(j.remaining) : '—';
document.getElementById('liExpires').textContent = j.expiresAt ? fmt(j.expiresAt) : 'No expiry';
document.getElementById('liClaimed').textContent = j.claimed ? 'Yes' : 'No';
document.getElementById('liStatus').textContent = j.active ? 'Active' : 'Inactive';
if (!j.active) {
// Disable dropzone
const dz = document.getElementById('dropzone');
const fi = document.getElementById('fileInput');
dz.classList.add('opacity-50');
fi.disabled = true;
const items = document.getElementById('items');
items.innerHTML = '<div class="text-sm text-gray-500">This link is not active.</div>';
}
} catch {}
})();
// Hook theme/ping
(function(){
const btnTheme = document.getElementById('btnTheme');
const btnPing = document.getElementById('btnPing');
const pingStatus = document.getElementById('pingStatus');
if (btnTheme) btnTheme.onclick = ()=>{ const isDark = document.documentElement.classList.toggle('dark'); try{ localStorage.setItem('theme', isDark ? 'dark':'light'); }catch{}; const isDarkNow = document.documentElement.classList.contains('dark'); try{ document.getElementById('iconLight').classList.toggle('hidden', !isDarkNow); document.getElementById('iconDark').classList.toggle('hidden', isDarkNow);}catch{} };
if (btnPing) btnPing.onclick = async ()=>{
pingStatus.textContent = 'checking…';
try { const r = await fetch('/api/ping', { method:'POST' }); const j = await r.json(); pingStatus.textContent = j.ok ? 'Connected' : 'No connection'; pingStatus.className = 'ml-2 text-sm ' + (j.ok ? 'text-green-600' : 'text-red-600'); if(j.ok){ let text = `Connected to Immich at ${j.base_url}`; if(j.album_name) text += ` | Uploading to album: "${j.album_name}"`; showBanner(text, 'ok'); } } catch { pingStatus.textContent = 'No connection'; pingStatus.className='ml-2 text-sm text-red-600'; }
};
})();
</script>
</html>

74
frontend/login.html Normal file
View File

@@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Login Immich Drop</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { darkMode: 'class' };
</script>
</head>
<body class="min-h-screen bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<div class="mx-auto max-w-2xl p-6 space-y-6">
<div id="topBanner" class="hidden rounded-2xl border border-green-200 bg-green-50 p-3 text-green-700 text-center dark:bg-green-900 dark:border-green-700 dark:text-green-300"></div>
<header class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Login to Immich</h1>
<div class="flex items-center gap-2">
<a id="linkPublicUploader" href="/" class="hidden rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Public uploader</a>
<button id="btnTheme" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600" title="Toggle dark mode">
<svg id="iconLight" class="w-4 h-4 hidden" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414z" clip-rule="evenodd"/>
</svg>
<svg id="iconDark" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>
</button>
<button id="btnPing" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Test connection</button>
<span id="pingStatus" class="ml-2 text-sm text-gray-500 dark:text-gray-400"></span>
</div>
</header>
<div class="max-w-md">
<h2 class="text-lg font-medium mb-2">Enter your credentials</h2>
<div id="msg" class="hidden mb-3 rounded-lg border p-2 text-sm"></div>
<form id="loginForm" class="space-y-3">
<div>
<label class="block text-sm mb-1">Email</label>
<input id="email" type="email" required class="w-full rounded-lg border px-3 py-2 bg-white dark:bg-gray-800 dark:border-gray-700" />
</div>
<div>
<label class="block text-sm mb-1">Password</label>
<input id="password" type="password" required class="w-full rounded-lg border px-3 py-2 bg-white dark:bg-gray-800 dark:border-gray-700" />
</div>
<div class="flex items-center justify-between">
<button class="rounded-xl bg-black text-white px-4 py-2 dark:bg-white dark:text-black" type="submit">Login</button>
<a href="/" class="text-sm text-gray-500">Back to uploader</a>
</div>
</form>
</div>
</div>
<script src="/static/header.js"></script>
<script>
const form = document.getElementById('loginForm');
const msg = document.getElementById('msg');
function show(kind, text){
msg.textContent = text;
msg.className = 'mb-3 rounded-lg border p-2 text-sm ' + (kind==='ok' ? 'border-green-200 bg-green-50 text-green-700 dark:bg-green-900 dark:border-green-700 dark:text-green-300' : 'border-red-200 bg-red-50 text-red-700 dark:bg-red-900 dark:border-red-700 dark:text-red-300');
msg.classList.remove('hidden');
}
form.onsubmit = async (e)=>{
e.preventDefault();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
try{
const r = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({email, password}) });
const j = await r.json().catch(()=>({}));
if(!r.ok){ show('err', j.error || 'Login failed'); return; }
show('ok', 'Login successful. Redirecting…');
setTimeout(()=>{ location.href = '/menu'; }, 500);
}catch(err){ show('err', String(err)); }
};
// header.js wires theme + ping and provides consistent banner behavior
</script>
</body>
</html>

168
frontend/menu.html Normal file
View File

@@ -0,0 +1,168 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Menu Immich Drop</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { darkMode: 'class' };
</script>
</head>
<body class="min-h-screen bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<div class="mx-auto max-w-2xl p-6 space-y-6">
<div id="topBanner" class="hidden rounded-2xl border border-green-200 bg-green-50 p-3 text-green-700 text-center dark:bg-green-900 dark:border-green-700 dark:text-green-300"></div>
<header class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Create Upload Link</h1>
<div class="flex items-center gap-2">
<a id="linkPublicUploader" href="/" class="hidden rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Public uploader</a>
<a href="/logout" id="btnLogout" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Logout</a>
<button id="btnTheme" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600" title="Toggle dark mode">
<svg id="iconLight" class="w-4 h-4 hidden" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414z" clip-rule="evenodd"/>
</svg>
<svg id="iconDark" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>
</button>
<button id="btnPing" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Test connection</button>
<span id="pingStatus" class="ml-2 text-sm text-gray-500 dark:text-gray-400"></span>
</div>
</header>
<section class="rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 space-y-3">
<div>
<div class="text-sm font-medium mb-1">Target album</div>
<div id="albumControls" class="space-y-2">
<div id="albumSelectWrap" class="hidden">
<select id="albumSelect" class="w-full rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700"></select>
</div>
<div id="albumInputWrap" class="hidden">
<input id="albumInput" placeholder="Album name" class="w-full rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700" />
<button id="btnCreateAlbum" class="mt-2 rounded-xl bg-black text-white px-3 py-1 dark:bg-white dark:text-black">Create album</button>
</div>
<div id="albumHint" class="text-sm text-gray-500"></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<div class="text-sm font-medium mb-1">Usage</div>
<select id="usage" class="w-full rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700">
<option value="1">One-time</option>
<option value="-1">Indefinite</option>
</select>
</div>
<div>
<div class="text-sm font-medium mb-1">Expires in days</div>
<input id="days" type="number" min="0" placeholder="Leave empty for no expiry" class="w-full rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700" />
</div>
</div>
<div>
<button id="btnCreate" class="rounded-xl bg-black text-white px-4 py-2 dark:bg-white dark:text-black">Create link</button>
</div>
<div id="result" class="hidden rounded-xl border p-3 text-sm space-y-2">
<div id="linkRow" class="flex items-center gap-2">
<input id="linkOut" class="flex-1 rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700" readonly />
<button id="btnCopy" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Copy</button>
</div>
<div>
<img id="qrImg" alt="QR" class="h-40 w-40" />
</div>
</div>
</section>
<section class="text-xs text-gray-500">
If album listing or creation is forbidden by your token, specify a fixed album in the .env file as IMMICH_ALBUM_NAME.
</section>
</div>
<script src="/static/header.js"></script>
<script>
const albumSelectWrap = document.getElementById('albumSelectWrap');
const albumSelect = document.getElementById('albumSelect');
const albumInputWrap = document.getElementById('albumInputWrap');
const albumInput = document.getElementById('albumInput');
const albumHint = document.getElementById('albumHint');
const btnCreateAlbum = document.getElementById('btnCreateAlbum');
const btnCreate = document.getElementById('btnCreate');
const usage = document.getElementById('usage');
const days = document.getElementById('days');
const result = document.getElementById('result');
const btnLogout = document.getElementById('btnLogout');
const btnTheme = document.getElementById('btnTheme');
const btnPing = document.getElementById('btnPing');
const pingStatus = document.getElementById('pingStatus');
const linkOut = document.getElementById('linkOut');
const btnCopy = document.getElementById('btnCopy');
const qrImg = document.getElementById('qrImg');
function showResult(kind, text){
result.className = 'rounded-xl border p-3 text-sm space-y-2 ' + (kind==='ok' ? 'border-green-200 bg-green-50 text-green-700 dark:bg-green-900 dark:border-green-700 dark:text-green-300' : 'border-red-200 bg-red-50 text-red-700 dark:bg-red-900 dark:border-red-700 dark:text-red-300');
result.classList.remove('hidden');
}
async function loadAlbums(){
try {
const r = await fetch('/api/albums');
if (r.status === 403) {
albumHint.textContent = 'Listing albums is forbidden with current credentials. Using .env IMMICH_ALBUM_NAME if set.';
albumInputWrap.classList.remove('hidden');
return;
}
const list = await r.json();
if (Array.isArray(list)){
albumSelect.innerHTML = list.map(a => `<option value="${a.id}">${a.albumName || a.title || a.id}</option>`).join('');
albumSelectWrap.classList.remove('hidden');
albumInputWrap.classList.remove('hidden');
albumHint.textContent = 'Pick an existing album, or type a new name and click Create album.';
}
} catch (e) {
albumHint.textContent = 'Failed to load albums.';
}
}
btnCreateAlbum.onclick = async () => {
const name = albumInput.value.trim();
if (!name) return;
try{
const r = await fetch('/api/albums', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({ name }) });
const j = await r.json().catch(()=>({}));
if(!r.ok){ showResult('err', j.error || 'Album create failed'); return; }
showResult('ok', `Album created: ${j.albumName || j.id || name}`);
try { await loadAlbums(); } catch {}
}catch(err){ showResult('err', String(err)); }
};
btnCreate.onclick = async () => {
let albumId = null, albumName = null;
if (!albumSelectWrap.classList.contains('hidden') && albumSelect.value) {
albumId = albumSelect.value;
} else if (albumInput.value.trim()) {
albumName = albumInput.value.trim();
}
const payload = { maxUses: parseInt(usage.value, 10) };
const d = days.value.trim();
if (d) payload.expiresDays = parseInt(d, 10);
if (albumId) payload.albumId = albumId; else if (albumName) payload.albumName = albumName;
try{
const r = await fetch('/api/invites', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify(payload) });
const j = await r.json().catch(()=>({}));
if(!r.ok){ showResult('err', j.error || 'Failed to create link'); return; }
const link = j.absoluteUrl || (location.origin + j.url);
showResult('ok', '');
linkOut.value = link;
// Build QR via backend PNG generator (no external libs)
qrImg.src = `/api/qr?text=${encodeURIComponent(link)}`;
}catch(err){ showResult('err', String(err)); }
};
// Logout handled via plain link to /logout (clears session + redirects)
btnCopy.onclick = async ()=>{
try { await navigator.clipboard.writeText(linkOut.value); btnCopy.textContent = 'Copied'; setTimeout(()=>btnCopy.textContent='Copy', 1200); } catch {}
};
// header.js wires theme + ping and shows banner consistently
loadAlbums();
</script>
</body>
</html>

11
main.py
View File

@@ -1,13 +1,16 @@
"""
Thin entrypoint so you can run `python main.py` from project root.
"""Thin entrypoint for local development.
Reads host/port from environment and starts Uvicorn.
"""
import os
import uvicorn
from dotenv import load_dotenv
load_dotenv()
if __name__ == "__main__":
# Load .env for host/port only; app config loads in app.config
try:
load_dotenv()
except Exception:
pass
host = os.getenv("HOST", "127.0.0.1")
port = int(os.getenv("PORT", "8080"))
uvicorn.run("app.app:app", host=host, port=port, reload=True)

View File

@@ -6,14 +6,18 @@ click==8.2.1
colorama==0.4.6
fastapi==0.116.1
h11==0.16.0
httpcore==1.0.9
httptools==0.6.4
httpx==0.28.1
idna==3.10
itsdangerous==2.2.0
pillow==11.3.0
pydantic==2.11.7
pydantic_core==2.33.2
python-dotenv==1.1.1
python-multipart==0.0.20
PyYAML==6.0.2
qrcode==8.2
requests==2.32.5
requests-toolbelt==1.0.0
sniffio==1.3.1