Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
70662c9
feat(web): scaffold Flask app + /api/health
epheterson May 28, 2026
c2875d5
feat(web): /api/status — config + service + marker status
epheterson May 28, 2026
ecfbbb9
feat(web): /api/logs — last N lines of icloud.log
epheterson May 28, 2026
4e658ac
feat(web): dashboard template + GET /
epheterson May 28, 2026
d9ec4f6
feat(web): /auth form (password + code states)
epheterson May 28, 2026
546f727
feat(web): /auth/password — start session, trigger 2FA push, stash pe…
epheterson May 28, 2026
d71c751
feat(web): /auth/code validate+trust+persist + /auth/reset escape hatch
epheterson May 28, 2026
178560d
feat(web): app.web_ui config block (enabled/host/port)
epheterson May 28, 2026
e21945b
feat(web): launch web UI thread from main.py when enabled
epheterson May 28, 2026
6922362
build(web): EXPOSE 8080 in Dockerfile
epheterson May 28, 2026
57d5f63
test: conftest autouse fixture to restore ENV_CONFIG_FILE_PATH around…
epheterson May 28, 2026
a43ec30
fix(web): ProxyFix + Cache-Control: no-store + threaded=True
epheterson May 28, 2026
24deffe
feat(web): truthful auth-state pill + library destinations on dashboard
epheterson May 28, 2026
4e4b796
fix(web): /auth shows 'already signed in' state when auth_state == ready
epheterson May 28, 2026
dec1d82
feat(web): force-sync buttons, refresh-trust, stats, mobile-friendly CSS
epheterson May 29, 2026
7d1d363
test: ruff-clean — auto-fix + PERF401 list-comp in queue/pending helpers
epheterson May 30, 2026
d811388
test: lift coverage to 100% on web-ui — sync.py + main.py + web.py + …
epheterson May 30, 2026
60ad5f9
test: add missing test_web_signals.py (was untracked in prior commit)
epheterson May 30, 2026
5ef2bbf
review: address all 8 Copilot threads on web-ui PR (security cluster)
epheterson May 31, 2026
e170115
feat(notify): trust-expiry awareness + web UI URL in notifications
epheterson Jun 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,7 @@ COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

EXPOSE 80
# Web UI (Flask app — see src/web.py). Opt-in via app.web_ui.enabled
# in config.yaml; default OFF so vanilla installs see no behaviour change.
EXPOSE 8080
CMD ["/usr/local/bin/docker-entrypoint.sh"]
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ icloudpy==0.8.0
ruamel.yaml==0.19.1
python-magic==0.4.27
requests~=2.32.3
flask==3.0.3
194 changes: 168 additions & 26 deletions src/config_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,16 @@ def get_region(config: dict) -> str:
Region string ('global' or 'china')
"""
config_path = ["app", "region"]
region = get_config_value_or_default(config=config, config_path=config_path, default="global")
region = get_config_value_or_default(
config=config, config_path=config_path, default="global",
)

if region == "global" and not traverse_config_path(config=config, config_path=config_path):
log_config_not_found_warning(config_path, "not found. Using default value - global ...")
if region == "global" and not traverse_config_path(
config=config, config_path=config_path,
):
log_config_not_found_warning(
config_path, "not found. Using default value - global ...",
)
elif region not in ["global", "china"]:
log_config_error(
config_path,
Expand All @@ -167,7 +173,9 @@ def get_region(config: dict) -> str:
# =============================================================================


def get_sync_interval(config: dict, config_path: list[str], service_name: str, log_messages: bool = True) -> int:
def get_sync_interval(
config: dict, config_path: list[str], service_name: str, log_messages: bool = True,
) -> int:
"""Get sync interval for a service (drive or photos).

Extracted common logic for retrieving sync intervals.
Expand All @@ -194,7 +202,9 @@ def get_sync_interval(config: dict, config_path: list[str], service_name: str, l
f"is not found. Using default sync_interval: {sync_interval} seconds ...",
)
else:
log_config_found_info(f"Syncing {service_name} every {sync_interval} seconds.")
log_config_found_info(
f"Syncing {service_name} every {sync_interval} seconds.",
)

