Coverage for src/web.py: 100%
334 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-05 00:26 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-05 00:26 +0000
1"""Web UI for icloud-docker.
3Goal — give the user a single page they can hit from any device to:
4 1) (primary) authenticate / re-authenticate Apple ID + 2FA;
5 2) (secondary) confirm config paths, mount markers, and last-sync status;
6 3) (tertiary) tail the recent log lines.
8The web server runs in a daemon thread spawned from ``main.py`` alongside
9the existing ``sync.sync()`` loop. The two share state through the
10filesystem (keyring, session cookies, log file). No new persistence layer.
12Designed for **LAN- or proxy-trusted** exposure. There is no built-in
13login on this UI — put Cloudflare Access / Authelia / Tailscale in front
14when exposing publicly. Opt-out via ``app.web_ui.enabled: false`` in
15``config.yaml``.
16"""
18__author__ = "Mandar Patil (mandarons@pm.me)"
20import hmac
21import os
22import secrets
23import threading
24import time
25from typing import Any
27from flask import Flask, jsonify, redirect, render_template, request, url_for
28from werkzeug.serving import make_server
30from src import (
31 DEFAULT_CONFIG_FILE_PATH,
32 DEFAULT_COOKIE_DIRECTORY,
33 ENV_CONFIG_FILE_PATH_KEY,
34 config_parser,
35 get_logger,
36 read_config,
37 web_signals,
38)
40LOGGER = get_logger()
42# Module-level holder for the live icloudpy session created during
43# POST /auth/password, so POST /auth/code can call validate_2fa_code on
44# the SAME session. Cleared after a successful trust_session or via
45# POST /auth/reset.
46_PENDING_AUTH: dict[str, Any] = {}
47_AUTH_LOCK = threading.Lock()
49# Drop stale pending auth after this many seconds. The submitted Apple ID
50# password sits in process memory (in ``_PENDING_AUTH["password"]``) while
51# waiting for the user to enter their 2FA code; without an expiry it would
52# linger indefinitely if the user closed the browser tab mid-flow. 10 min
53# is generous for typing a code -- and short enough that a forgotten
54# session evaporates before the next sync cycle picks up the keyring.
55_PENDING_AUTH_TTL_SECONDS = 600
58def _pending_auth_is_stale() -> bool:
59 """True when the in-memory password is older than the TTL.
61 Caller must hold ``_AUTH_LOCK``. Returns False for an empty dict
62 (nothing to expire) and for entries that pre-date stashed_at
63 bookkeeping (defensive — we never penalise a fresh stash).
64 """
65 if not _PENDING_AUTH:
66 return False
67 stashed_at = _PENDING_AUTH.get("stashed_at")
68 if stashed_at is None:
69 return False
70 return (time.monotonic() - stashed_at) > _PENDING_AUTH_TTL_SECONDS
73def _expire_stale_pending_auth_unlocked() -> None:
74 """If the pending auth is older than the TTL, wipe it. Caller holds the lock."""
75 if _pending_auth_is_stale():
76 LOGGER.info("Web UI: expiring stale _PENDING_AUTH past TTL.")
77 _PENDING_AUTH.clear()
80# CSRF defence. Threat model: even with the default host pinned to
81# 127.0.0.1, a user who opts into LAN exposure (host: 0.0.0.0) AND lacks
82# a proper auth proxy in front would otherwise be vulnerable to a
83# same-network attacker who tricks them into loading a page that posts
84# to ``/auth/refresh-trust`` or ``/api/sync``. Double-submit cookie
85# pattern: per-process random token, set as a SameSite=Strict cookie,
86# required on every state-changing POST as either a form field
87# ``csrf_token`` or an ``X-CSRF-Token`` header. SameSite=Strict alone
88# already blocks the cross-site cookie send in modern browsers; the
89# server-side compare is belt-and-braces for older clients.
90_CSRF_TOKEN = secrets.token_urlsafe(32)
91_CSRF_COOKIE_NAME = "csrf_token"
94def _get_csrf_token() -> str:
95 """Expose the token to templates (so forms can embed it) and to
96 tests (so they can post it). Process-lifetime, regenerated on
97 restart -- enough for a single-user operator console."""
98 return _CSRF_TOKEN
101def _require_csrf() -> tuple[Any, int] | None:
102 """Validate CSRF token on the current request. Returns ``None``
103 when the request is allowed, or a ``(response, status)`` tuple
104 when it should be rejected. Use at the top of every state-
105 changing endpoint:
107 rejection = _require_csrf()
108 if rejection is not None:
109 return rejection
111 **Calling from a script / monitor.** Every state-changing endpoint
112 needs BOTH the CSRF cookie and a matching token, so a bare
113 ``curl -X POST /api/sync -d service=drive`` gets a 403. The cookie
114 is only set by a prior page load, so fetch it first and echo it
115 back in the ``X-CSRF-Token`` header::
117 curl -c jar -s http://127.0.0.1:8080/ >/dev/null
118 TOKEN=$(awk '/csrf_token/ {print $7}' jar)
119 curl -b jar -H "X-CSRF-Token: $TOKEN" \
120 -X POST http://127.0.0.1:8080/api/sync -d service=drive
122 The 403 bodies name which leg failed ("CSRF cookie missing or
123 stale" vs "CSRF token mismatch") so the fix is obvious.
124 """
125 cookie = request.cookies.get(_CSRF_COOKIE_NAME)
126 submitted = request.form.get("csrf_token") or request.headers.get("X-CSRF-Token")
127 # The cookie must match this process's token AND the submitted value
128 # must match the cookie. Browsers won't include a SameSite=Strict
129 # cookie on a cross-site POST, so the cookie absence alone is the
130 # primary signal; the form/header echo is the belt-and-braces leg.
131 if not cookie or not hmac.compare_digest(cookie, _CSRF_TOKEN):
132 return jsonify({"error": "CSRF cookie missing or stale"}), 403
133 if not submitted or not hmac.compare_digest(submitted, cookie):
134 return jsonify({"error": "CSRF token mismatch"}), 403
135 return None
138def _current_config_path() -> str:
139 """Resolve the active config path the same way sync.py does."""
140 return os.environ.get(ENV_CONFIG_FILE_PATH_KEY, DEFAULT_CONFIG_FILE_PATH)
143def _load_current_config() -> dict | None:
144 """Re-read config.yaml fresh on every request so edits show up live.
146 Defensive: mandarons' ``read_config`` reaches into
147 ``config["app"]["credentials"]["username"]`` unconditionally and
148 crashes if the credentials block is missing. Catch that so a partial
149 config (e.g. fresh install with only ``app.logger`` set) still lets
150 the web UI render the setup-needed state instead of 500-ing.
151 """
152 path = _current_config_path()
153 if not os.path.isfile(path):
154 return None
155 try:
156 return read_config(config_path=path)
157 except Exception as e:
158 # Broad on purpose: ``/api/health`` exists for external monitors
159 # and must be robust against any config-loading failure (YAML
160 # parse errors, permission denied, missing credentials block,
161 # ruamel internals raising). A 500 on /api/health blinds the
162 # monitor; rendering a "config error" state lets the user fix
163 # it via the UI.
164 LOGGER.warning(f"Web UI: read_config failed: {e!s}")
165 return None
168def _get_marker_filename(config: dict) -> str:
169 """Marker filename from ``app.mount_marker_filename`` (default ``.mounted``)."""
170 return config_parser.get_mount_marker_filename(config=config)
173def _get_require_mount_marker(config: dict, service: str) -> bool:
174 """``{drive,photos}.require_mount_marker`` for the given service."""
175 getter = getattr(config_parser, f"get_{service}_require_mount_marker")
176 return bool(getter(config=config))
179def _get_library_destinations(config: dict) -> dict[str, str]:
180 """``photos.library_destinations`` mapping (empty dict when unset)."""
181 return config_parser.get_photos_library_destinations(config=config) or {}
184def _build_service(config: dict, service: str, marker_filename: str) -> dict[str, Any]:
185 """Compose a single service entry (Photos or Drive) for /api/status."""
186 if service == "photos":
187 # Read-only on purpose: ``prepare_*_destination`` calls
188 # ``join_and_ensure_path`` (mkdir), so a plain ``GET /api/status``
189 # would write to disk -- and 500 the whole dashboard on a
190 # read-only destination mount. Composing the non-mutating getters
191 # lets a read-only or absent destination degrade to
192 # ``destination_exists: false`` instead.
193 destination = os.path.join(
194 config_parser.get_root_destination_path(config=config),
195 config_parser.get_photos_destination_path(config=config),
196 )
197 interval = config_parser.get_photos_sync_interval(
198 config=config,
199 log_messages=False,
200 )
201 name = "Photos"
202 library_destinations = _get_library_destinations(config=config)
203 else:
204 destination = os.path.join(
205 config_parser.get_root_destination_path(config=config),
206 config_parser.get_drive_destination_path(config=config),
207 )
208 interval = config_parser.get_drive_sync_interval(
209 config=config,
210 log_messages=False,
211 )
212 name = "Drive"
213 library_destinations = {}
215 marker_path = os.path.join(destination, marker_filename)
216 state = web_signals.get_sync_state(service=service)
217 stats = None
218 if state:
219 completed_at = state.get("completed_at")
220 stats = {
221 "last_sync_relative": (
222 web_signals.format_relative_time(completed_at) if completed_at else None
223 ),
224 "files_downloaded": state.get("files_downloaded"),
225 "files_skipped": state.get("files_skipped"),
226 "files_removed": state.get("files_removed"),
227 # Named for what it is: the last COMPLETED cycle's
228 # downloaded+skipped total. The state record overwrites per
229 # cycle rather than accumulating, so this is not a running
230 # count of files on disk.
231 "last_cycle_total": (
232 (state.get("files_downloaded") or 0) + (state.get("files_skipped") or 0)
233 if (
234 state.get("files_downloaded") is not None
235 or state.get("files_skipped") is not None
236 )
237 else None
238 ),
239 "errors": state.get("errors", 0),
240 "duration_seconds": state.get("duration_seconds"),
241 }
242 return {
243 "name": name,
244 "destination": destination,
245 "destination_exists": os.path.isdir(destination),
246 "sync_interval_s": interval,
247 "require_mount_marker": _get_require_mount_marker(
248 config=config,
249 service=service,
250 ),
251 "marker_present": os.path.isfile(marker_path),
252 "marker_path": marker_path,
253 "library_destinations": library_destinations,
254 "stats": stats,
255 "force_sync_pending": service in web_signals.pending_force_syncs(),
256 }
259def _logger_filename(config: dict | None) -> str:
260 """Resolve where ``sync.py`` is writing log lines. Best-effort.
262 Reads ``app.logger.filename`` directly off the config dict to avoid
263 introducing a new ``config_parser`` helper just for this — keeps the
264 upstream PR diff small.
265 """
266 if not config:
267 return ""
268 try:
269 return config.get("app", {}).get("logger", {}).get("filename", "") or ""
270 except AttributeError:
271 return ""
274def _tail_log_file(path: str, lines: int = 200) -> list[str]:
275 """Return the last ``lines`` lines of ``path``.
277 Best-effort: missing path, unreadable file, or decode failure all
278 return an empty list. Reads from the end in 8 KiB blocks so the cost
279 is bounded by ``lines * average_line_length`` rather than file size.
280 """
281 if not path or not os.path.isfile(path):
282 return []
283 try:
284 with open(path, "rb") as f:
285 f.seek(0, os.SEEK_END)
286 size = f.tell()
287 block = 8192
288 data = b""
289 while size > 0 and data.count(b"\n") <= lines:
290 read_size = min(block, size)
291 size -= read_size
292 f.seek(size)
293 data = f.read(read_size) + data
294 return data.decode("utf-8", errors="replace").splitlines()[-lines:]
295 except OSError as e:
296 LOGGER.warning(f"Web UI could not tail log {path}: {e!s}")
297 return []
300def _build_status(config: dict | None) -> dict[str, Any]:
301 """Compose the payload returned by /api/status (and consumed by the
302 dashboard template)."""
303 if not config:
304 return {
305 "config_loaded": False,
306 "config_path": _current_config_path(),
307 "username": None,
308 "services": [],
309 }
311 marker_filename = _get_marker_filename(config=config)
312 services = []
313 if "photos" in config:
314 services.append(
315 _build_service(
316 config=config,
317 service="photos",
318 marker_filename=marker_filename,
319 ),
320 )
321 if "drive" in config:
322 services.append(
323 _build_service(
324 config=config,
325 service="drive",
326 marker_filename=marker_filename,
327 ),
328 )
330 username = config_parser.get_username(config=config)
331 trust = web_signals.get_trust_state()
332 trust_expires_at = trust.get("expires_at")
333 trust_days_remaining: int | None = None
334 if trust_expires_at:
335 try:
336 import datetime
338 exp = datetime.datetime.fromisoformat(trust_expires_at)
339 now = (
340 datetime.datetime.now(tz=exp.tzinfo)
341 if exp.tzinfo
342 else datetime.datetime.now()
343 )
344 trust_days_remaining = (exp - now).days
345 except (
346 ValueError,
347 TypeError,
348 ): # pragma: no cover -- defensive against malformed iso
349 trust_days_remaining = None
350 return {
351 "config_loaded": True,
352 "config_path": _current_config_path(),
353 "username": username,
354 "region": config_parser.get_region(config=config),
355 "marker_filename": marker_filename,
356 "services": services,
357 "auth_state": _detect_auth_state(username=username),
358 "force_sync_pending": web_signals.pending_force_syncs(),
359 "trust_expires_at": trust_expires_at,
360 "trust_days_remaining": trust_days_remaining,
361 }
364def _detect_auth_state(username: str | None) -> str:
365 """Best-effort check of whether the sync loop can actually authenticate.
367 Returns one of:
368 - ``not_configured`` — no ``app.credentials.username`` in config.
369 - ``setup_needed`` — username set, but the keyring has no password
370 cached. The container's first 2FA flow hasn't been completed.
371 - ``ready`` — username set + keyring entry present. Sync loop can
372 resume the session on the next retry.
374 Distinct from a *live* iCloud session check (which would require
375 hitting Apple). This is the cheap on-disk signal users see today
376 when sync.py's loop prints ``Password is not stored in keyring``.
377 """
378 if not username:
379 return "not_configured"
380 try:
381 from icloudpy import utils as icloudpy_utils
383 if icloudpy_utils.password_exists_in_keyring(username):
384 return "ready"
385 except Exception as e:
386 LOGGER.debug(f"Web UI auth-state check raised: {e!s}")
387 return "setup_needed"
390def create_app(testing: bool = False) -> Flask:
391 """Construct the Flask app.
393 Splitting this out keeps ``tests/`` able to build the app under
394 ``TESTING=True`` without spawning a thread.
395 """
396 from werkzeug.middleware.proxy_fix import ProxyFix
398 # No ``static_folder``: templates are single-file with inline CSS and
399 # nothing ships under src/static, so wiring it would only add a 404
400 # route for /static/*.
401 template_dir = os.path.join(os.path.dirname(__file__), "templates")
402 app = Flask(__name__, template_folder=template_dir)
403 app.config["TESTING"] = testing
405 # Trust X-Forwarded-* from a single reverse-proxy hop (Cloudflare Tunnel,
406 # Authelia / Traefik). Lets ``url_for`` produce ``https://`` URLs and
407 # prevents Flask from mis-detecting the scheme when behind a TLS-
408 # terminating proxy. One hop is correct here — Cloudflare → backend.
409 app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
411 @app.after_request
412 def _no_cache(response):
413 """Defense against intermediaries (browser back/forward cache,
414 Cloudflare's auto-minify, mobile carrier proxies) serving stale
415 dashboard or auth payloads. The dashboard is always live data —
416 a cached snapshot would hide a missing mount marker or an
417 expired session."""
418 response.headers["Cache-Control"] = (
419 "private, no-store, no-cache, must-revalidate, max-age=0"
420 )
421 response.headers["Pragma"] = "no-cache"
422 response.headers["Expires"] = "0"
423 # CSRF defence: set the SameSite=Strict token cookie on every
424 # response so forms rendered server-side can read it (via the
425 # template) and same-site fetches automatically include it.
426 # ``secure=False`` because the default deployment is loopback
427 # over plain HTTP; users behind a TLS proxy benefit from the
428 # proxy's transport security, and SameSite=Strict is the load-
429 # bearing protection here regardless of TLS.
430 response.set_cookie(
431 _CSRF_COOKIE_NAME,
432 _CSRF_TOKEN,
433 samesite="Strict",
434 httponly=False,
435 secure=False,
436 path="/",
437 )
438 return response
440 @app.route("/")
441 def dashboard():
442 """Render the HTML dashboard — Apple-leaning design."""
443 config = _load_current_config()
444 status_payload = _build_status(config=config)
445 log_path = _logger_filename(config=config)
446 log_lines = _tail_log_file(path=log_path, lines=200)
447 return render_template(
448 "dashboard.html",
449 status=status_payload,
450 log_lines=log_lines,
451 log_path=log_path,
452 active_nav="dashboard",
453 version=os.environ.get("APP_VERSION", ""),
454 csrf_token=_get_csrf_token(),
455 )
457 @app.route("/api/health")
458 def health():
459 """Tiny endpoint for external monitors.
461 - 200 ``{"state": "ok"}`` when the config file is readable.
462 - 503 ``{"state": "config_missing"}`` when it isn't.
464 ``2fa_required`` is *not* a 503 — Apple sessions expire all the time
465 and the dashboard must stay reachable so the user can re-auth.
466 """
467 if not os.path.isfile(_current_config_path()):
468 return jsonify({"state": "config_missing"}), 503
469 return jsonify({"state": "ok"})
471 @app.route("/api/status")
472 def status():
473 """Live status payload for the dashboard + external consumers."""
474 config = _load_current_config()
475 payload = _build_status(config=config)
476 if not payload["config_loaded"]:
477 return jsonify(payload), 503
478 return jsonify(payload)
480 @app.route("/api/logs")
481 def logs():
482 """Last 200 lines of the configured log file. Best-effort: missing
483 or unreadable returns an empty list (never 500 — the dashboard
484 relies on this being reachable to render the rest of the page)."""
485 config = _load_current_config()
486 return jsonify(
487 {"lines": _tail_log_file(path=_logger_filename(config=config), lines=200)},
488 )
490 @app.route("/auth", methods=["GET"])
491 def auth_form():
492 """Auth form. Renders the password field by default; renders the
493 6-digit code field instead when ``_PENDING_AUTH`` indicates that
494 the password step already succeeded and 2FA is pending."""
495 return _render_auth(message=None, message_kind=None)
497 @app.route("/auth/password", methods=["POST"])
498 def auth_password():
499 """Step 1: store password in keyring, instantiate ICloudPyService,
500 trigger 2FA push if needed.
502 On success of either path: redirects — to /auth (now showing the
503 code form) if 2FA is pending, or back to / if the cached session
504 was still trusted.
506 Exceptions are caught and rendered as an error pill on /auth so
507 the user sees what Apple said.
508 """
509 rejection = _require_csrf()
510 if rejection is not None:
511 return rejection
513 password = request.form.get("password", "")
514 if not password:
515 return (
516 _render_auth(message="Password is required.", message_kind="err"),
517 400,
518 )
520 config = _load_current_config()
521 username = None
522 if config:
523 try:
524 username = config_parser.get_username(config=config)
525 except (KeyError, AttributeError, TypeError): # pragma: no cover
526 # Defensive: get_username walks app.credentials.username;
527 # partial configs (no credentials block) raise. Treat as
528 # missing. Rare in practice — coverage-pragma'd because
529 # mocking get_username globally breaks _render_auth.
530 username = None
531 if not username:
532 return (
533 _render_auth(
534 message="No app.credentials.username in config.yaml — set it and reload.",
535 message_kind="err",
536 ),
537 400,
538 )
540 try:
541 # Late import so /api/health still works if icloudpy is mid-upgrade.
542 import icloudpy
543 from icloudpy import utils as icloudpy_utils
545 api = icloudpy.ICloudPyService(
546 apple_id=username,
547 password=password,
548 cookie_directory=DEFAULT_COOKIE_DIRECTORY,
549 )
550 except Exception as e:
551 LOGGER.exception("Web UI auth failed during ICloudPyService instantiation")
552 return (
553 _render_auth(
554 message=f"Authentication failed: {e!s}",
555 message_kind="err",
556 ),
557 400,
558 )
560 if api.requires_2fa:
561 # PR 1 / fix/ios-26.4-auth dependency — best-effort. Catches all
562 # exceptions so a missing-method or push-trigger failure doesn't
563 # block the user from typing in a code they got via SMS.
564 try:
565 trigger = getattr(api, "trigger_2fa_push_notification", None)
566 if callable(trigger):
567 trigger()
568 except Exception as e:
569 LOGGER.warning(f"Web UI 2FA push trigger failed (non-fatal): {e!s}")
570 with _AUTH_LOCK:
571 _expire_stale_pending_auth_unlocked()
572 _PENDING_AUTH["api"] = api
573 _PENDING_AUTH["username"] = username
574 _PENDING_AUTH["password"] = password
575 _PENDING_AUTH["stashed_at"] = time.monotonic()
576 return redirect(url_for("auth_form"))
578 # No 2FA needed — cached session still trusted. Persist the
579 # password to the keyring so the sync loop can use it on the
580 # next retry, then bounce back to the dashboard.
581 try:
582 icloudpy_utils.store_password_in_keyring(
583 username=username,
584 password=password,
585 )
586 except Exception as e:
587 LOGGER.warning(f"Web UI keyring persist failed (non-fatal): {e!s}")
588 return redirect(url_for("dashboard"))
590 @app.route("/auth/code", methods=["POST"])
591 def auth_code():
592 """Step 2: validate the 6-digit code on the in-flight session,
593 trust the browser, persist the password, clear pending, redirect.
595 - 400 if the code field is empty or no pending auth exists.
596 - 400 + 'Code rejected' if Apple says no — pending kept so the
597 user can retry without re-entering the password.
598 - On success: validate_2fa_code -> trust_session (failures here
599 are logged but non-fatal — the code already worked) ->
600 store_password_in_keyring -> clear pending -> redirect to /.
601 """
602 rejection = _require_csrf()
603 if rejection is not None:
604 return rejection
606 code = request.form.get("code", "").strip()
607 if not code:
608 return (
609 _render_auth(message="Enter the 6-digit code.", message_kind="err"),
610 400,
611 )
613 with _AUTH_LOCK:
614 _expire_stale_pending_auth_unlocked()
615 api = _PENDING_AUTH.get("api")
616 username = _PENDING_AUTH.get("username")
617 password = _PENDING_AUTH.get("password")
618 if api is None:
619 return (
620 _render_auth(
621 message="No pending auth — submit your password first.",
622 message_kind="err",
623 ),
624 400,
625 )
627 # All exit paths from here clear ``_PENDING_AUTH`` -- including
628 # failed validate_2fa_code, rejected codes, and trust_session
629 # failures. Without the ``finally`` the previous code only cleared
630 # on the success path, leaving the password sitting in process
631 # memory if Apple raised. On rejection the user retries via
632 # /auth/refresh-trust or by re-entering the password; we'd rather
633 # they take that path than leave a stale credential in memory.
634 try:
635 try:
636 accepted = api.validate_2fa_code(code)
637 except Exception as e:
638 LOGGER.exception("Web UI: validate_2fa_code raised")
639 return (
640 _render_auth(
641 message=f"2FA validation error: {e!s}",
642 message_kind="err",
643 ),
644 400,
645 )
647 if not accepted:
648 return (
649 _render_auth(
650 message="Code rejected by Apple. Try again — make sure you copy the latest code.",
651 message_kind="err",
652 ),
653 400,
654 )
656 # Code worked. Best-effort trust so the next session resume skips
657 # 2FA; if that fails (e.g. cookie store write error) just log it
658 # — the user's auth still succeeded for this session.
659 try:
660 api.trust_session()
661 except Exception as e:
662 LOGGER.warning(f"Web UI trust_session failed (non-fatal): {e!s}")
664 # Persist password to keyring so the sync-loop's next retry
665 # picks up the trusted session without prompting.
666 try:
667 from icloudpy import utils as icloudpy_utils
669 icloudpy_utils.store_password_in_keyring(
670 username=username,
671 password=password,
672 )
673 except Exception as e:
674 LOGGER.warning(f"Web UI keyring persist failed (non-fatal): {e!s}")
676 return redirect(url_for("dashboard"))
677 finally:
678 with _AUTH_LOCK:
679 _PENDING_AUTH.clear()
681 @app.route("/auth/reset", methods=["POST"])
682 def auth_reset():
683 """Escape hatch — clear any in-flight pending-auth state.
685 Useful when the user closed the tab mid-2FA and wants to start
686 over without waiting for the in-memory state to expire.
687 """
688 rejection = _require_csrf()
689 if rejection is not None:
690 return rejection
692 with _AUTH_LOCK:
693 _PENDING_AUTH.clear()
694 return redirect(url_for("auth_form"))
696 @app.route("/auth/refresh-trust", methods=["POST"])
697 def auth_refresh_trust():
698 """One-tap re-auth using the keyring-cached password.
700 When Apple's trusted-session lifetime is winding down (or has
701 already expired since the last sync attempt), this lets the user
702 kick off a fresh 2FA push without having to retype their
703 password. Useful for "reset the clock" workflows where the
704 password didn't change — only the trust window did.
706 Flow:
707 1. Look up keyring password by username from config.
708 2. If absent → bounce to /auth so the user enters a new one.
709 3. If present → spin up a transient ICloudPyService, fire the
710 2FA push if needed, stash the live session under the same
711 _PENDING_AUTH dict /auth/code already consumes.
712 4. Redirect to /auth — UI is now in "enter 6-digit code" mode.
713 """
714 rejection = _require_csrf()
715 if rejection is not None:
716 return rejection
718 config = _load_current_config()
719 username = None
720 if config:
721 try:
722 username = config_parser.get_username(config=config)
723 except (
724 KeyError,
725 AttributeError,
726 TypeError,
727 ): # pragma: no cover — defensive for hand-malformed configs
728 username = None
729 if not username:
730 return (
731 _render_auth(
732 message="No app.credentials.username in config.yaml — set it first.",
733 message_kind="err",
734 ),
735 400,
736 )
738 try:
739 from icloudpy import utils as icloudpy_utils
741 password = icloudpy_utils.get_password_from_keyring(username)
742 except Exception as e:
743 LOGGER.exception("Web UI: keyring lookup raised")
744 return (
745 _render_auth(
746 message=f"Keyring lookup failed: {e!s}",
747 message_kind="err",
748 ),
749 500,
750 )
751 if not password:
752 return (
753 _render_auth(
754 message=(
755 "No password in keyring — submit one below to "
756 "complete the first-time auth."
757 ),
758 message_kind="warn",
759 ),
760 400,
761 )
763 try:
764 import icloudpy
766 api = icloudpy.ICloudPyService(
767 apple_id=username,
768 password=password,
769 cookie_directory=DEFAULT_COOKIE_DIRECTORY,
770 )
771 except Exception as e:
772 LOGGER.exception("Web UI refresh-trust: ICloudPyService raised")
773 return (
774 _render_auth(
775 message=(
776 f"Refresh trust failed: {e!s}. Your stored "
777 "password may be stale — submit a new one below."
778 ),
779 message_kind="err",
780 ),
781 400,
782 )
784 if not api.requires_2fa:
785 # Trust window was still alive — nothing to do, sync loop is
786 # already authenticated. Bounce back to the dashboard with
787 # the success state.
788 return redirect(url_for("dashboard"))
790 try:
791 trigger = getattr(api, "trigger_2fa_push_notification", None)
792 if callable(trigger):
793 trigger()
794 except Exception as e:
795 LOGGER.warning(f"Web UI refresh-trust 2FA push failed: {e!s}")
797 with _AUTH_LOCK:
798 _expire_stale_pending_auth_unlocked()
799 _PENDING_AUTH["api"] = api
800 _PENDING_AUTH["username"] = username
801 _PENDING_AUTH["password"] = password
802 _PENDING_AUTH["stashed_at"] = time.monotonic()
803 return redirect(url_for("auth_form"))
805 @app.route("/api/sync", methods=["POST"])
806 def api_sync():
807 """Queue an immediate sync run for one or both services.
809 ``service=drive`` / ``service=photos`` / ``service=all``. The
810 web thread can't run sync.sync() directly — it would race with
811 the existing loop. Instead this touches a sentinel file in
812 ICLOUD_DOCKER_CONFIG_DIR; ``src.sync`` checks for it at the top
813 of each loop iteration and resets the countdown when present.
815 Idempotent: tapping repeatedly while a request is still queued
816 is a no-op (the sentinel just gets re-touched).
817 """
818 rejection = _require_csrf()
819 if rejection is not None:
820 return rejection
822 service = (
823 (request.form.get("service") or request.args.get("service") or "")
824 .strip()
825 .lower()
826 )
827 if service == "all":
828 wanted = ("drive", "photos")
829 elif service in ("drive", "photos"):
830 wanted = (service,)
831 else:
832 return (
833 jsonify({"error": "service must be one of: drive, photos, all"}),
834 400,
835 )
837 # Honour the user's config — only queue services that are
838 # actually configured. Avoids touching a photos sentinel on a
839 # drive-only install.
840 config = _load_current_config()
841 configured = {svc for svc in ("drive", "photos") if config and svc in config}
842 if not configured:
843 return jsonify({"error": "no services configured"}), 400
845 queued = [
846 svc
847 for svc in wanted
848 if svc in configured and web_signals.request_force_sync(svc)
849 ]
851 # Browser form submit gets a redirect; API consumers (curl,
852 # monitors) get JSON. Distinguished by Accept header.
853 if request.headers.get("Accept", "").startswith("application/json"):
854 return jsonify({"queued": queued})
855 return redirect(url_for("dashboard"))
857 return app
860def _render_auth(message: str | None, message_kind: str | None):
861 """Render auth.html with the current pending state and an optional
862 error/info pill. Factored out so the POST endpoints can reuse it."""
863 from flask import render_template as _render
865 config = _load_current_config()
866 status_payload = _build_status(config=config)
867 with _AUTH_LOCK:
868 pending = bool(_PENDING_AUTH)
869 return _render(
870 "auth.html",
871 status=status_payload,
872 pending=pending,
873 message=message,
874 message_kind=message_kind,
875 active_nav="auth",
876 version=os.environ.get("APP_VERSION", ""),
877 csrf_token=_get_csrf_token(),
878 )
881def start_in_thread(
882 host: str = "127.0.0.1",
883 port: int = 8080,
884) -> threading.Thread:
885 """Launch the Flask app on a daemon thread.
887 The main sync loop owns the process; the web thread dies when the
888 parent process exits. Returns the thread, or ``None`` if the port
889 could not be bound (the sync loop continues regardless).
891 Uses ``werkzeug.serving.make_server`` rather than ``Flask.run()``:
892 binding happens synchronously here so a port conflict is reported
893 as an error instead of a false "listening" line, and it avoids the
894 dev-server banner. Deliberately dependency-free (no gunicorn) --
895 this is a single-user, behind-a-proxy operator console (default
896 host 127.0.0.1), not a public-facing API.
897 """
898 app = create_app()
900 # Bind in the main thread so the "listening" log is truthful: a port
901 # conflict raises here and is reported as a failure, instead of the
902 # old optimistic log racing a background bind error.
903 try:
904 server = make_server(host, port, app, threaded=True)
905 except OSError as e:
906 LOGGER.error(f"Web UI failed to bind {host}:{port} — {e!s}")
907 return None
909 def _serve_bound():
910 try:
911 server.serve_forever()
912 except Exception as e: # noqa: BLE001 -- daemon thread, never crash the sync loop
913 LOGGER.error(f"Web UI server stopped: {e!s}")
915 thread = threading.Thread(target=_serve_bound, name="icloud-web-ui", daemon=True)
916 thread.start()
917 LOGGER.info(f"Web UI listening on http://{host}:{port}/ (host={host}, port={port})")
918 return thread