"""Shopping list PWA web endpoint (US-SL-004). Exposes a lightweight, mobile-responsive shopping list at ``/shopping`` that can be accessed from any phone browser. Optionally protected by a PIN stored in ``AppConfig.shopping_pwa_pin``; default is open (no auth). Endpoints --------- GET /shopping — PWA HTML shell GET /shopping/manifest.json — Web app manifest GET /shopping/api/items — JSON: list all items POST /shopping/api/items — JSON: add item PATCH /shopping/api/items/ — JSON: toggle checked POST /shopping/api/clear-checked — JSON: remove all checked items POST /shopping/pin — PIN auth form handler """ from __future__ import annotations import hashlib import hmac import json import logging import secrets from collections.abc import Callable from functools import wraps from pathlib import Path from typing import Any from flask import Blueprint, Response, make_response, redirect, request, send_from_directory from flask.typing import ResponseReturnValue from rex.shopping_list import ShoppingList logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Module-level singletons set by create_blueprint() # --------------------------------------------------------------------------- _shopping_list: ShoppingList | None = None _pin_hash: str | None = None # SHA-256 hex of the configured PIN, or None _cookie_secret: str = secrets.token_hex(32) # ephemeral per-process secret _COOKIE_NAME = "rex_shopping_auth" _COOKIE_MAX_AGE = 86400 # 24 h # --------------------------------------------------------------------------- # Auth helpers # --------------------------------------------------------------------------- def _make_token(pin: str) -> str: """Return an HMAC-signed token for *pin* using the process-local secret.""" return hmac.new(_cookie_secret.encode(), pin.encode(), hashlib.sha256).hexdigest() def _is_authenticated() -> bool: """Return True if the request carries a valid auth cookie or no PIN is set.""" if _pin_hash is None: return True token = request.cookies.get(_COOKIE_NAME) if not token: return False # Reconstruct expected token from stored PIN hash expected = hmac.new(_cookie_secret.encode(), _pin_hash.encode(), hashlib.sha256).hexdigest() return hmac.compare_digest(token, expected) def _login_required(f: Callable[..., Any]) -> Callable[..., Any]: @wraps(f) def wrapper(*args: Any, **kwargs: Any) -> Any: if not _is_authenticated(): return _render_pin_page() return f(*args, **kwargs) return wrapper # --------------------------------------------------------------------------- # HTML / manifest templates (inline — no external templates directory) # --------------------------------------------------------------------------- _PIN_PAGE = """ AskRex — Shopping

🛒 Shopping List

{error}
""" _MAIN_PAGE = """ AskRex — Shopping
AskRex

Shopping List

To Buy

    """ _MANIFEST = { "name": "Rex Shopping List", "short_name": "Rex Shop", "description": "Rex AI shopping list — check items off at the store", "start_url": "/shopping", "display": "standalone", "background_color": "#f5f7fa", "theme_color": "#2563eb", "icons": [ { "src": "data:image/svg+xml," "🛒", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable", } ], } # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _render_pin_page(error: str = "") -> Response: error_html = f'

    {error}

    ' if error else "" html = _PIN_PAGE.replace("{error}", error_html) return make_response(html, 200 if not error else 401) def _json(data: Any, status: int = 200) -> Response: return Response(json.dumps(data), status=status, mimetype="application/json") # --------------------------------------------------------------------------- # Blueprint factory # --------------------------------------------------------------------------- def create_blueprint(shopping_list: ShoppingList, *, pin: str | None = None) -> Blueprint: """Return a Flask Blueprint for the shopping list PWA. Parameters ---------- shopping_list: Shared ``ShoppingList`` instance. pin: Optional PIN string. If falsy, the endpoint is unauthenticated. """ global _shopping_list, _pin_hash _shopping_list = shopping_list _pin_hash = hashlib.sha256(pin.encode()).hexdigest() if pin else None bp = Blueprint("shopping_pwa", __name__) # ------------------------------------------------------------------ # PIN auth form handler # ------------------------------------------------------------------ @bp.route("/shopping/pin", methods=["POST"]) def pin_submit() -> ResponseReturnValue: if _pin_hash is None: return redirect("/shopping") submitted = (request.form.get("pin") or "").strip() submitted_hash = hashlib.sha256(submitted.encode()).hexdigest() if not hmac.compare_digest(submitted_hash, _pin_hash): return _render_pin_page("Incorrect PIN. Please try again.") token = hmac.new(_cookie_secret.encode(), _pin_hash.encode(), hashlib.sha256).hexdigest() resp = make_response(redirect("/shopping")) resp.set_cookie( _COOKIE_NAME, token, max_age=_COOKIE_MAX_AGE, httponly=True, samesite="Lax", ) return resp # ------------------------------------------------------------------ # PWA shell # ------------------------------------------------------------------ @bp.route("/shopping") @_login_required def shopping_index() -> ResponseReturnValue: return make_response(_MAIN_PAGE, 200, {"Content-Type": "text/html; charset=utf-8"}) # ------------------------------------------------------------------ # Manifest # ------------------------------------------------------------------ @bp.route("/shopping/manifest.json") def shopping_manifest() -> ResponseReturnValue: return _json(_MANIFEST) # ------------------------------------------------------------------ # REST API # ------------------------------------------------------------------ @bp.route("/shopping/api/items", methods=["GET"]) @_login_required def api_list_items() -> ResponseReturnValue: assert _shopping_list is not None items = [i.to_dict() for i in _shopping_list.list_items()] return _json({"items": items}) @bp.route("/shopping/api/items", methods=["POST"]) @_login_required def api_add_item() -> ResponseReturnValue: assert _shopping_list is not None body = request.get_json(force=True, silent=True) or {} name = (body.get("name") or "").strip() if not name: return _json({"error": "name is required"}, 400) quantity = float(body.get("quantity") or 1) unit = (body.get("unit") or "").strip() added_by = (body.get("added_by") or "web").strip() item = _shopping_list.add_item(name, quantity=quantity, unit=unit, added_by=added_by) return _json(item.to_dict(), 201) @bp.route("/shopping/api/items/", methods=["PATCH"]) @_login_required def api_toggle_item(item_id: str) -> ResponseReturnValue: assert _shopping_list is not None body = request.get_json(force=True, silent=True) or {} checked = bool(body.get("checked", True)) found = ( _shopping_list.check_item(item_id) if checked else _shopping_list.uncheck_item(item_id) ) if not found: return _json({"error": "item not found"}, 404) item = _shopping_list.get_item(item_id) return _json(item.to_dict() if item else {"id": item_id}) @bp.route("/shopping/api/clear-checked", methods=["POST"]) @_login_required def api_clear_checked() -> ResponseReturnValue: assert _shopping_list is not None removed = _shopping_list.clear_checked() return _json({"removed": removed}) _BRAND_DIR = Path(__file__).resolve().parent.parent / "assets" / "brand" @bp.route("/assets/brand/") def brand_asset(filename: str) -> ResponseReturnValue: return send_from_directory(str(_BRAND_DIR), filename) return bp __all__ = ["create_blueprint"]