"""WooCommerce REST v3 client (stdlib urllib). Basic-auth, HTTPS-only, paginated. Default creds come from Settings (wp_username plain, app_password AES-GCM). Missing creds raise WooCredentialsMissing (fail-closed), distinct from WooAuthError (401 from the store). Prototype per-user override: callers may pass X-Woo-Username + X-Woo-App-Password request headers into client_for_request(); those build a per-request WooClient and are never written into Settings. See PROTOTYPE.md. """ import base64 import json import urllib.error import urllib.request from typing import Any, Callable from .crypto import DecryptError, decrypt # WordPress security plugins (Wordfence, WAF, CDN) commonly block the default # Python-urllib User-Agent with 403. Send a descriptive one. _USER_AGENT = "SlabUploader/1.0 (+https://www.whidbeywoodstore.com)" class WooError(Exception): """Base for all Woo client failures.""" class WooCredentialsMissing(WooError): """Woo base URL or credentials are not configured (fail-closed).""" class WooAuthError(WooError): """The store rejected the credentials (401).""" class WooForbiddenError(WooError): """The store blocked the request (403) — usually a security plugin or WAF.""" class WooNotFoundError(WooError): pass class WooServerError(WooError): pass class WooConnectionError(WooError): """Store unreachable (DNS, TLS, timeout).""" def _default_request(url: str, method: str, data: bytes | None, headers: dict[str, str]): req = urllib.request.Request(url, method=method, data=data) for k, v in headers.items(): req.add_header(k, v) try: with urllib.request.urlopen(req, timeout=15) as r: return r.getcode(), dict(r.headers.items()), r.read() except urllib.error.HTTPError as e: body = e.read() try: parsed = json.loads(body) except (ValueError, json.JSONDecodeError): parsed = {} msg = parsed.get("message") if isinstance(parsed, dict) else None if e.code == 401: raise WooAuthError(msg or "authentication rejected (401)") from e if e.code == 403: raise WooForbiddenError(msg or "request forbidden (403)") from e if e.code == 404: raise WooNotFoundError(msg or "not found (404)") from e raise WooServerError(f"server error {e.code}: {msg}") from e except (urllib.error.URLError, OSError, TimeoutError) as e: raise WooConnectionError(f"store unreachable: {e}") from e class WooClient: def __init__(self, username: str, password: str, base_url: str, request: Callable[..., tuple[int, dict, bytes]] = _default_request) -> None: if not base_url or not base_url.startswith("https://"): raise WooCredentialsMissing("WOO_BASE_URL must be https:// (SETTINGS-10)") self.base_url = base_url.rstrip("/") self._request = request self._auth = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii") @classmethod def from_settings(cls, base_url: str, username: str | None, encrypted_password: str | None, request: Callable[..., tuple[int, dict, bytes]] | None = None) -> "WooClient": """Build from Settings values; raise WooCredentialsMissing if incomplete (fail-closed).""" if not username: raise WooCredentialsMissing("woo_wp_username not set (SETTINGS-15)") if not encrypted_password: raise WooCredentialsMissing("woo_app_password not configured (SETTINGS-15)") try: password = decrypt(encrypted_password) except DecryptError as e: raise WooCredentialsMissing("woo_app_password cannot be decrypted (re-enter it)") from e if request is None: request = _default_request return cls(username, password, base_url, request) def _get(self, path: str) -> tuple[int, dict, bytes]: url = f"{self.base_url}/wp-json/wc/v3{path}" return self._request(url, "GET", None, self._auth_headers()) def _fetch_all(self, path: str) -> list[dict[str, Any]]: page = 1 results: list[dict[str, Any]] = [] while True: code, headers, body = self._get(f"{path}?per_page=100&page={page}") if code != 200: raise WooServerError(f"expected 200, got {code}") results.extend(json.loads(body)) total_pages = int(headers.get("X-WP-TotalPages") or headers.get("x-wp-totalpages") or 1) if page >= total_pages: return results page += 1 def get_categories(self) -> list[dict[str, Any]]: return self._fetch_all("/products/categories") def get_tags(self) -> list[dict[str, Any]]: return self._fetch_all("/products/tags") def get_attributes(self) -> list[dict[str, Any]]: return self._fetch_all("/products/attributes") def get_attribute_terms(self, attr_id: int) -> list[dict[str, Any]]: return self._fetch_all(f"/products/attributes/{attr_id}/terms") def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]: """Shared headers for every outbound Woo/WP request (User-Agent + auth).""" h = { "Authorization": f"Basic {self._auth}", "Accept": "application/json", "User-Agent": _USER_AGENT, } if extra: h.update(extra) return h def get_products_by_sku(self, sku: str) -> list[dict[str, Any]]: """GET products filtered by SKU. Raises typed Woo* errors on non-200.""" code, _h, body = self._request( f"{self.base_url}/wp-json/wc/v3/products?sku={sku}", "GET", None, self._auth_headers(), ) if code != 200: raise WooServerError(f"sku lookup returned {code}") return list(json.loads(body)) def create_product(self, payload: dict[str, Any]) -> int: """POST /products. Raises typed Woo* errors on failure; returns product id.""" body = json.dumps(payload).encode("utf-8") code, _h, resp = self._request( f"{self.base_url}/wp-json/wc/v3/products", "POST", body, self._auth_headers({"Content-Type": "application/json"}), ) if code not in (200, 201): raise WooServerError(f"product create returned {code}") return int(json.loads(resp)["id"]) def upload_media(self, png_bytes: bytes, filename: str) -> int: """POST a PNG to WordPress core media; return the attachment id.""" boundary = "----slabuploader-media-boundary" body = ( f"--{boundary}\r\n" f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' "Content-Type: image/png\r\n\r\n" ).encode("utf-8") + png_bytes + f"\r\n--{boundary}--\r\n".encode("utf-8") url = f"{self.base_url}/wp-json/wp/v2/media" code, _headers, resp = self._request( url, "POST", body, self._auth_headers({"Content-Type": f"multipart/form-data; boundary={boundary}"}), ) if code not in (200, 201): raise WooServerError(f"media upload returned {code}") return int(json.loads(resp)["id"]) # --- Per-request Woo credential override (prototype) --- # Scheme: browser sends plaintext WP Application Password credentials on # Woo-touching API calls via: # X-Woo-Username # X-Woo-App-Password # Server uses those for THIS request's WooClient only; never writes them into # the Settings row. If either header is missing/blank, fall back to the # shop-default singleton (woo_wp_username + encrypted woo_app_password). HEADER_WOO_USERNAME = "X-Woo-Username" HEADER_WOO_APP_PASSWORD = "X-Woo-App-Password" def settings_woo_creds(conn) -> tuple[str, str | None, str | None]: """Shop-default Woo creds from env + Settings (encrypted password).""" import os base = os.environ.get("WOO_BASE_URL", "") u = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone() p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone() return base, (u[0] if u else None), (p[0] if p else None) def request_woo_override(headers) -> tuple[str, str] | None: """Return (username, app_password) if both override headers are present and non-blank.""" if headers is None: return None # Starlette Headers are case-insensitive user = (headers.get(HEADER_WOO_USERNAME) or "").strip() password = (headers.get(HEADER_WOO_APP_PASSWORD) or "").strip() if user and password: return user, password return None def client_for_request(conn, headers=None, request: Callable[..., tuple[int, dict, bytes]] | None = None) -> WooClient: """Build a WooClient for one HTTP request: override headers → Settings fallback. Never mutates Settings. Inference stays on shared Settings (caller responsibility). """ base, settings_user, settings_enc = settings_woo_creds(conn) override = request_woo_override(headers) if override is not None: user, password = override if request is None: request = _default_request return WooClient(user, password, base, request) return WooClient.from_settings(base, settings_user, settings_enc, request=request)