Coverage for src/web.py: 100%

334 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-05 00:26 +0000

1"""Web UI for icloud-docker. 

2 

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. 

7 

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. 

11 

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""" 

17 

18__author__ = "Mandar Patil (mandarons@pm.me)" 

19 

20import hmac 

21import os 

22import secrets 

23import threading 

24import time 

25from typing import Any 

26 

27from flask import Flask, jsonify, redirect, render_template, request, url_for 

28from werkzeug.serving import make_server 

29 

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) 

39 

40LOGGER = get_logger() 

41 

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() 

48 

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 

56 

57 

58def _pending_auth_is_stale() -> bool: 

59 """True when the in-memory password is older than the TTL. 

60 

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 

71 

72 

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() 

78 

79 

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" 

92 

93 

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 

99 

100 

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: 

106 

107 rejection = _require_csrf() 

108 if rejection is not None: 

109 return rejection 

110 

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:: 

116 

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 

121 

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 

136 

137 

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) 

141 

142 

143def _load_current_config() -> dict | None: 

144 """Re-read config.yaml fresh on every request so edits show up live. 

145 

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 

166 

167 

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) 

171 

172 

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)) 

177 

178 

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 {} 

182 

183 

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 = {} 

214 

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 } 

257 

258 

259def _logger_filename(config: dict | None) -> str: 

260 """Resolve where ``sync.py`` is writing log lines. Best-effort. 

261 

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 "" 

272 

273 

274def _tail_log_file(path: str, lines: int = 200) -> list[str]: 

275 """Return the last ``lines`` lines of ``path``. 

276 

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 [] 

298 

299 

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 } 

310 

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 ) 

329 

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 

337 

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 } 

362 

363 

364def _detect_auth_state(username: str | None) -> str: 

365 """Best-effort check of whether the sync loop can actually authenticate. 

366 

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. 

373 

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 

382 

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" 

388 

389 

390def create_app(testing: bool = False) -> Flask: 

391 """Construct the Flask app. 

392 

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 

397 

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 

404 

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) 

410 

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 

439 

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 ) 

456 

457 @app.route("/api/health") 

458 def health(): 

459 """Tiny endpoint for external monitors. 

460 

461 - 200 ``{"state": "ok"}`` when the config file is readable. 

462 - 503 ``{"state": "config_missing"}`` when it isn't. 

463 

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"}) 

470 

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) 

479 

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 ) 

489 

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) 

496 

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. 

501 

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. 

505 

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 

512 

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 ) 

519 

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 ) 

539 

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 

544 

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 ) 

559 

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")) 

577 

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")) 

589 

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. 

594 

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 

605 

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 ) 

612 

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 ) 

626 

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 ) 

646 

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 ) 

655 

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}") 

663 

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 

668 

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}") 

675 

676 return redirect(url_for("dashboard")) 

677 finally: 

678 with _AUTH_LOCK: 

679 _PENDING_AUTH.clear() 

680 

681 @app.route("/auth/reset", methods=["POST"]) 

682 def auth_reset(): 

683 """Escape hatch — clear any in-flight pending-auth state. 

684 

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 

691 

692 with _AUTH_LOCK: 

693 _PENDING_AUTH.clear() 

694 return redirect(url_for("auth_form")) 

695 

696 @app.route("/auth/refresh-trust", methods=["POST"]) 

697 def auth_refresh_trust(): 

698 """One-tap re-auth using the keyring-cached password. 

699 

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. 

705 

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 

717 

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 ) 

737 

738 try: 

739 from icloudpy import utils as icloudpy_utils 

740 

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 ) 

762 

763 try: 

764 import icloudpy 

765 

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 ) 

783 

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")) 

789 

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}") 

796 

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")) 

804 

805 @app.route("/api/sync", methods=["POST"]) 

806 def api_sync(): 

807 """Queue an immediate sync run for one or both services. 

808 

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. 

814 

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 

821 

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 ) 

836 

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 

844 

845 queued = [ 

846 svc 

847 for svc in wanted 

848 if svc in configured and web_signals.request_force_sync(svc) 

849 ] 

850 

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")) 

856 

857 return app 

858 

859 

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 

864 

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 ) 

879 

880 

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. 

886 

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). 

890 

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() 

899 

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 

908 

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}") 

914 

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