From 69aa1c031ecf4afa56ba92f6e2830b00f57d2f2e Mon Sep 17 00:00:00 2001 From: "MEGASOL\\simon.adams" Date: Tue, 16 Sep 2025 09:34:43 +0200 Subject: [PATCH] chunks enabled. --- .env.example | 6 + app/app.py | 429 ++++++++++++++++++++++++++++++++++++++++++- app/config.py | 9 + docker-compose.yml | 3 + frontend/app.js | 129 ++++++++++--- frontend/invite.html | 50 ++++- frontend/menu.html | 8 + 7 files changed, 600 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index f66f5f2..ed99c31 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,9 @@ STATE_DB=./data/state.db SESSION_SECRET=SET-A-STRONG-RANDOM-VALUE LOG_LEVEL=DEBUG +# Chunked uploads (to work around 100MB proxy limits) +# Enable to send files in chunks from browser to this service; the service reassembles and forwards to Immich. +# Recommended chunk size for Cloudflare Tunnel is <= 95MB. +CHUNKED_UPLOADS_ENABLED=false +CHUNK_SIZE_MB=95 + diff --git a/app/app.py b/app/app.py index 53bda4a..62789ff 100644 --- a/app/app.py +++ b/app/app.py @@ -59,6 +59,13 @@ app.add_middleware(SessionMiddleware, secret_key=SETTINGS.session_secret, same_s FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend") app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static") +# Chunk upload storage +CHUNK_ROOT = "/data/chunks" +try: + os.makedirs(CHUNK_ROOT, exist_ok=True) +except Exception: + pass + # Album cache ALBUM_ID: Optional[str] = None @@ -379,6 +386,8 @@ async def api_config() -> dict: """Expose minimal public configuration flags for the frontend.""" return { "public_upload_page_enabled": SETTINGS.public_upload_page_enabled, + "chunked_uploads_enabled": SETTINGS.chunked_uploads_enabled, + "chunk_size_mb": SETTINGS.chunk_size_mb, } @app.websocket("/ws") @@ -469,7 +478,7 @@ async def api_upload( 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,)) + cur.execute("SELECT token, album_id, album_name, max_uses, used_count, expires_at, COALESCE(claimed,0), claimed_by_session, password_hash FROM invites WHERE token = ?", (invite_token,)) row = cur.fetchone() conn.close() except Exception as e: @@ -478,7 +487,17 @@ async def api_upload( 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 + _, album_id, album_name, max_uses, used_count, expires_at, claimed, claimed_by_session, password_hash = row + # If invite requires password, ensure this session is authorized + if password_hash: + try: + ia = request.session.get("inviteAuth") or {} + if not ia.get(invite_token): + await send_progress(session_id, item_id, "error", 100, "Password required") + return JSONResponse({"error": "invite_password_required"}, status_code=403) + except Exception: + await send_progress(session_id, item_id, "error", 100, "Password required") + return JSONResponse({"error": "invite_password_required"}, status_code=403) # Expiry check if expires_at: try: @@ -604,6 +623,314 @@ async def api_upload( return await do_upload() +# --------- Chunked upload endpoints --------- + +def _chunk_dir(session_id: str, item_id: str) -> str: + safe_session = session_id.replace('/', '_') + safe_item = item_id.replace('/', '_') + return os.path.join(CHUNK_ROOT, safe_session, safe_item) + +@app.post("/api/upload/chunk/init") +async def api_upload_chunk_init(request: Request) -> JSONResponse: + """Initialize a chunked upload; creates a temp directory for incoming parts.""" + try: + data = await request.json() + except Exception: + return JSONResponse({"error": "invalid_json"}, status_code=400) + item_id = (data or {}).get("item_id") + session_id = (data or {}).get("session_id") + if not item_id or not session_id: + return JSONResponse({"error": "missing_ids"}, status_code=400) + d = _chunk_dir(session_id, item_id) + try: + os.makedirs(d, exist_ok=True) + # Write manifest for later use + meta = { + "name": (data or {}).get("name"), + "size": (data or {}).get("size"), + "last_modified": (data or {}).get("last_modified"), + "invite_token": (data or {}).get("invite_token"), + "content_type": (data or {}).get("content_type") or "application/octet-stream", + "created_at": datetime.utcnow().isoformat(), + } + with open(os.path.join(d, "meta.json"), "w", encoding="utf-8") as f: + json.dump(meta, f) + except Exception as e: + logger.exception("Chunk init failed: %s", e) + return JSONResponse({"error": "init_failed"}, status_code=500) + return JSONResponse({"ok": True}) + +@app.post("/api/upload/chunk") +async def api_upload_chunk( + request: Request, + item_id: str = Form(...), + session_id: str = Form(...), + chunk_index: int = Form(...), + total_chunks: int = Form(...), + invite_token: Optional[str] = Form(None), + chunk: UploadFile = Form(...), +) -> JSONResponse: + """Receive a single chunk; write to disk under chunk directory.""" + d = _chunk_dir(session_id, item_id) + try: + os.makedirs(d, exist_ok=True) + # Persist invite token in meta if provided (for consistency) + meta_path = os.path.join(d, "meta.json") + if os.path.exists(meta_path): + try: + with open(meta_path, "r", encoding="utf-8") as f: + meta = json.load(f) + except Exception: + meta = {} + else: + meta = {} + if invite_token: + meta["invite_token"] = invite_token + meta["total_chunks"] = int(total_chunks) + with open(meta_path, "w", encoding="utf-8") as f: + json.dump(meta, f) + # Save chunk + content = await chunk.read() + with open(os.path.join(d, f"part_{int(chunk_index):06d}"), "wb") as f: + f.write(content) + except Exception as e: + logger.exception("Chunk write failed: %s", e) + return JSONResponse({"error": "chunk_write_failed"}, status_code=500) + return JSONResponse({"ok": True}) + +@app.post("/api/upload/chunk/complete") +async def api_upload_chunk_complete(request: Request) -> JSONResponse: + """Assemble all parts and run the regular upload flow to Immich.""" + try: + data = await request.json() + except Exception: + return JSONResponse({"error": "invalid_json"}, status_code=400) + item_id = (data or {}).get("item_id") + session_id = (data or {}).get("session_id") + name = (data or {}).get("name") or "upload.bin" + last_modified = (data or {}).get("last_modified") + invite_token = (data or {}).get("invite_token") + content_type = (data or {}).get("content_type") or "application/octet-stream" + if not item_id or not session_id: + return JSONResponse({"error": "missing_ids"}, status_code=400) + d = _chunk_dir(session_id, item_id) + meta_path = os.path.join(d, "meta.json") + # Basic validation + try: + with open(meta_path, "r", encoding="utf-8") as f: + meta = json.load(f) + except Exception: + meta = {} + total_chunks = int(meta.get("total_chunks") or (data or {}).get("total_chunks") or 0) + if total_chunks <= 0: + return JSONResponse({"error": "missing_total"}, status_code=400) + # Assemble + parts = [] + try: + for i in range(total_chunks): + p = os.path.join(d, f"part_{i:06d}") + if not os.path.exists(p): + return JSONResponse({"error": "missing_part", "index": i}, status_code=400) + with open(p, "rb") as f: + parts.append(f.read()) + raw = b"".join(parts) + except Exception as e: + logger.exception("Assemble failed: %s", e) + return JSONResponse({"error": "assemble_failed"}, status_code=500) + # Cleanup parts promptly + try: + for i in range(total_chunks): + try: + os.remove(os.path.join(d, f"part_{i:06d}")) + except Exception: + pass + try: + os.remove(meta_path) + except Exception: + pass + try: + os.rmdir(d) + except Exception: + pass + except Exception: + pass + + # Now reuse the core logic from api_upload but with assembled bytes + item_id_local = item_id + session_id_local = session_id + file_like_name = name + file_size = len(raw) + checksum = sha1_hex(raw) + exif_created, exif_modified = read_exif_datetimes(raw) + created_at = exif_created or (datetime.fromtimestamp(last_modified / 1000) if last_modified else datetime.utcnow()) + modified_at = exif_modified or created_at + created_iso = created_at.isoformat() + modified_iso = modified_at.isoformat() + device_asset_id = f"{file_like_name}-{last_modified or 0}-{file_size}" + + # Local duplicate checks + if db_lookup_checksum(checksum): + await send_progress(session_id_local, item_id_local, "duplicate", 100, "Duplicate (by checksum - local cache)") + return JSONResponse({"status": "duplicate", "id": None}, status_code=200) + if db_lookup_device_asset(device_asset_id): + await send_progress(session_id_local, item_id_local, "duplicate", 100, "Already uploaded from this device (local cache)") + return JSONResponse({"status": "duplicate", "id": None}, status_code=200) + + await send_progress(session_id_local, item_id_local, "checking", 2, "Checking duplicates…") + bulk = immich_bulk_check([{ "id": item_id_local, "checksum": checksum }]) + if bulk.get(item_id_local, {}).get("action") == "reject" and bulk[item_id_local].get("reason") == "duplicate": + asset_id = bulk[item_id_local].get("assetId") + db_insert_upload(checksum, file_like_name, file_size, device_asset_id, asset_id, created_iso) + await send_progress(session_id_local, item_id_local, "duplicate", 100, "Duplicate (server)", asset_id) + return JSONResponse({"status": "duplicate", "id": asset_id}, status_code=200) + + def gen_encoder2() -> MultipartEncoder: + return MultipartEncoder(fields={ + "assetData": (file_like_name, io.BytesIO(raw), content_type or "application/octet-stream"), + "deviceAssetId": device_asset_id, + "deviceId": f"python-{session_id_local}", + "fileCreatedAt": created_iso, + "fileModifiedAt": modified_iso, + "isFavorite": "false", + "filename": file_like_name, + }) + + # Invite validation/gating mirrors api_upload + 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, password_hash 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_local, item_id_local, "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, password_hash = row + if password_hash: + try: + ia = request.session.get("inviteAuth") or {} + if not ia.get(invite_token): + await send_progress(session_id_local, item_id_local, "error", 100, "Password required") + return JSONResponse({"error": "invite_password_required"}, status_code=403) + except Exception: + await send_progress(session_id_local, item_id_local, "error", 100, "Password required") + return JSONResponse({"error": "invite_password_required"}, status_code=403) + # expiry + if expires_at: + try: + if datetime.utcnow() > datetime.fromisoformat(expires_at): + await send_progress(session_id_local, item_id_local, "error", 100, "Invite expired") + return JSONResponse({"error": "invite_expired"}, status_code=403) + except Exception: + pass + 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: + if claimed_by_session and claimed_by_session != session_id_local: + await send_progress(session_id_local, item_id_local, "error", 100, "Invite already used") + return JSONResponse({"error": "invite_claimed"}, status_code=403) + else: + 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_local, invite_token) + ) + connc.commit() + changed = connc.total_changes + connc.close() + except Exception as e: + logger.exception("Invite claim failed: %s", e) + return JSONResponse({"error": "invite_claim_failed"}, status_code=500) + if changed == 0: + try: + conn2 = sqlite3.connect(SETTINGS.state_db) + cur2 = conn2.cursor() + cur2.execute("SELECT claimed_by_session FROM invites WHERE token = ?", (invite_token,)) + owner_row = cur2.fetchone() + conn2.close() + owner = owner_row[0] if owner_row else None + except Exception: + owner = None + if not owner or owner != session_id_local: + await send_progress(session_id_local, item_id_local, "error", 100, "Invite already used") + return JSONResponse({"error": "invite_claimed"}, status_code=403) + else: + if (used_count or 0) >= (max_uses_int if max_uses_int >= 0 else 10**9): + await send_progress(session_id_local, item_id_local, "error", 100, "Invite already used up") + return JSONResponse({"error": "invite_exhausted"}, status_code=403) + target_album_id = album_id + target_album_name = album_name + + await send_progress(session_id_local, item_id_local, "uploading", 0, "Uploading…") + sent = {"pct": 0} + def cb2(monitor: MultipartEncoderMonitor) -> None: + if monitor.len: + pct = int(monitor.bytes_read * 100 / monitor.len) + if pct != sent["pct"]: + sent["pct"] = pct + asyncio.create_task(send_progress(session_id_local, item_id_local, "uploading", pct)) + encoder2 = gen_encoder2() + monitor2 = MultipartEncoderMonitor(encoder2, cb2) + headers = {"Accept": "application/json", "Content-Type": monitor2.content_type, "x-immich-checksum": checksum, **immich_headers(request)} + try: + r = requests.post(f"{SETTINGS.normalized_base_url}/assets", headers=headers, data=monitor2, timeout=120) + if r.status_code in (200, 201): + data_r = r.json() + asset_id = data_r.get("id") + db_insert_upload(checksum, file_like_name, file_size, device_asset_id, asset_id, created_iso) + status = data_r.get("status", "created") + 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_local, item_id_local, "duplicate" if status == "duplicate" else "done", 100, status, asset_id) + if invite_token: + try: + conn2 = sqlite3.connect(SETTINGS.state_db) + cur2 = conn2.cursor() + 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: + msg = r.json().get("message", r.text) + except Exception: + msg = r.text + await send_progress(session_id_local, item_id_local, "error", 100, msg) + return JSONResponse({"error": msg}, status_code=400) + except Exception as e: + await send_progress(session_id_local, item_id_local, "error", 100, str(e)) + return JSONResponse({"error": str(e)}, status_code=500) + @app.post("/api/album/reset") async def api_album_reset() -> dict: """Explicit trigger from the UI to clear cached album id.""" @@ -725,6 +1052,11 @@ def ensure_invites_table() -> None: cur.execute("ALTER TABLE invites ADD COLUMN claimed_by_session TEXT") except Exception: pass + # Optional password protection for invites + try: + cur.execute("ALTER TABLE invites ADD COLUMN password_hash TEXT") + except Exception: + pass conn.commit() conn.close() except Exception as e: @@ -745,6 +1077,7 @@ async def api_invites_create(request: Request) -> JSONResponse: album_id = (body or {}).get("albumId") album_name = (body or {}).get("albumName") max_uses = (body or {}).get("maxUses", 1) + invite_password = (body or {}).get("password") expires_days = (body or {}).get("expiresDays") # Normalize max_uses try: @@ -775,13 +1108,33 @@ async def api_invites_create(request: Request) -> JSONResponse: # Generate token import uuid token = uuid.uuid4().hex + # Prepare password hash, if provided + def hash_password(pw: str) -> str: + try: + if not pw: + return "" + import os as _os + import binascii as _binascii + salt = _os.urandom(16) + iterations = 200_000 + dk = hashlib.pbkdf2_hmac('sha256', pw.encode('utf-8'), salt, iterations) + return f"pbkdf2_sha256${iterations}${_binascii.hexlify(salt).decode()}${_binascii.hexlify(dk).decode()}" + except Exception: + return "" + pw_hash = hash_password(invite_password or "") if (invite_password and str(invite_password).strip()) else None 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) - ) + if pw_hash: + cur.execute( + "INSERT INTO invites (token, album_id, album_name, max_uses, expires_at, password_hash) VALUES (?,?,?,?,?,?)", + (token, resolved_album_id, album_name, max_uses, expires_at, pw_hash) + ) + else: + 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: @@ -812,11 +1165,11 @@ async def invite_page(token: str, request: Request) -> HTMLResponse: return FileResponse(os.path.join(FRONTEND_DIR, "invite.html")) @app.get("/api/invite/{token}") -async def api_invite_info(token: str) -> JSONResponse: +async def api_invite_info(token: str, request: Request) -> 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,)) + cur.execute("SELECT token, album_id, album_name, max_uses, used_count, expires_at, COALESCE(claimed,0), claimed_at, password_hash FROM invites WHERE token = ?", (token,)) row = cur.fetchone() conn.close() except Exception as e: @@ -824,7 +1177,7 @@ async def api_invite_info(token: str) -> JSONResponse: 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 + _, album_id, album_name, max_uses, used_count, expires_at, claimed, claimed_at, password_hash = row # compute remaining remaining = None try: @@ -851,6 +1204,14 @@ async def api_invite_info(token: str) -> JSONResponse: if expired: deactivated = True active = not deactivated + # Password requirement + authorization state + password_required = bool(password_hash) + authorized = False + try: + ia = request.session.get("inviteAuth") or {} + authorized = bool(ia.get(token)) + except Exception: + authorized = False return JSONResponse({ "token": token, "albumId": album_id, @@ -864,8 +1225,58 @@ async def api_invite_info(token: str) -> JSONResponse: "claimedAt": claimed_at, "expired": expired, "active": active, + "passwordRequired": password_required, + "authorized": authorized, }) +@app.post("/api/invite/{token}/auth") +async def api_invite_auth(token: str, request: Request) -> JSONResponse: + """Validate a password for an invite token, and mark this session authorized if valid.""" + try: + body = await request.json() + except Exception: + body = None + provided = (body or {}).get("password") if isinstance(body, dict) else None + try: + conn = sqlite3.connect(SETTINGS.state_db) + cur = conn.cursor() + cur.execute("SELECT password_hash FROM invites WHERE token = ?", (token,)) + row = cur.fetchone() + conn.close() + except Exception as e: + logger.exception("Invite auth lookup error: %s", e) + return JSONResponse({"error": "db_error"}, status_code=500) + if not row: + return JSONResponse({"error": "not_found"}, status_code=404) + password_hash = row[0] + if not password_hash: + # No password required; mark as authorized to simplify client flow + ia = request.session.get("inviteAuth") or {} + ia[token] = True + request.session["inviteAuth"] = ia + return JSONResponse({"ok": True, "authorized": True}) + # verify + def verify_password(stored: str, pw: Optional[str]) -> bool: + if not pw: + return False + try: + algo, iter_s, salt_hex, hash_hex = stored.split("$") + if algo != 'pbkdf2_sha256': + return False + iterations = int(iter_s) + import binascii as _binascii + salt = _binascii.unhexlify(salt_hex) + dk = hashlib.pbkdf2_hmac('sha256', pw.encode('utf-8'), salt, iterations) + return _binascii.hexlify(dk).decode() == hash_hex + except Exception: + return False + if not verify_password(password_hash, provided): + return JSONResponse({"error": "invalid_password"}, status_code=403) + ia = request.session.get("inviteAuth") or {} + ia[token] = True + request.session["inviteAuth"] = ia + return JSONResponse({"ok": True, "authorized": True}) + @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').""" diff --git a/app/config.py b/app/config.py index bcd4ffc..4e1981a 100644 --- a/app/config.py +++ b/app/config.py @@ -22,6 +22,8 @@ class Settings: state_db: str = "" session_secret: str = "" log_level: str = "INFO" + chunked_uploads_enabled: bool = False + chunk_size_mb: int = 95 @property def normalized_base_url(self) -> str: @@ -51,6 +53,11 @@ def load_settings() -> Settings: state_db = os.getenv("STATE_DB", "/data/state.db") session_secret = os.getenv("SESSION_SECRET") or secrets.token_hex(32) log_level = os.getenv("LOG_LEVEL", "INFO").upper() + chunked_uploads_enabled = as_bool(os.getenv("CHUNKED_UPLOADS_ENABLED", "false"), False) + try: + chunk_size_mb = int(os.getenv("CHUNK_SIZE_MB", "95")) + except ValueError: + chunk_size_mb = 95 return Settings( immich_base_url=base, immich_api_key=api_key, @@ -61,4 +68,6 @@ def load_settings() -> Settings: state_db=state_db, session_secret=session_secret, log_level=log_level, + chunked_uploads_enabled=chunked_uploads_enabled, + chunk_size_mb=chunk_size_mb, ) diff --git a/docker-compose.yml b/docker-compose.yml index 7194a98..6fd9f69 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,9 @@ services: #Enable/Disable Public upload page to folder PUBLIC_UPLOAD_PAGE_ENABLED: false IMMICH_ALBUM_NAME: dead-drop + # Chunked uploads to bypass 100MB limits (e.g., Cloudflare Tunnel) + CHUNKED_UPLOADS_ENABLED: true + CHUNK_SIZE_MB: 95 volumes: diff --git a/frontend/app.js b/frontend/app.js index 3acae4f..2d61075 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,5 +1,6 @@ // Frontend logic (mobile-safe picker; no settings UI) const sessionId = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(36).slice(2)); +let CFG = { chunked_uploads_enabled: false, chunk_size_mb: 95 }; // Detect invite token from URL path /invite/{token} let INVITE_TOKEN = null; try { @@ -40,6 +41,21 @@ function updateThemeIcon() { initDarkMode(); +// --- Load minimal config --- +(async function loadConfig(){ + try{ + const r = await fetch('/api/config'); + if (r.ok) { + const j = await r.json(); + if (j && typeof j === 'object') { + CFG.chunked_uploads_enabled = !!j.chunked_uploads_enabled; + const n = parseInt(j.chunk_size_mb, 10); + if (!Number.isNaN(n) && n > 0) CFG.chunk_size_mb = n; + } + } + }catch{} +})(); + // --- helpers --- function human(bytes){ if (!bytes) return '0 B'; @@ -156,27 +172,10 @@ async function runQueue(){ render(); inflight++; try{ - const form = new FormData(); - form.append('file', next.file); - 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'){ - next.status='error'; - next.message = body.error || 'Upload failed'; - render(); - } else if (res.ok) { - // Fallback finalize on HTTP success in case WS final message is missed - const statusText = (body && body.status) ? String(body.status) : ''; - const isDuplicate = /duplicate/i.test(statusText); - next.status = isDuplicate ? 'duplicate' : 'done'; - next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded'); - next.progress = 100; - render(); - try { showBanner(isDuplicate ? `Duplicate: ${next.name}` : `Uploaded: ${next.name}`, isDuplicate ? 'warn' : 'ok'); } catch {} + if (CFG.chunked_uploads_enabled && next.file.size > (CFG.chunk_size_mb * 1024 * 1024)) { + await uploadChunked(next); + } else { + await uploadWhole(next); } }catch(err){ next.status='error'; @@ -190,6 +189,94 @@ async function runQueue(){ for(let i=0;i<3;i++) runNext(); } +async function uploadWhole(next){ + const form = new FormData(); + form.append('file', next.file); + 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'){ + next.status='error'; + next.message = body.error || 'Upload failed'; + render(); + } else if (res.ok) { + const statusText = (body && body.status) ? String(body.status) : ''; + const isDuplicate = /duplicate/i.test(statusText); + next.status = isDuplicate ? 'duplicate' : 'done'; + next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded'); + next.progress = 100; + render(); + try { showBanner(isDuplicate ? `Duplicate: ${next.name}` : `Uploaded: ${next.name}`, isDuplicate ? 'warn' : 'ok'); } catch {} + } +} + +async function uploadChunked(next){ + const chunkBytes = Math.max(1, CFG.chunk_size_mb|0) * 1024 * 1024; + const total = Math.ceil(next.file.size / chunkBytes) || 1; + // init + try { + await fetch('/api/upload/chunk/init', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({ + item_id: next.id, + session_id: sessionId, + name: next.file.name, + size: next.file.size, + last_modified: next.file.lastModified || '', + invite_token: INVITE_TOKEN || '', + content_type: next.file.type || 'application/octet-stream' + }) }); + } catch {} + // upload parts + let uploaded = 0; + for (let i=0;i({})); + throw new Error(j.error || `Chunk ${i} failed`); + } + uploaded++; + // Approximate progress until final server-side upload takes over + next.status = 'uploading'; + next.progress = Math.min(90, Math.floor((uploaded/total) * 60) + 20); // stay under 100 until WS finish + render(); + } + // complete + const rc = await fetch('/api/upload/chunk/complete', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({ + item_id: next.id, + session_id: sessionId, + name: next.file.name, + last_modified: next.file.lastModified || '', + invite_token: INVITE_TOKEN || '', + content_type: next.file.type || 'application/octet-stream', + total_chunks: total + }) }); + const body = await rc.json().catch(()=>({})); + if (!rc.ok && next.status!=='error'){ + next.status='error'; + next.message = body.error || 'Upload failed'; + render(); + } else if (rc.ok) { + const statusText = (body && body.status) ? String(body.status) : ''; + const isDuplicate = /duplicate/i.test(statusText); + next.status = isDuplicate ? 'duplicate' : 'done'; + next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded'); + next.progress = 100; + render(); + } +} + // --- DOM refs --- const dz = document.getElementById('dropzone'); const fi = document.getElementById('fileInput'); diff --git a/frontend/invite.html b/frontend/invite.html index fb20929..ea630cc 100644 --- a/frontend/invite.html +++ b/frontend/invite.html @@ -53,6 +53,16 @@ + + +
+
+
Password (optional)
+ +
Recipients must enter this password before uploading.
+
@@ -96,6 +101,7 @@ const linkOut = document.getElementById('linkOut'); const btnCopy = document.getElementById('btnCopy'); const qrImg = document.getElementById('qrImg'); + const passwordInput = document.getElementById('password'); 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'); @@ -145,6 +151,8 @@ const d = days.value.trim(); if (d) payload.expiresDays = parseInt(d, 10); if (albumId) payload.albumId = albumId; else if (albumName) payload.albumName = albumName; + const pw = (passwordInput && passwordInput.value) ? passwordInput.value.trim() : ''; + if (pw) payload.password = pw; 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(()=>({}));