From 1bed02ebb5290d4f2561a554b99ac986a4c164fd Mon Sep 17 00:00:00 2001 From: derekc Date: Sun, 8 Mar 2026 23:49:54 -0700 Subject: [PATCH] Add ntfy authentication support (username/password and API key) - Settings table gets ntfy_username, ntfy_password, ntfy_api_key columns - Backend applies Basic or Bearer auth header when sending notifications - Settings page UI lets you toggle between no auth, basic, or token auth - Masked credential display on load to avoid exposing stored secrets - README updated with auth modes documentation Co-Authored-By: Claude Sonnet 4.6 --- README.md | 17 +++++++++++--- backend/models.py | 3 +++ backend/routers/notifications.py | 20 ++++++++++++---- backend/schemas.py | 3 +++ mysql/init.sql | 5 +++- nginx/html/css/style.css | 6 +++++ nginx/html/index.html | 31 +++++++++++++++++++++++++ nginx/html/js/app.js | 40 ++++++++++++++++++++++++++------ 8 files changed, 109 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index f2585bd..d526386 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Sproutly takes the guesswork out of seed starting. Enter your plant varieties on - **Seed Library** — manage plant varieties with frost-relative timing, germination days, sun/water requirements - **Garden Tracker** — log growing batches and track status from `planned` → `germinating` → `seedling` → `potted up` → `hardening off` → `garden` → `harvested` - **Year Timeline** — visual calendar showing when each variety's stages fall across the year -- **Ntfy Notifications** — daily summary push notifications to your phone, configurable time and topic +- **Ntfy Notifications** — daily summary push notifications to your phone, configurable time, topic, and authentication - **Settings** — set your last frost date, fall frost date, location, timezone, and notification preferences ## Stack @@ -45,7 +45,7 @@ Access the app at **http://localhost:8053** ### First Steps 1. Go to **Settings** and enter your last frost date — this anchors all planting schedule calculations -2. Optionally configure an [ntfy](https://ntfy.sh) topic for push notifications +2. Optionally configure an [ntfy](https://ntfy.sh) topic for push notifications — supports no auth, username/password, or API key/token 3. Browse the pre-loaded **Seed Library** (12 common vegetables, herbs, and flowers included) 4. Start logging batches in **My Garden** as you sow seeds @@ -97,7 +97,18 @@ Key endpoints: - `GET/PUT /api/settings/` — app settings - `POST /api/notifications/test` — send test ntfy notification - `POST /api/notifications/daily` — trigger daily summary +- `GET /api/notifications/log` — recent notification history + +## Ntfy Authentication + +For private ntfy servers or access-controlled topics, the Settings page supports three auth modes: + +| Mode | When to use | +|------|-------------| +| None | Public ntfy.sh topics | +| Username & Password | ntfy server with basic auth enabled | +| API Key / Token | ntfy account access token (generate in ntfy account settings) | ## Status -This project is in early development. Core infrastructure and UI prototype are functional. UI design and feature set are evolving based on user feedback. +Core infrastructure is functional. UI design and feature set are evolving based on user feedback. diff --git a/backend/models.py b/backend/models.py index 2aa769c..487445a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -86,6 +86,9 @@ class Settings(Base): notification_time = Column(String(5), default="07:00") timezone = Column(String(50), default="UTC") location_name = Column(String(100)) + ntfy_username = Column(String(200)) + ntfy_password = Column(String(200)) + ntfy_api_key = Column(String(200)) class NotificationLog(Base): diff --git a/backend/routers/notifications.py b/backend/routers/notifications.py index b6397e7..f4d6051 100644 --- a/backend/routers/notifications.py +++ b/backend/routers/notifications.py @@ -1,3 +1,4 @@ +import base64 from datetime import date, timedelta from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session @@ -96,16 +97,25 @@ async def send_ntfy(settings: Settings, title: str, message: str, db: Session, p server = (settings.ntfy_server or "https://ntfy.sh").rstrip("/") url = f"{server}/{settings.ntfy_topic}" + headers = { + "Title": title, + "Priority": priority, + "Tags": "seedling", + } + if settings.ntfy_api_key: + headers["Authorization"] = f"Bearer {settings.ntfy_api_key}" + elif settings.ntfy_username: + creds = base64.b64encode( + f"{settings.ntfy_username}:{settings.ntfy_password or ''}".encode() + ).decode() + headers["Authorization"] = f"Basic {creds}" + try: async with httpx.AsyncClient(timeout=10) as client: resp = await client.post( url, content=message.encode("utf-8"), - headers={ - "Title": title, - "Priority": priority, - "Tags": "seedling", - }, + headers=headers, ) resp.raise_for_status() diff --git a/backend/schemas.py b/backend/schemas.py index c3e9c4b..cde2a6c 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -78,6 +78,9 @@ class SettingsUpdate(BaseModel): notification_time: Optional[str] = "07:00" timezone: Optional[str] = "UTC" location_name: Optional[str] = None + ntfy_username: Optional[str] = None + ntfy_password: Optional[str] = None + ntfy_api_key: Optional[str] = None class SettingsOut(SettingsUpdate): diff --git a/mysql/init.sql b/mysql/init.sql index 50d450e..29a80ee 100644 --- a/mysql/init.sql +++ b/mysql/init.sql @@ -41,7 +41,10 @@ CREATE TABLE IF NOT EXISTS settings ( ntfy_server VARCHAR(200) DEFAULT 'https://ntfy.sh', notification_time VARCHAR(5) DEFAULT '07:00', timezone VARCHAR(50) DEFAULT 'UTC', - location_name VARCHAR(100) + location_name VARCHAR(100), + ntfy_username VARCHAR(200), + ntfy_password VARCHAR(200), + ntfy_api_key VARCHAR(200) ); CREATE TABLE IF NOT EXISTS notification_log ( diff --git a/nginx/html/css/style.css b/nginx/html/css/style.css index e883e81..49b7b48 100644 --- a/nginx/html/css/style.css +++ b/nginx/html/css/style.css @@ -628,3 +628,9 @@ a:hover { text-decoration: underline; } .main-content { margin-left: 0; padding: 1rem; } .stats-row { grid-template-columns: 1fr 1fr; } } + +/* Ntfy auth toggle */ +.hidden { display: none !important; } +.auth-toggle { display: flex; gap: 1.25rem; flex-wrap: wrap; } +.auth-toggle-option { display: flex; align-items: center; gap: 0.35rem; cursor: pointer; font-size: 0.9rem; } +.auth-toggle-option input[type="radio"] { accent-color: var(--green-mid); } diff --git a/nginx/html/index.html b/nginx/html/index.html index 0c546ea..c2533c1 100644 --- a/nginx/html/index.html +++ b/nginx/html/index.html @@ -182,6 +182,37 @@ Subscribe to this topic in the ntfy app +
+ +
+ + + +
+
+ +
diff --git a/nginx/html/js/app.js b/nginx/html/js/app.js index 6af817b..fea7d89 100644 --- a/nginx/html/js/app.js +++ b/nginx/html/js/app.js @@ -389,6 +389,11 @@ function renderGarden() { function filterBatches() { renderGarden(); } // ===== Settings ===== +function toggleNtfyAuth(type) { + document.getElementById('ntfy-basic-auth').classList.toggle('hidden', type !== 'basic'); + document.getElementById('ntfy-token-auth').classList.toggle('hidden', type !== 'token'); +} + async function loadSettings() { try { state.settings = await api.get('/settings/'); @@ -400,6 +405,16 @@ async function loadSettings() { document.getElementById('s-ntfy-server').value = s.ntfy_server || 'https://ntfy.sh'; document.getElementById('s-ntfy-topic').value = s.ntfy_topic || ''; document.getElementById('s-notif-time').value = s.notification_time || '07:00'; + + // Auth type + let authType = 'none'; + if (s.ntfy_api_key) authType = 'token'; + else if (s.ntfy_username) authType = 'basic'; + document.querySelector(`input[name="ntfy-auth-type"][value="${authType}"]`).checked = true; + toggleNtfyAuth(authType); + document.getElementById('s-ntfy-username').value = s.ntfy_username || ''; + document.getElementById('s-ntfy-password').value = s.ntfy_password ? '••••••••' : ''; + document.getElementById('s-ntfy-api-key').value = s.ntfy_api_key ? '••••••••' : ''; } catch (e) { toast('Failed to load settings: ' + e.message, true); } @@ -407,15 +422,26 @@ async function loadSettings() { async function saveSettings() { try { + const authType = document.querySelector('input[name="ntfy-auth-type"]:checked').value; + const rawPassword = document.getElementById('s-ntfy-password').value; + const rawApiKey = document.getElementById('s-ntfy-api-key').value; + // If the placeholder mask is still showing, don't overwrite + const isMask = v => v === '••••••••'; + const payload = { - location_name: document.getElementById('s-location').value || null, - last_frost_date: document.getElementById('s-last-frost').value || null, + location_name: document.getElementById('s-location').value || null, + last_frost_date: document.getElementById('s-last-frost').value || null, first_frost_fall_date: document.getElementById('s-first-frost').value || null, - timezone: document.getElementById('s-timezone').value || 'UTC', - ntfy_server: document.getElementById('s-ntfy-server').value || 'https://ntfy.sh', - ntfy_topic: document.getElementById('s-ntfy-topic').value || null, - notification_time: document.getElementById('s-notif-time').value || '07:00', + timezone: document.getElementById('s-timezone').value || 'UTC', + ntfy_server: document.getElementById('s-ntfy-server').value || 'https://ntfy.sh', + ntfy_topic: document.getElementById('s-ntfy-topic').value || null, + notification_time: document.getElementById('s-notif-time').value || '07:00', + ntfy_username: authType === 'basic' ? (document.getElementById('s-ntfy-username').value || null) : null, + ntfy_password: authType === 'basic' && !isMask(rawPassword) ? (rawPassword || null) : (authType === 'basic' ? undefined : null), + ntfy_api_key: authType === 'token' && !isMask(rawApiKey) ? (rawApiKey || null) : (authType === 'token' ? undefined : null), }; + // Remove undefined keys so they are excluded from the PUT (server keeps existing value) + Object.keys(payload).forEach(k => payload[k] === undefined && delete payload[k]); await api.put('/settings/', payload); toast('Settings saved!'); document.getElementById('settings-status').textContent = 'Saved!'; @@ -779,7 +805,7 @@ window.App = { showAddVarietyModal, showEditVarietyModal, submitAddVariety, submitEditVariety, deleteVariety, showAddBatchModal, showEditBatchModal, submitAddBatch, submitEditBatch, deleteBatch, filterVarieties, filterBatches, - saveSettings, sendTestNotification, sendDailySummary, + saveSettings, toggleNtfyAuth, sendTestNotification, sendDailySummary, closeModal: (e) => closeModal(e), };