#!/usr/bin/env python3 """Pling/OpenDesktop uploader. Modes: - default: destructive overwrite upload (delete all product files, then upload files) - --dry-run: authenticate and validate upload endpoint discovery only """ from __future__ import annotations import argparse import json import os import re import sys import time from dataclasses import dataclass from html.parser import HTMLParser from pathlib import Path from typing import Dict, Iterable, Optional from urllib.parse import urljoin, urlparse try: import niquests except ImportError as exc: # pragma: no cover - import guard print( "ERROR: missing dependency 'niquests'. Install it first, e.g. 'uv add niquests'.", file=sys.stderr, ) raise SystemExit(2) from exc DEFAULT_BASE_URL = "https://www.opendesktop.org" LOGIN_PATH = "/login/" EDIT_PATH_TEMPLATE = "/p/{project_id}/edit" DEFAULT_TIMEOUT_SECONDS = 30.0 DEFAULT_MAX_RETRIES = 2 REQUIRED_EDIT_DATA_ATTRS = ( "data-addpploadfile-uri", "data-updatepploadfile-uri", "data-deletepploadfile-uri", "data-deletepploadfiles-uri", "data-product-id", "data-ppload-collection-id", ) SENSITIVE_KEYS = { "password", "csrf", "cookie", "cookies", "authorization", "token", "session", } class PlingUploaderError(RuntimeError): """A non-secret, user-facing uploader failure.""" @dataclass(frozen=True) class LoginForm: action_url: str hidden_fields: Dict[str, str] @dataclass(frozen=True) class RuntimeConfig: project_id: str base_url: str username: str password: str timeout: float max_retries: int dry_run: bool artifact_paths: list[Path] @dataclass(frozen=True) class EditContext: add_file_url: str update_file_url: str delete_file_url: str delete_all_files_url: str product_id: str collection_id: str file_server_upload_url: str file_server_client_id: str file_server_owner_id: str class _LoginFormParser(HTMLParser): def __init__(self) -> None: super().__init__() self._inside_form = False self._form_action: Optional[str] = None self._form_has_auth_fields = False self._form_hidden_inputs: Dict[str, str] = {} self.login_form: Optional[LoginForm] = None def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None: attrs_dict = {k: (v or "") for k, v in attrs} if tag == "form": self._inside_form = True self._form_action = attrs_dict.get("action") or LOGIN_PATH self._form_has_auth_fields = False self._form_hidden_inputs = {} return if not self._inside_form or tag != "input": return name = (attrs_dict.get("name") or "").strip() input_type = (attrs_dict.get("type") or "").strip().lower() value = attrs_dict.get("value") or "" if name in {"email", "password"}: self._form_has_auth_fields = True if input_type == "hidden" and name: self._form_hidden_inputs[name] = value def handle_endtag(self, tag: str) -> None: if tag != "form" or not self._inside_form: return if self._form_has_auth_fields: self.login_form = LoginForm( action_url=self._form_action or LOGIN_PATH, hidden_fields=dict(self._form_hidden_inputs), ) self._inside_form = False self._form_action = None self._form_has_auth_fields = False self._form_hidden_inputs = {} def _redact(key: str, value: object) -> object: lowered = key.lower() if any(secret in lowered for secret in SENSITIVE_KEYS): return "***" return value def log_event(level: str, event: str, **fields: object) -> None: payload = { "level": level.upper(), "event": event, } payload.update({k: _redact(k, v) for k, v in fields.items()}) print(json.dumps(payload, ensure_ascii=True, sort_keys=True)) def normalize_base_url(base_url: str) -> str: normalized = base_url.strip().rstrip("/") parsed = urlparse(normalized) if not parsed.scheme or not parsed.netloc: raise PlingUploaderError(f"Invalid --base-url: {base_url!r}") return normalized def _safe_response_context(response: object) -> dict[str, object]: status_code = getattr(response, "status_code", "unknown") url = getattr(response, "url", "unknown") return {"status_code": status_code, "url": str(url)} def request_with_retries( session: "niquests.Session", method: str, url: str, *, timeout: float, max_retries: int, retry_on_statuses: Iterable[int] = (429, 500, 502, 503, 504), **kwargs: object, ): attempt = 0 last_error: Optional[Exception] = None while attempt <= max_retries: attempt += 1 try: response = session.request(method=method, url=url, timeout=timeout, **kwargs) except Exception as exc: last_error = exc log_event( "warning", "http_request_exception", method=method, url=url, attempt=attempt, max_retries=max_retries, error_type=type(exc).__name__, ) if attempt > max_retries: break time.sleep(min(0.5 * attempt, 2.0)) continue if response.status_code in retry_on_statuses and attempt <= max_retries: log_event( "warning", "http_retryable_status", method=method, url=url, attempt=attempt, status_code=response.status_code, ) time.sleep(min(0.5 * attempt, 2.0)) continue return response if last_error is not None: raise PlingUploaderError( f"HTTP request failed after retries: {method} {url} ({type(last_error).__name__})" ) from last_error raise PlingUploaderError(f"HTTP request failed after retries: {method} {url}") def parse_json_response(response: object, *, context: str) -> dict[str, object]: try: data = response.json() # type: ignore[attr-defined] except Exception as exc: raise PlingUploaderError(f"{context}: expected JSON response.") from exc if not isinstance(data, dict): raise PlingUploaderError(f"{context}: response JSON shape is not an object.") return data def parse_login_form(login_html: str, login_url: str) -> LoginForm: parser = _LoginFormParser() parser.feed(login_html) if parser.login_form is None: raise PlingUploaderError("Could not find login form with email/password fields.") action_url = urljoin(login_url, parser.login_form.action_url) hidden_fields = parser.login_form.hidden_fields missing_keys = [k for k in ("csrf", "redirect_url", "twit") if k not in hidden_fields] if missing_keys: raise PlingUploaderError( f"Login form is missing expected hidden fields: {', '.join(missing_keys)}" ) return LoginForm(action_url=action_url, hidden_fields=hidden_fields) def extract_required_data_attrs(edit_html: str, project_id: str) -> dict[str, str]: attrs: dict[str, str] = {} for attr in REQUIRED_EDIT_DATA_ATTRS: match = re.search(rf"\b{re.escape(attr)}=[\"']([^\"']*)[\"']", edit_html) if match: attrs[attr] = match.group(1).strip() missing = [attr for attr in REQUIRED_EDIT_DATA_ATTRS if attr not in attrs] if missing: raise PlingUploaderError( "Edit page is missing required upload data attributes: " + ", ".join(missing) ) if attrs.get("data-product-id", "") == "": attrs["data-product-id"] = project_id return attrs def extract_js_string_var(html: str, var_name: str) -> Optional[str]: match = re.search(rf"\bvar\s+{re.escape(var_name)}\s*=\s*[\"']([^\"']+)[\"']", html) if match: return match.group(1).strip() return None def extract_form_append_literal(html: str, key: str) -> Optional[str]: match = re.search( rf"form\.append\([\"']{re.escape(key)}[\"'],\s*[\"']([^\"']+)[\"']\)", html, ) if match: return match.group(1).strip() return None def looks_like_login_page(html: str) -> bool: lowered = html.lower() return 'name="email"' in lowered and 'name="password"' in lowered and "login" in lowered def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( "Pling/OpenDesktop uploader. Default mode deletes all files then uploads files." ) ) parser.add_argument( "-f", "--file", dest="files", action="append", default=[], help="File to upload in live mode. Repeat -f multiple times to upload multiple files.", ) parser.add_argument( "--project-id", help="Pling/OpenDesktop project id (fallback: env PLING_PROJECT_ID)", ) parser.add_argument( "--base-url", help=f"Base URL (fallback: env PLING_BASE_URL, then {DEFAULT_BASE_URL})", ) parser.add_argument( "--username", help="Login username/email (fallback: env PLING_USERNAME)", ) parser.add_argument( "--password", help="Login password (fallback: env PLING_PASSWORD)", ) parser.add_argument( "--timeout", type=float, help=( "Per-request timeout seconds " f"(fallback: env PLING_TIMEOUT, then {DEFAULT_TIMEOUT_SECONDS})" ), ) parser.add_argument( "--max-retries", type=int, help=( "Retry count for transient errors " f"(fallback: env PLING_MAX_RETRIES, then {DEFAULT_MAX_RETRIES})" ), ) parser.add_argument( "--dry-run", action="store_true", help="Validate login and endpoint discovery only.", ) return parser def resolve_cli_or_env( *, cli_value: Optional[str], env_key: str, default: Optional[str] = None, ) -> Optional[str]: if cli_value is not None and cli_value.strip() != "": return cli_value.strip() env_value = os.getenv(env_key) if env_value is not None and env_value.strip() != "": return env_value.strip() return default def resolve_float_cli_or_env( *, cli_value: Optional[float], env_key: str, default: float, ) -> float: if cli_value is not None: return cli_value env_value = os.getenv(env_key) if env_value is None or env_value.strip() == "": return default try: return float(env_value) except ValueError as exc: raise PlingUploaderError( f"Invalid {env_key} value {env_value!r}; expected a number." ) from exc def resolve_int_cli_or_env( *, cli_value: Optional[int], env_key: str, default: int, ) -> int: if cli_value is not None: return cli_value env_value = os.getenv(env_key) if env_value is None or env_value.strip() == "": return default try: return int(env_value) except ValueError as exc: raise PlingUploaderError( f"Invalid {env_key} value {env_value!r}; expected an integer." ) from exc def resolve_runtime_config(args: argparse.Namespace) -> RuntimeConfig: project_id = resolve_cli_or_env(cli_value=args.project_id, env_key="PLING_PROJECT_ID") if not project_id: raise PlingUploaderError("Missing project id. Set --project-id or env PLING_PROJECT_ID.") username = resolve_cli_or_env(cli_value=args.username, env_key="PLING_USERNAME") password = resolve_cli_or_env(cli_value=args.password, env_key="PLING_PASSWORD") if not username or not password: raise PlingUploaderError( "Missing credentials. Set --username/--password or env PLING_USERNAME/PLING_PASSWORD." ) timeout = resolve_float_cli_or_env( cli_value=args.timeout, env_key="PLING_TIMEOUT", default=DEFAULT_TIMEOUT_SECONDS, ) max_retries = resolve_int_cli_or_env( cli_value=args.max_retries, env_key="PLING_MAX_RETRIES", default=DEFAULT_MAX_RETRIES, ) if max_retries < 0: raise PlingUploaderError("--max-retries must be >= 0") if timeout <= 0: raise PlingUploaderError("--timeout must be > 0") base_url = normalize_base_url( resolve_cli_or_env( cli_value=args.base_url, env_key="PLING_BASE_URL", default=DEFAULT_BASE_URL, ) or DEFAULT_BASE_URL ) artifact_paths: list[Path] = [] if not args.dry_run: cli_files = [entry.strip() for entry in args.files if entry and entry.strip()] env_files_raw = os.getenv("PLING_FILES", "") env_files = [entry.strip() for entry in env_files_raw.split(",") if entry.strip()] selected_files = cli_files if cli_files else env_files if not selected_files: raise PlingUploaderError( "Missing upload files. Pass -f (repeatable) or set env PLING_FILES as comma-separated values." ) for file_entry in selected_files: artifact_path = Path(file_entry) if not artifact_path.exists() or not artifact_path.is_file(): raise PlingUploaderError( f"Upload file does not exist or is not a file: {artifact_path}" ) artifact_paths.append(artifact_path) return RuntimeConfig( project_id=project_id, base_url=base_url, username=username, password=password, timeout=timeout, max_retries=max_retries, dry_run=args.dry_run, artifact_paths=artifact_paths, ) def create_session(base_url: str) -> "niquests.Session": session = niquests.Session() session.headers.update( { "User-Agent": ( "Mozilla/5.0 (X11; Linux x86_64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/123.0.0.0 Safari/537.36" ), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", } ) session.cookies.set("verified", "1", domain=urlparse(base_url).hostname) return session def discover_edit_context( session: "niquests.Session", config: RuntimeConfig, ) -> tuple[str, EditContext]: login_url = urljoin(config.base_url + "/", LOGIN_PATH.lstrip("/")) edit_url = urljoin( config.base_url + "/", EDIT_PATH_TEMPLATE.format(project_id=config.project_id).lstrip("/"), ) log_event( "info", "session_start", base_url=config.base_url, project_id=config.project_id, dry_run=config.dry_run, ) login_page = request_with_retries( session, "GET", login_url, timeout=config.timeout, max_retries=config.max_retries, ) log_event("info", "login_page_fetched", **_safe_response_context(login_page)) login_form = parse_login_form(login_page.text, login_url) form_payload = dict(login_form.hidden_fields) form_payload.update({"email": config.username, "password": config.password}) auth_response = request_with_retries( session, "POST", login_form.action_url, data=form_payload, timeout=config.timeout, max_retries=config.max_retries, ) log_event("info", "login_submitted", **_safe_response_context(auth_response)) if "incorrect login" in auth_response.text.lower(): raise PlingUploaderError("Authentication failed: incorrect username or password.") edit_page = request_with_retries( session, "GET", edit_url, timeout=config.timeout, max_retries=config.max_retries, ) log_event("info", "edit_page_fetched", **_safe_response_context(edit_page)) if looks_like_login_page(edit_page.text): raise PlingUploaderError( "Authentication appears unsuccessful: redirected back to login page." ) if str(edit_page.url).rstrip("/") == normalize_base_url(login_url).rstrip("/"): raise PlingUploaderError("Unable to access edit page; received login page URL.") attrs = extract_required_data_attrs(edit_page.text, project_id=config.project_id) resolved: dict[str, str] = {} for key, value in attrs.items(): replaced = value.replace("@@project_id@@", config.project_id) if key.endswith("-uri"): resolved[key] = urljoin(config.base_url + "/", replaced.lstrip("/")) else: resolved[key] = replaced client_id = extract_js_string_var(edit_page.text, "client_id") file_uri = extract_js_string_var(edit_page.text, "fileUri") owner_id = extract_form_append_literal(edit_page.text, "owner_id") missing_runtime = [] if not client_id: missing_runtime.append("client_id") if not file_uri: missing_runtime.append("fileUri") if not owner_id: missing_runtime.append("owner_id") if missing_runtime: raise PlingUploaderError( "Edit page is missing required upload runtime values: " + ", ".join(missing_runtime) ) context = EditContext( add_file_url=resolved["data-addpploadfile-uri"], update_file_url=resolved["data-updatepploadfile-uri"], delete_file_url=resolved["data-deletepploadfile-uri"], delete_all_files_url=resolved["data-deletepploadfiles-uri"], product_id=resolved["data-product-id"], collection_id=resolved["data-ppload-collection-id"], file_server_upload_url=file_uri, file_server_client_id=client_id, file_server_owner_id=owner_id, ) log_event("info", "endpoint_discovered", name="data-addpploadfile-uri", value=context.add_file_url) log_event( "info", "endpoint_discovered", name="data-deletepploadfiles-uri", value=context.delete_all_files_url, ) log_event( "info", "endpoint_discovered", name="data-ppload-collection-id", value=context.collection_id, ) log_event("info", "endpoint_discovered", name="data-product-id", value=context.product_id) return edit_url, context def delete_all_existing_files( session: "niquests.Session", config: RuntimeConfig, edit_url: str, context: EditContext, ) -> None: log_event( "warning", "delete_all_existing_files_start", message="Overwrite mode enabled: deleting all existing product files before upload.", ) response = request_with_retries( session, "POST", context.delete_all_files_url, data={}, headers={ "X-Requested-With": "XMLHttpRequest", "Accept": "application/json", "Referer": edit_url, }, timeout=config.timeout, max_retries=config.max_retries, ) payload = parse_json_response(response, context="deletepploadfiles") status = payload.get("status") if status != "ok": raise PlingUploaderError( f"deletepploadfiles failed: status={status!r}" ) log_event("info", "delete_all_existing_files_success") def upload_to_file_server( session: "niquests.Session", config: RuntimeConfig, context: EditContext, artifact_path: Path, ) -> dict[str, object]: log_event("info", "file_server_upload_start", artifact=str(artifact_path)) with artifact_path.open("rb") as artifact_file: response = request_with_retries( session, "POST", context.file_server_upload_url, data={ "client_id": context.file_server_client_id, "owner_id": context.file_server_owner_id, "format": "json", "collection_id": context.collection_id, "method": "post", }, files={ "file": (artifact_path.name, artifact_file), }, timeout=max(config.timeout, 120.0), max_retries=config.max_retries, ) payload = parse_json_response(response, context="file server upload") status = payload.get("status") if status != "success": raise PlingUploaderError(f"File server upload failed: status={status!r}") file_payload = payload.get("file") if not isinstance(file_payload, dict) or not file_payload: raise PlingUploaderError("File server upload succeeded but no file payload was returned.") log_event( "info", "file_server_upload_success", file_id=file_payload.get("id", "unknown"), file_name=file_payload.get("name", "unknown"), ) return file_payload def register_uploaded_file( session: "niquests.Session", config: RuntimeConfig, edit_url: str, context: EditContext, file_payload: dict[str, object], ) -> dict[str, object]: response = request_with_retries( session, "POST", context.add_file_url, data=file_payload, headers={ "X-Requested-With": "XMLHttpRequest", "Accept": "application/json", "Referer": edit_url, }, timeout=config.timeout, max_retries=config.max_retries, ) payload = parse_json_response(response, context="addpploadfile") status = payload.get("status") if status != "ok": raise PlingUploaderError(f"addpploadfile failed: status={status!r}") registered_file = payload.get("file") if not isinstance(registered_file, dict) or not registered_file: raise PlingUploaderError("addpploadfile succeeded but no file payload was returned.") return registered_file def run_dry_run(config: RuntimeConfig) -> int: session = create_session(config.base_url) _edit_url, _context = discover_edit_context(session, config) log_event( "info", "dry_run_success", message="Dry-run completed. No upload/update/delete actions were performed.", ) return 0 def run_upload_mode(config: RuntimeConfig) -> int: session = create_session(config.base_url) edit_url, context = discover_edit_context(session, config) delete_all_existing_files(session, config, edit_url, context) uploaded_file_ids: list[str] = [] for artifact_path in config.artifact_paths: file_payload = upload_to_file_server(session, config, context, artifact_path) registered_file = register_uploaded_file(session, config, edit_url, context, file_payload) uploaded_file_id = str(registered_file.get("id", "unknown")) uploaded_file_name = str(registered_file.get("name", artifact_path.name)) uploaded_file_ids.append(uploaded_file_id) log_event( "info", "file_registered", uploaded_file_id=uploaded_file_id, uploaded_file_name=uploaded_file_name, ) log_event("info", "upload_success", files_uploaded=len(uploaded_file_ids)) return 0 def main() -> int: parser = build_parser() args = parser.parse_args() try: config = resolve_runtime_config(args) if config.dry_run: return run_dry_run(config) return run_upload_mode(config) except PlingUploaderError as exc: log_event("error", "upload_failed", message=str(exc)) return 1 if __name__ == "__main__": raise SystemExit(main())