return sync_interval

Expand All @@ -210,7 +220,12 @@ def get_drive_sync_interval(config: dict, log_messages: bool = True) -> int:
Drive sync interval in seconds
"""
config_path = ["drive", "sync_interval"]
return get_sync_interval(config=config, config_path=config_path, service_name="drive", log_messages=log_messages)
return get_sync_interval(
config=config,
config_path=config_path,
service_name="drive",
log_messages=log_messages,
)


def get_drive_request_timeout(config: dict) -> int:
Expand Down Expand Up @@ -241,7 +256,12 @@ def get_photos_sync_interval(config: dict, log_messages: bool = True) -> int:
Photos sync interval in seconds
"""
config_path = ["photos", "sync_interval"]
return get_sync_interval(config=config, config_path=config_path, service_name="photos", log_messages=log_messages)
return get_sync_interval(
config=config,
config_path=config_path,
service_name="photos",
log_messages=log_messages,
)


# =============================================================================
Expand Down Expand Up @@ -271,9 +291,13 @@ def parse_max_threads_value(max_threads_config: Any, default_max_threads: int) -
# Handle "auto" value
if isinstance(max_threads_config, str) and max_threads_config.lower() == "auto":
max_threads = default_max_threads
log_config_found_info(f"Using automatic thread count: {max_threads} threads (based on CPU cores).")
log_config_found_info(
f"Using automatic thread count: {max_threads} threads (based on CPU cores).",
)
elif isinstance(max_threads_config, int) and max_threads_config >= 1:
max_threads = min(max_threads_config, 16) # Cap at 16 to avoid overwhelming servers
max_threads = min(
max_threads_config, 16,
) # Cap at 16 to avoid overwhelming servers
log_config_found_info(f"Using configured max_threads: {max_threads}.")
else:
log_invalid_config_value(
Expand All @@ -286,6 +310,89 @@ def parse_max_threads_value(max_threads_config: Any, default_max_threads: int) -
return max_threads


def get_web_ui_enabled(config: dict) -> bool:
"""Return whether the embedded web UI should start on container boot.

Default: **False** — opt-in. Existing mandarons installs see no
behaviour change; only users who explicitly set
``app.web_ui.enabled: true`` open the port.
"""
return bool(
get_config_value_or_default(
config=config,
config_path=["app", "web_ui", "enabled"],
default=False,
),
)


def get_web_ui_host(config: dict) -> str:
"""Web UI bind address.

Default ``127.0.0.1`` — the web UI accepts the user's Apple ID
password on POST /auth/password with no built-in authentication and
no CSRF token (the feature assumes a reverse-proxy trust boundary
in front of it). Defaulting to loopback means the credential-
accepting form is never exposed to LAN/public on a vanilla install.
Users who run behind a reverse proxy or want explicit LAN exposure
set ``app.web_ui.host: 0.0.0.0`` consciously.
"""
return str(
get_config_value_or_default(
config=config,
config_path=["app", "web_ui", "host"],
default="127.0.0.1",
),
)
Comment thread
epheterson marked this conversation as resolved.


def get_web_ui_port(config: dict) -> int:
"""Web UI TCP port. Default ``8080``. Coexists with mandarons' legacy
``EXPOSE 80`` (unused) — no port collision."""
return int(
get_config_value_or_default(
config=config,
config_path=["app", "web_ui", "port"],
default=8080,
),
)


def get_web_ui_public_url(config: dict) -> str | None:
"""Public-facing URL for the web UI, used in notifications.

The daemon only knows its local bind host:port; the user-facing URL
(e.g. ``https://icloud.zosia.io`` behind a reverse proxy) must be
declared explicitly. When unset, notifications fall back to
``http://{host}:{port}`` and log a one-shot warning at startup.
"""
value = get_config_value_or_default(
config=config,
config_path=["app", "web_ui", "public_url"],
default=None,
)
if value is None:
return None
return str(value).rstrip("/")


def get_trust_expiry_warn_days(config: dict) -> int:
"""Warn this many days before Apple's trust cookie expires.

Default 7. The check + notification fires once per cookie-lifetime
when ``trust_days_remaining`` first drops below this threshold so
the user can tap refresh-trust *before* the sync loop hits a
failed-auth state.
"""
return int(
get_config_value_or_default(
config=config,
config_path=["app", "trust_expiry_warn_days"],
default=7,
),
)


def get_app_max_threads(config: dict) -> int:
"""Return app-level max threads from config with support for 'auto' value.

Expand Down Expand Up @@ -433,15 +540,19 @@ def get_drive_remove_obsolete(config: dict) -> bool:
True if obsolete files should be removed, False otherwise
"""
config_path = ["drive", "remove_obsolete"]
drive_remove_obsolete = get_config_value_or_default(config=config, config_path=config_path, default=False)
drive_remove_obsolete = get_config_value_or_default(
config=config, config_path=config_path, default=False,
)

if not drive_remove_obsolete:
_log_config_warning_once(
config_path,
"remove_obsolete is not found. Not removing the obsolete files and folders.",
)
else:
log_config_debug(f"{'R' if drive_remove_obsolete else 'Not R'}emoving obsolete files and folders ...")
log_config_debug(
f"{'R' if drive_remove_obsolete else 'Not R'}emoving obsolete files and folders ...",
)

return drive_remove_obsolete

Expand Down Expand Up @@ -501,7 +612,9 @@ def get_photos_all_albums(config: dict) -> bool:
True if all albums should be synced, False otherwise
"""
config_path = ["photos", "all_albums"]
download_all = get_config_value_or_default(config=config, config_path=config_path, default=False)
download_all = get_config_value_or_default(
config=config, config_path=config_path, default=False,
)

if download_all:
log_config_found_info("Syncing all albums.")
Expand All @@ -520,7 +633,9 @@ def get_photos_use_hardlinks(config: dict, log_messages: bool = True) -> bool:
True if hard links should be used, False otherwise
"""
config_path = ["photos", "use_hardlinks"]
use_hardlinks = get_config_value_or_default(config=config, config_path=config_path, default=False)
use_hardlinks = get_config_value_or_default(
config=config, config_path=config_path, default=False,
)

if use_hardlinks and log_messages:
log_config_found_info("Using hard links for duplicate photos.")
Expand All @@ -538,15 +653,19 @@ def get_photos_remove_obsolete(config: dict) -> bool:
True if obsolete files should be removed, False otherwise
"""
config_path = ["photos", "remove_obsolete"]
photos_remove_obsolete = get_config_value_or_default(config=config, config_path=config_path, default=False)
photos_remove_obsolete = get_config_value_or_default(
config=config, config_path=config_path, default=False,
)

if not photos_remove_obsolete:
_log_config_warning_once(
config_path,
"remove_obsolete is not found. Not removing the obsolete files and folders.",
)
else:
log_config_debug(f"{'R' if photos_remove_obsolete else 'Not R'}emoving obsolete files and folders ...")
log_config_debug(
f"{'R' if photos_remove_obsolete else 'Not R'}emoving obsolete files and folders ...",
)

return photos_remove_obsolete

Expand Down Expand Up @@ -599,7 +718,9 @@ def validate_file_sizes(file_sizes: list[str]) -> list[str]:
return validated_sizes if validated_sizes else ["original"]


def get_photos_libraries_filter(config: dict, base_config_path: list[str]) -> list[str] | None:
def get_photos_libraries_filter(
config: dict, base_config_path: list[str],
) -> list[str] | None:
"""Get libraries filter from photos config.

Args:
Expand All @@ -613,13 +734,17 @@ def get_photos_libraries_filter(config: dict, base_config_path: list[str]) -> li
libraries = get_config_value_or_none(config=config, config_path=config_path)

if not libraries or len(libraries) == 0:
log_config_not_found_warning(config_path, "not found. Downloading all libraries ...")
log_config_not_found_warning(
config_path, "not found. Downloading all libraries ...",
)
return None

return libraries


def get_photos_albums_filter(config: dict, base_config_path: list[str]) -> list[str] | None:
def get_photos_albums_filter(
config: dict, base_config_path: list[str],
) -> list[str] | None:
"""Get albums filter from photos config.

Args:
Expand All @@ -633,13 +758,17 @@ def get_photos_albums_filter(config: dict, base_config_path: list[str]) -> list[
albums = get_config_value_or_none(config=config, config_path=config_path)

if not albums or len(albums) == 0:
log_config_not_found_warning(config_path, "not found. Downloading all albums ...")
log_config_not_found_warning(
config_path, "not found. Downloading all albums ...",
)
return None

return albums


def get_photos_file_sizes_filter(config: dict, base_config_path: list[str]) -> list[str]:
def get_photos_file_sizes_filter(
config: dict, base_config_path: list[str],
) -> list[str]:
"""Get file sizes filter from photos config.

Args:
Expand All @@ -652,14 +781,18 @@ def get_photos_file_sizes_filter(config: dict, base_config_path: list[str]) -> l
config_path = base_config_path + ["file_sizes"]

if not traverse_config_path(config=config, config_path=config_path):
log_config_not_found_warning(config_path, "not found. Downloading original size photos ...")
log_config_not_found_warning(
config_path, "not found. Downloading original size photos ...",
)
return ["original"]

file_sizes = get_config_value(config=config, config_path=config_path)
return validate_file_sizes(file_sizes)


def get_photos_extensions_filter(config: dict, base_config_path: list[str]) -> list[str] | None:
def get_photos_extensions_filter(
config: dict, base_config_path: list[str],
) -> list[str] | None:
"""Get extensions filter from photos config.

Args:
Expand All @@ -673,7 +806,9 @@ def get_photos_extensions_filter(config: dict, base_config_path: list[str]) -> l
extensions = get_config_value_or_none(config=config, config_path=config_path)

if not extensions or len(extensions) == 0:
log_config_not_found_warning(config_path, "not found. Downloading all extensions ...")
log_config_not_found_warning(
config_path, "not found. Downloading all extensions ...",
)
return None

return extensions
Expand Down Expand Up @@ -708,8 +843,12 @@ def get_photos_filters(config: dict) -> dict[str, Any]:
# Parse individual filter components
photos_filters["libraries"] = get_photos_libraries_filter(config, base_config_path)
photos_filters["albums"] = get_photos_albums_filter(config, base_config_path)
photos_filters["file_sizes"] = get_photos_file_sizes_filter(config, base_config_path)
photos_filters["extensions"] = get_photos_extensions_filter(config, base_config_path)
photos_filters["file_sizes"] = get_photos_file_sizes_filter(
config, base_config_path,
)
photos_filters["extensions"] = get_photos_extensions_filter(
config, base_config_path,
)

return photos_filters

Expand All @@ -719,7 +858,9 @@ def get_photos_filters(config: dict) -> dict[str, Any]:
# =============================================================================


def get_smtp_config_value(config: dict, key: str, warn_if_missing: bool = True) -> str | None:
def get_smtp_config_value(
config: dict, key: str, warn_if_missing: bool = True,
) -> str | None:
"""Get SMTP configuration value with optional warning.

Common helper for SMTP config retrieval to reduce duplication.
Expand Down Expand Up @@ -925,6 +1066,7 @@ def get_pushover_api_token(config: dict) -> str | None:
"""
return get_notification_config_value(config, "pushover", "api_token")


def get_pushover_notification_priority(config: dict) -> int | None:
"""Return Pushover notification priority from config.

Expand Down
Loading
Loading