upload unique links + default upload page + login
This commit is contained in:
@@ -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)
|
IMMICH_API_KEY=ADD-YOUR-API-KEY #key needs asset.upload (default functions)
|
||||||
MAX_CONCURRENT=3
|
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)
|
IMMICH_ALBUM_NAME=dead-drop #key needs album.create,album.read,albumAsset.create (extended functions)
|
||||||
|
|
||||||
# Data path inside the container
|
# Data path inside the container
|
||||||
|
|||||||
499
app/app.py
499
app/app.py
@@ -21,22 +21,21 @@ from typing import Dict, List, Optional
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
from requests_toolbelt.multipart.encoder import MultipartEncoder, MultipartEncoderMonitor
|
from requests_toolbelt.multipart.encoder import MultipartEncoder, MultipartEncoderMonitor
|
||||||
|
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
|
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.websockets import WebSocketState
|
from starlette.websockets import WebSocketState
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
from PIL import Image, ExifTags
|
from PIL import Image, ExifTags
|
||||||
from dotenv import load_dotenv
|
try:
|
||||||
|
import qrcode
|
||||||
|
except Exception:
|
||||||
|
qrcode = None
|
||||||
|
|
||||||
from app.config import Settings, load_settings
|
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 & static ----
|
||||||
app = FastAPI(title="Immich Drop Uploader (Python)")
|
app = FastAPI(title="Immich Drop Uploader (Python)")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -47,12 +46,19 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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)
|
# Global settings (read-only at runtime)
|
||||||
SETTINGS: Settings = load_settings()
|
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 cache
|
||||||
ALBUM_ID: Optional[str] = None
|
ALBUM_ID: Optional[str] = None
|
||||||
|
|
||||||
@@ -65,7 +71,7 @@ def reset_album_cache() -> None:
|
|||||||
|
|
||||||
def db_init() -> None:
|
def db_init() -> None:
|
||||||
"""Create the local SQLite table used for duplicate checks (idempotent)."""
|
"""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 = conn.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
@@ -86,7 +92,7 @@ def db_init() -> None:
|
|||||||
|
|
||||||
def db_lookup_checksum(checksum: str) -> Optional[dict]:
|
def db_lookup_checksum(checksum: str) -> Optional[dict]:
|
||||||
"""Return a record for the given checksum if seen before (None if not)."""
|
"""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 = conn.cursor()
|
||||||
cur.execute("SELECT checksum, immich_asset_id FROM uploads WHERE checksum = ?", (checksum,))
|
cur.execute("SELECT checksum, immich_asset_id FROM uploads WHERE checksum = ?", (checksum,))
|
||||||
row = cur.fetchone()
|
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:
|
def db_lookup_device_asset(device_asset_id: str) -> bool:
|
||||||
"""True if a deviceAssetId has been uploaded by this service previously."""
|
"""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 = conn.cursor()
|
||||||
cur.execute("SELECT 1 FROM uploads WHERE device_asset_id = ?", (device_asset_id,))
|
cur.execute("SELECT 1 FROM uploads WHERE device_asset_id = ?", (device_asset_id,))
|
||||||
row = cur.fetchone()
|
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:
|
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)."""
|
"""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 = conn.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT OR IGNORE INTO uploads (checksum, filename, size, device_asset_id, immich_asset_id, created_at) VALUES (?,?,?,?,?,?)",
|
"INSERT OR IGNORE INTO uploads (checksum, filename, size, device_asset_id, immich_asset_id, created_at) VALUES (?,?,?,?,?,?)",
|
||||||
@@ -197,66 +203,86 @@ def read_exif_datetimes(file_bytes: bytes):
|
|||||||
pass
|
pass
|
||||||
return created, modified
|
return created, modified
|
||||||
|
|
||||||
def immich_headers() -> dict:
|
def immich_headers(request: Optional[Request] = None) -> dict:
|
||||||
"""Headers for Immich API calls (keeps key server-side)."""
|
"""Headers for Immich API calls using either session access token or API key."""
|
||||||
return {"Accept": "application/json", "x-api-key": SETTINGS.immich_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."""
|
"""Get existing album by name or create a new one. Returns album ID or None."""
|
||||||
global ALBUM_ID
|
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
|
# Skip if no album name configured
|
||||||
if not SETTINGS.album_name:
|
if not album_name:
|
||||||
return None
|
return None
|
||||||
|
# Return cached album ID if already fetched and using default settings name
|
||||||
# Return cached album ID if already fetched
|
if album_name_override is None and ALBUM_ID:
|
||||||
if ALBUM_ID:
|
|
||||||
return ALBUM_ID
|
return ALBUM_ID
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# First, try to find existing album
|
# First, try to find existing album
|
||||||
url = f"{SETTINGS.normalized_base_url}/albums"
|
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:
|
if r.status_code == 200:
|
||||||
albums = r.json()
|
albums = r.json()
|
||||||
for album in albums:
|
for album in albums:
|
||||||
if album.get("albumName") == SETTINGS.album_name:
|
if album.get("albumName") == album_name:
|
||||||
ALBUM_ID = album.get("id")
|
found_id = album.get("id")
|
||||||
print(f"Found existing album '{SETTINGS.album_name}' with ID: {ALBUM_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
|
return ALBUM_ID
|
||||||
|
else:
|
||||||
|
return found_id
|
||||||
|
|
||||||
# Album doesn't exist, create it
|
# Album doesn't exist, create it
|
||||||
create_url = f"{SETTINGS.normalized_base_url}/albums"
|
create_url = f"{SETTINGS.normalized_base_url}/albums"
|
||||||
payload = {
|
payload = {
|
||||||
"albumName": SETTINGS.album_name,
|
"albumName": album_name,
|
||||||
"description": "Auto-created album for Immich Drop uploads"
|
"description": "Auto-created album for Immich Drop uploads"
|
||||||
}
|
}
|
||||||
r = requests.post(create_url, headers={**immich_headers(), "Content-Type": "application/json"},
|
r = requests.post(create_url, headers={**immich_headers(request), "Content-Type": "application/json"},
|
||||||
json=payload, timeout=10)
|
json=payload, timeout=10)
|
||||||
|
|
||||||
if r.status_code in (200, 201):
|
if r.status_code in (200, 201):
|
||||||
data = r.json()
|
data = r.json()
|
||||||
ALBUM_ID = data.get("id")
|
new_id = data.get("id")
|
||||||
print(f"Created new album '{SETTINGS.album_name}' with ID: {ALBUM_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
|
return ALBUM_ID
|
||||||
else:
|
else:
|
||||||
print(f"Failed to create album: {r.status_code} - {r.text}")
|
logger.info("Created new album '%s' with ID: %s", album_name, new_id)
|
||||||
|
return new_id
|
||||||
|
else:
|
||||||
|
logger.warning("Failed to create album: %s - %s", r.status_code, r.text)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error managing album: {e}")
|
logger.exception("Error managing album: %s", e)
|
||||||
|
|
||||||
return None
|
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."""
|
"""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:
|
if not album_id or not asset_id:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
url = f"{SETTINGS.normalized_base_url}/albums/{album_id}/assets"
|
url = f"{SETTINGS.normalized_base_url}/albums/{album_id}/assets"
|
||||||
payload = {"ids": [asset_id]}
|
payload = {"ids": [asset_id]}
|
||||||
r = requests.put(url, headers={**immich_headers(), "Content-Type": "application/json"},
|
r = requests.put(url, headers={**immich_headers(request), "Content-Type": "application/json"},
|
||||||
json=payload, timeout=10)
|
json=payload, timeout=10)
|
||||||
|
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
@@ -270,7 +296,7 @@ def add_asset_to_album(asset_id: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error adding asset to album: {e}")
|
logger.exception("Error adding asset to album: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def immich_ping() -> bool:
|
def immich_ping() -> bool:
|
||||||
@@ -312,10 +338,24 @@ async def send_progress(session_id: str, item_id: str, status: str, progress: in
|
|||||||
# ---------- Routes ----------
|
# ---------- Routes ----------
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def index(_: Request) -> HTMLResponse:
|
async def index(request: Request) -> HTMLResponse:
|
||||||
"""Serve the SPA (frontend/index.html)."""
|
"""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"))
|
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")
|
@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."""
|
||||||
@@ -325,6 +365,13 @@ async def api_ping() -> dict:
|
|||||||
"album_name": SETTINGS.album_name if SETTINGS.album_name else None
|
"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")
|
@app.websocket("/ws")
|
||||||
async def ws_endpoint(ws: WebSocket) -> None:
|
async def ws_endpoint(ws: WebSocket) -> None:
|
||||||
"""WebSocket endpoint for pushing per-item upload progress."""
|
"""WebSocket endpoint for pushing per-item upload progress."""
|
||||||
@@ -358,11 +405,12 @@ async def ws_endpoint(ws: WebSocket) -> None:
|
|||||||
|
|
||||||
@app.post("/api/upload")
|
@app.post("/api/upload")
|
||||||
async def api_upload(
|
async def api_upload(
|
||||||
_: Request,
|
request: Request,
|
||||||
file: UploadFile,
|
file: UploadFile,
|
||||||
item_id: str = Form(...),
|
item_id: str = Form(...),
|
||||||
session_id: str = Form(...),
|
session_id: str = Form(...),
|
||||||
last_modified: Optional[int] = Form(None),
|
last_modified: Optional[int] = Form(None),
|
||||||
|
invite_token: Optional[str] = Form(None),
|
||||||
):
|
):
|
||||||
"""Receive a file, check duplicates, forward to Immich; stream progress via WS."""
|
"""Receive a file, check duplicates, forward to Immich; stream progress via WS."""
|
||||||
raw = await file.read()
|
raw = await file.read()
|
||||||
@@ -405,6 +453,65 @@ async def api_upload(
|
|||||||
|
|
||||||
encoder = gen_encoder()
|
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():
|
async def do_upload():
|
||||||
await send_progress(session_id, item_id, "uploading", 0, "Uploading…")
|
await send_progress(session_id, item_id, "uploading", 0, "Uploading…")
|
||||||
sent = {"pct": 0}
|
sent = {"pct": 0}
|
||||||
@@ -415,7 +522,7 @@ async def api_upload(
|
|||||||
sent["pct"] = pct
|
sent["pct"] = pct
|
||||||
asyncio.create_task(send_progress(session_id, item_id, "uploading", pct))
|
asyncio.create_task(send_progress(session_id, item_id, "uploading", pct))
|
||||||
monitor = MultipartEncoderMonitor(encoder, cb)
|
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:
|
try:
|
||||||
r = requests.post(f"{SETTINGS.normalized_base_url}/assets", headers=headers, data=monitor, timeout=120)
|
r = requests.post(f"{SETTINGS.normalized_base_url}/assets", headers=headers, data=monitor, timeout=120)
|
||||||
if r.status_code in (200, 201):
|
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)
|
db_insert_upload(checksum, file.filename, size, device_asset_id, asset_id, created_iso)
|
||||||
status = data.get("status", "created")
|
status = data.get("status", "created")
|
||||||
|
|
||||||
# Add to album if configured
|
# Add to album if configured (invite overrides .env)
|
||||||
if SETTINGS.album_name and asset_id:
|
if asset_id:
|
||||||
if add_asset_to_album(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}')"
|
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)
|
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)
|
return JSONResponse({"id": asset_id, "status": status}, status_code=200)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
@@ -450,6 +585,278 @@ async def api_album_reset() -> dict:
|
|||||||
reset_album_cache()
|
reset_album_cache()
|
||||||
return {"ok": True}
|
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
|
Note: Do not run this module directly. Use `python main.py` from
|
||||||
project root, which starts `uvicorn app.app:app` with reload.
|
project root, which starts `uvicorn app.app:app` with reload.
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ Reads ONLY from .env; there is NO runtime mutation from the UI.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
import secrets
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -15,6 +17,11 @@ class Settings:
|
|||||||
immich_api_key: str
|
immich_api_key: str
|
||||||
max_concurrent: int = 3
|
max_concurrent: int = 3
|
||||||
album_name: str = ""
|
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
|
@property
|
||||||
def normalized_base_url(self) -> str:
|
def normalized_base_url(self) -> str:
|
||||||
@@ -23,11 +30,35 @@ class Settings:
|
|||||||
|
|
||||||
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
|
||||||
|
try:
|
||||||
|
load_dotenv()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
base = os.getenv("IMMICH_BASE_URL", "http://127.0.0.1:2283/api")
|
base = os.getenv("IMMICH_BASE_URL", "http://127.0.0.1:2283/api")
|
||||||
api_key = os.getenv("IMMICH_API_KEY", "")
|
api_key = os.getenv("IMMICH_API_KEY", "")
|
||||||
album_name = os.getenv("IMMICH_ALBUM_NAME", "")
|
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:
|
try:
|
||||||
maxc = int(os.getenv("MAX_CONCURRENT", "3"))
|
maxc = int(os.getenv("MAX_CONCURRENT", "3"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
maxc = 3
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
// Frontend logic (mobile-safe picker; no settings UI)
|
// Frontend logic (mobile-safe picker; no settings UI)
|
||||||
const sessionId = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(36).slice(2));
|
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 items = [];
|
||||||
let socket;
|
let socket;
|
||||||
|
|
||||||
@@ -24,8 +32,10 @@ function toggleDarkMode() {
|
|||||||
|
|
||||||
function updateThemeIcon() {
|
function updateThemeIcon() {
|
||||||
const isDark = document.documentElement.classList.contains('dark');
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
document.getElementById('iconLight').classList.toggle('hidden', !isDark);
|
const light = document.getElementById('iconLight');
|
||||||
document.getElementById('iconDark').classList.toggle('hidden', isDark);
|
const dark = document.getElementById('iconDark');
|
||||||
|
if (light && light.classList) light.classList.toggle('hidden', !isDark);
|
||||||
|
if (dark && dark.classList) dark.classList.toggle('hidden', isDark);
|
||||||
}
|
}
|
||||||
|
|
||||||
initDarkMode();
|
initDarkMode();
|
||||||
@@ -131,6 +141,7 @@ async function runQueue(){
|
|||||||
form.append('item_id', next.id);
|
form.append('item_id', next.id);
|
||||||
form.append('session_id', sessionId);
|
form.append('session_id', sessionId);
|
||||||
form.append('last_modified', next.file.lastModified || '');
|
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 res = await fetch('/api/upload', { method:'POST', body: form });
|
||||||
const body = await res.json().catch(()=>({}));
|
const body = await res.json().catch(()=>({}));
|
||||||
if(!res.ok && next.status!=='error'){
|
if(!res.ok && next.status!=='error'){
|
||||||
@@ -184,7 +195,7 @@ function showBanner(text, kind='ok'){
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Connection test with ephemeral banner ---
|
// --- Connection test with ephemeral banner ---
|
||||||
btnPing.onclick = async () => {
|
if (btnPing) btnPing.onclick = async () => {
|
||||||
pingStatus.textContent = 'checking…';
|
pingStatus.textContent = 'checking…';
|
||||||
try{
|
try{
|
||||||
const r = await fetch('/api/ping', { method:'POST' });
|
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) ---
|
// --- 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'); }));
|
['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'); }));
|
['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 ---
|
// --- Dark mode toggle ---
|
||||||
btnTheme.onclick = toggleDarkMode;
|
if (btnTheme) btnTheme.onclick = toggleDarkMode;
|
||||||
|
|||||||
94
frontend/header.js
Normal file
94
frontend/header.js
Normal 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 };
|
||||||
|
})();
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<header class="flex items-center justify-between">
|
<header class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Immich Drop Uploader</h1>
|
<h1 class="text-2xl font-semibold tracking-tight">Immich Drop Uploader</h1>
|
||||||
<div class="flex items-center gap-2">
|
<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">
|
<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">
|
<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"/>
|
<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
131
frontend/invite.html
Normal 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
74
frontend/login.html
Normal 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
168
frontend/menu.html
Normal 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
11
main.py
@@ -1,13 +1,16 @@
|
|||||||
"""
|
"""Thin entrypoint for local development.
|
||||||
Thin entrypoint so you can run `python main.py` from project root.
|
Reads host/port from environment and starts Uvicorn.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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")
|
host = os.getenv("HOST", "127.0.0.1")
|
||||||
port = int(os.getenv("PORT", "8080"))
|
port = int(os.getenv("PORT", "8080"))
|
||||||
uvicorn.run("app.app:app", host=host, port=port, reload=True)
|
uvicorn.run("app.app:app", host=host, port=port, reload=True)
|
||||||
@@ -6,14 +6,18 @@ click==8.2.1
|
|||||||
colorama==0.4.6
|
colorama==0.4.6
|
||||||
fastapi==0.116.1
|
fastapi==0.116.1
|
||||||
h11==0.16.0
|
h11==0.16.0
|
||||||
|
httpcore==1.0.9
|
||||||
httptools==0.6.4
|
httptools==0.6.4
|
||||||
|
httpx==0.28.1
|
||||||
idna==3.10
|
idna==3.10
|
||||||
|
itsdangerous==2.2.0
|
||||||
pillow==11.3.0
|
pillow==11.3.0
|
||||||
pydantic==2.11.7
|
pydantic==2.11.7
|
||||||
pydantic_core==2.33.2
|
pydantic_core==2.33.2
|
||||||
python-dotenv==1.1.1
|
python-dotenv==1.1.1
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
PyYAML==6.0.2
|
PyYAML==6.0.2
|
||||||
|
qrcode==8.2
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
requests-toolbelt==1.0.0
|
requests-toolbelt==1.0.0
|
||||||
sniffio==1.3.1
|
sniffio==1.3.1
|
||||||
|
|||||||
Reference in New Issue
Block a user