From 09eda19fc470ff1f88a23cc40aee86c09c536e77 Mon Sep 17 00:00:00 2001 From: agentbox Date: Sat, 1 Aug 2026 06:50:23 +0000 Subject: [PATCH] fix: send ntfy metadata as HTTP headers, add auto-login from notification click - ntfy.py: switch from JSON body to HTTP headers (Title, Priority, Tags, Actions, Click) with plain message body. ntfy servers treat the POST body as the message, so JSON body fields were ignored and displayed as raw JSON. - ntfy.py: replace em dashes in headers with hyphens (HTTP headers must be latin-1 encodable). - meals.py: add /auto-login/ route that logs users in via their ntfy callback token so clicking a notification opens the dashboard without re-entering credentials. - ntfy.py: update Click URL to /auto-login/?date=... --- meals.py | 27 ++++++++++++++ ntfy.py | 107 +++++++++++++++++++++++++++++-------------------------- 2 files changed, 83 insertions(+), 51 deletions(-) diff --git a/meals.py b/meals.py index 1f16ec9..482c38a 100644 --- a/meals.py +++ b/meals.py @@ -342,6 +342,33 @@ def ntfy_callback(): return {"message": "Response recorded", "status": status}, 200 +# ── Auto-login from ntfy notification click ────────────────────────────────── + +@meals_bp.route("/auto-login/") +def auto_login(token): + """Log in a user via their ntfy callback token, then redirect to dashboard. + + Clicking an ntfy notification opens this URL. The callback token + identifies the user, so they don't need to re-enter credentials. + """ + user = get_user_by_callback_token(token) + if user is None: + flash(t("Invalid login link."), "error") + return redirect(url_for("auth.login")) + + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_admin"] = bool(user["is_admin"]) + session["household_id"] = user["household_id"] + session["household_name"] = user["household_name"] or "" + + date_param = request.args.get("date", "") + target = url_for("meals.dashboard") + if date_param: + target += f"?date={date_param}" + return redirect(target) + + # ── Cron tick (called by meal-cron container) ───────────────────────────────── REMINDER_HOURS = {"lunch": 10, "dinner": 16} diff --git a/ntfy.py b/ntfy.py index f12dec5..9fd14f0 100644 --- a/ntfy.py +++ b/ntfy.py @@ -8,9 +8,9 @@ import urllib.error from datetime import date -def _ntfy_headers(token: str) -> dict[str, str]: - """Build Authorization header if token is provided.""" - headers = {"Content-Type": "application/json"} +def _ntfy_headers(token: str, extra: dict[str, str] | None = None) -> dict[str, str]: + """Build headers for ntfy request, optionally with Authorization.""" + headers = dict(extra) if extra else {} if token.strip(): headers["Authorization"] = f"Bearer {token.strip()}" return headers @@ -39,49 +39,54 @@ def send_meal_reminder( """ date_str = for_date.isoformat() meal_label = "Lunch" if meal_type == "lunch" else "Dinner" - emoji = "🌤" if meal_type == "lunch" else "🌙" callback_url = f"{app_base_url.rstrip('/')}/api/ntfy-callback" - payload = { - "topic": ntfy_url.rstrip("/").rsplit("/", 1)[-1], - "title": f"{emoji} {meal_label} — Meal Reminder", - "message": f"Will you be home for {meal_label.lower()} on {date_str}?", - "priority": 4, - "tags": ["plate_with_cutlery"], - "actions": [ - { - "action": "http", - "label": "🏠 Home", - "method": "POST", - "url": callback_url, - "headers": {"Content-Type": "application/json"}, - "body": json.dumps({ - "token": callback_token, - "meal_type": meal_type, - "date": date_str, - "status": "yes", - }), - }, - { - "action": "http", - "label": "🍽 Out", - "method": "POST", - "url": callback_url, - "headers": {"Content-Type": "application/json"}, - "body": json.dumps({ - "token": callback_token, - "meal_type": meal_type, - "date": date_str, - "status": "no", - }), - }, - ], - "click": f"{app_base_url.rstrip('/')}/dashboard?date={date_str}", - } + topic = ntfy_url.rstrip("/").rsplit("/", 1)[-1] + message = f"Will you be home for {meal_label.lower()} on {date_str}?" - data = json.dumps(payload).encode("utf-8") - req = urllib.request.Request(ntfy_url, data=data, headers=_ntfy_headers(ntfy_token)) + home_body = json.dumps({ + "token": callback_token, + "meal_type": meal_type, + "date": date_str, + "status": "yes", + }) + out_body = json.dumps({ + "token": callback_token, + "meal_type": meal_type, + "date": date_str, + "status": "no", + }) + + actions = json.dumps([ + { + "action": "http", + "label": "Home", + "method": "POST", + "url": callback_url, + "headers": {"Content-Type": "application/json"}, + "body": home_body, + }, + { + "action": "http", + "label": "Out", + "method": "POST", + "url": callback_url, + "headers": {"Content-Type": "application/json"}, + "body": out_body, + }, + ]) + + headers = _ntfy_headers(ntfy_token, { + "Title": f"{meal_label} - Meal Reminder", + "Priority": "4", + "Tags": "plate_with_cutlery", + "Actions": actions, + "Click": f"{app_base_url.rstrip('/')}/auto-login/{callback_token}?date={date_str}", + }) + + data = message.encode("utf-8") + req = urllib.request.Request(ntfy_url, data=data, headers=headers) try: with urllib.request.urlopen(req, timeout=10) as resp: @@ -93,16 +98,16 @@ def send_meal_reminder( def send_test_notification(ntfy_url: str, ntfy_token: str) -> bool: """Send a simple test notification to verify ntfy configuration.""" - payload = { - "topic": ntfy_url.rstrip("/").rsplit("/", 1)[-1], - "title": "✅ Meal Tracker — Test", - "message": "Your ntfy integration is working! You'll receive meal reminders here.", - "priority": 3, - "tags": ["white_check_mark"], - } + topic = ntfy_url.rstrip("/").rsplit("/", 1)[-1] - data = json.dumps(payload).encode("utf-8") - req = urllib.request.Request(ntfy_url, data=data, headers=_ntfy_headers(ntfy_token)) + headers = _ntfy_headers(ntfy_token, { + "Title": "Meal Tracker - Test", + "Priority": "3", + "Tags": "white_check_mark", + }) + + data = "Your ntfy integration is working! You'll receive meal reminders here.".encode("utf-8") + req = urllib.request.Request(ntfy_url, data=data, headers=headers) try: with urllib.request.urlopen(req, timeout=10) as resp: