From 2343a917361475e1252fa802d808c99b2f53df22 Mon Sep 17 00:00:00 2001 From: Giancarlo Erra Date: Tue, 10 Mar 2026 16:59:12 +0000 Subject: [PATCH] feat: add token-authenticated cron trigger for weather refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add public POST /api/weather/refresh/trigger?key=… endpoint so external cron services (cron-job.org, GitHub Actions) can trigger weather refresh without session auth. Key is generated from Settings UI, stored as SHA-256 hash in Redis, and rate-limited separately (3 req/min). --- .env.example | 5 ++ README.md | 54 ++++++++++++++++---- public/settings.html | 115 ++++++++++++++++++++++++++++++++++++++++++ server/index.js | 117 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 280 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index e6f0976..3f33e19 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,11 @@ MASTER_PASSWORD=your-secret-password # Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" SESSION_SECRET=random-64-char-hex-string +# === Cron Trigger (optional β€” can also be generated via Settings UI) === +# If set, the /api/weather/refresh/trigger endpoint accepts this as the ?key= parameter. +# If not set, generate a key from Settings β†’ Automated Weather Refresh (stored in Redis). +# CRON_TRIGGER_TOKEN=random-64-char-hex-string + # === API Keys (optional β€” can also be set at runtime via Settings UI) === # Meteoblue: https://content.meteoblue.com/en/business-solutions/weather-apis/free-weather-api METEOBLUE_API_KEY=your-meteoblue-key diff --git a/README.md b/README.md index 31fc0b0..f8dbb9b 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ I've decided to open source it as I think it could be useful to other astrophoto - [πŸ”‘ Required services and API keys](#-required-services-and-api-keys) - [🌐 Quick Deployment (on Render.com)](#-quick-deployment-on-rendercom) - [πŸš€ Deployment Guide](#-deployment-guide) -- [⏰ Automated Data Collection](#-automated-data-collection) +- [⏰ Automated Weather Refresh](#-automated-weather-refresh) - [πŸ†˜ Support / hosting help](#-support--hosting-help) - [πŸ—οΈ Architecture](#️-architecture) - [πŸ” Authentication](#-authentication) @@ -178,7 +178,7 @@ If you don't have a Telescopius key, the sky dashboard still works fully β€” onl ### cron-job.org β€” optional, free -APD fetches fresh weather data automatically when you open the dashboard, but for proactive twice-daily updates (so the forecast is always ready when you need it), point a free HTTP cron job at your deployed URL. See [Automated Data Collection](#-automated-data-collection) for the full setup. +APD fetches fresh weather data automatically when you open the dashboard, but for proactive twice-daily updates (so the forecast is always ready when you need it), point a free HTTP cron job at your deployed URL. See [Automated Weather Refresh](#-automated-weather-refresh) for the full setup. --- @@ -279,26 +279,48 @@ On every push to `main`, Render will redeploy automatically. When you open the dashboard and the weather data is stale (older than 2:30 PM the previous day), APD will download fresh data automatically within that request β€” but you'll wait a few seconds while it fetches. A cron job pre-fetches the data on a fixed schedule so it's always ready the moment you open the app. -**Option A β€” cron-job.org (free)** +**Step 1 β€” Generate a trigger key** + +Go to **Settings β†’ Automated Weather Refresh** and click **Generate Key**. A unique URL will appear β€” copy it immediately (the key is only shown once). The URL looks like: + +``` +POST https://your-app.example.com/api/weather/refresh/trigger?key= +``` + +You can rotate or revoke the key at any time from the same Settings card. + +**Step 2 β€” Set up a cron service** + +**Option A β€” cron-job.org (free, recommended)** 1. Sign up at [cron-job.org](https://cron-job.org). -2. Create two jobs calling `POST https:///api/weather/refresh` with your `MASTER_PASSWORD` cookie or open endpoint. +2. Create two jobs using the trigger URL you copied above. Set the request method to **POST**. 3. Suggested schedule: `30 9 * * *` (09:30) and `35 13 * * *` (13:35). **Option B β€” GitHub Actions scheduled workflow** -Add a workflow that calls `curl -X POST https:///api/weather/refresh` on a schedule. +Add a workflow that calls the trigger URL on a schedule: + +```yaml +- run: curl -sf -X POST "${{ secrets.APD_TRIGGER_URL }}" +``` + +**Alternative β€” environment variable** + +If you prefer to manage the token outside the UI, set `CRON_TRIGGER_TOKEN` in your `.env` or hosting dashboard. The trigger URL is then: `POST https:///api/weather/refresh/trigger?key=`. --- -## ⏰ Automated Data Collection +## ⏰ Automated Weather Refresh ### Scheduled Downloads -A cron service calls `POST /api/weather/refresh` on a schedule to pre-fetch fresh data before you open the dashboard. Without it the app still works β€” it downloads on demand when you visit β€” but you wait a few seconds. With it, data is always ready instantly. +A cron service calls the trigger endpoint on a schedule to pre-fetch fresh data before you open the dashboard. Without it the app still works β€” it downloads on demand when you visit β€” but you wait a few seconds. With it, data is always ready instantly. - **Service**: any free HTTP cron service (e.g. [cron-job.org](https://cron-job.org), GitHub Actions scheduled workflows, Render cron jobs) -- **Endpoint**: `POST /api/weather/refresh` +- **Endpoint**: `POST /api/weather/refresh/trigger?key=` (public β€” authenticated by the key, not by session/cookie) +- **Key management**: generate, rotate, or revoke from **Settings β†’ Automated Weather Refresh** (or set `CRON_TRIGGER_TOKEN` env var) +- **Rate limit**: 3 requests/minute (separate from the manual refresh limit of 5/min) - **Recommended schedule**: once ~10:30 and once ~14:35 local time - **Timezone handling**: the staleness check uses your server's local time β€” no extra config needed @@ -363,9 +385,10 @@ APD requires a few third-party accounts and some technical setup. If you need he The app uses a single **master password** for access control. -- When `MASTER_PASSWORD` is set in `.env`, all routes require authentication (except `/api/health` and `/api/weather/summary`) +- When `MASTER_PASSWORD` is set in `.env`, all routes require authentication (except `/api/health`, `/api/weather/summary`, and `/api/weather/refresh/trigger`) - When `MASTER_PASSWORD` is **not set** (or empty), auth is completely disabled β€” all routes are open - Sessions use HMAC-SHA256 signed tokens stored in httpOnly cookies (30-day expiry) +- The cron trigger endpoint uses a separate token-based auth (not session cookies) β€” see [Automated Weather Refresh](#-automated-weather-refresh) - Login page served at `/login` ### Public Endpoints (always accessible, no auth required) @@ -374,6 +397,7 @@ The app uses a single **master password** for access control. |----------|---------| | `GET /api/health` | Health check β€” also confirms Redis connectivity | | `GET /api/weather/summary` | LLM-friendly weather forecast JSON (for AI integrations) | +| `POST /api/weather/refresh/trigger?key=…` | Cron trigger β€” token-authenticated weather refresh (see Settings) | | `GET /api/auth/check` | Check authentication status | | `POST /api/auth/login` | Login with master password | | `POST /api/auth/logout` | Clear session | @@ -405,6 +429,7 @@ cp .env.example .env | `UPSTASH_REDIS_REST_TOKEN` | **Yes** | Upstash Redis REST token | | `MASTER_PASSWORD` | No | Master password β€” if unset, auth is disabled | | `SESSION_SECRET` | No | Secret for signing session tokens β€” auto-generated if unset | +| `CRON_TRIGGER_TOKEN` | No | Cron trigger token β€” if set, the trigger endpoint accepts this value; otherwise manage via Settings UI β†’ Redis | | `METEOBLUE_API_KEY` | No* | Meteoblue API key β€” without it, only Met Office data is shown; can also be set via UI β†’ Redis | | `TELESCOPIUS_API_KEY` | No* | Telescopius API key β€” without it, sky chart DSO features are disabled; can also be set via UI β†’ Redis | | `OBSERVER_LAT` | No | Default observer latitude (default: 52.6278) | @@ -539,13 +564,22 @@ Both the weather dashboard and sky dashboard share a single observer location st | Method | Endpoint | Auth | Description | |--------|----------|------|-------------| | `GET` | `/api/weather` | Yes | Cached weather data (auto-downloads if stale) | -| `POST` | `/api/weather/refresh` | Yes | Force fresh download | +| `POST` | `/api/weather/refresh` | Yes | Force fresh download (manual/UI use) | +| `POST` | `/api/weather/refresh/trigger?key=…` | **Token** | Cron trigger β€” public, token-authenticated (3 req/min) | | `GET` | `/api/weather/status` | Yes | Download status only | | `GET` | `/api/weather/metoffice` | Yes | Cached Met Office data | | `GET` | `/api/weather/summary` | **No** | LLM-friendly processed forecast | | `GET` | `/api/weather/meteoblue/key-status` | Yes | Check if Meteoblue key is configured | | `PUT` | `/api/weather/meteoblue/key` | Yes | Save Meteoblue API key to Redis | +### Cron Trigger Token + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| `GET` | `/api/settings/cron-token` | Yes | Get trigger key status (configured, source, timestamps) | +| `POST` | `/api/settings/cron-token` | Yes | Generate or rotate trigger key (returns raw key once) | +| `DELETE` | `/api/settings/cron-token` | Yes | Revoke trigger key | + ### Location | Method | Endpoint | Auth | Description | diff --git a/public/settings.html b/public/settings.html index e892681..b8e67a1 100644 --- a/public/settings.html +++ b/public/settings.html @@ -136,6 +136,28 @@

πŸ”‘ API Keys

+ +
+

⏰ Automated Weather Refresh

+

+ Use the trigger URL below with a free cron service (like cron-job.org) to automatically refresh weather data on a schedule β€” so forecasts are always ready when you open the dashboard. +

+
+
+ +
+ + +
+
+

πŸ“Š System Status

@@ -223,6 +245,9 @@

✨ Features

renderKeyStatus('meteoblue', s.apiKeys.meteoblue); renderKeyStatus('telescopius', s.apiKeys.telescopius); + // Cron trigger + renderCronTrigger(s.cronTrigger); + // Auth const dotAuth = document.getElementById('dot-auth'); dotAuth.className = 'status-dot ' + (s.auth.enabled ? 'ok' : 'warn'); @@ -328,6 +353,96 @@

✨ Features

} catch { toast('Failed to save key', true); } } + function renderCronTrigger(cron) { + const el = document.getElementById('status-cron'); + const det = document.getElementById('cron-details'); + const urlBox = document.getElementById('cron-url-box'); + const btnGen = document.getElementById('btn-gen-cron'); + const btnRevoke = document.getElementById('btn-revoke-cron'); + + if (cron.configured) { + el.innerHTML = ' Trigger key active (source: ' + escapeHtml(cron.source) + ')'; + let info = ''; + if (cron.createdAt) info += 'Created: ' + new Date(cron.createdAt).toLocaleString() + ''; + if (cron.lastUsedAt) info += (info ? ' · ' : '') + 'Last used: ' + new Date(cron.lastUsedAt).toLocaleString() + ''; + det.innerHTML = info ? '
' + info + '
' : ''; + + if (cron.source === 'env') { + urlBox.style.display = 'none'; + btnGen.style.display = 'none'; + btnRevoke.style.display = 'none'; + det.innerHTML += '
Managed via CRON_TRIGGER_TOKEN environment variable. Use your trigger URL: ' + escapeHtml(window.location.origin) + '/api/weather/refresh/trigger?key=YOUR_TOKEN
'; + } else { + // Don't show URL box on page load (can't reconstruct token from hash) + // Only show it right after generation + if (!urlBox.dataset.justGenerated) urlBox.style.display = 'none'; + btnGen.textContent = 'Rotate Key'; + btnGen.style.display = ''; + btnRevoke.style.display = ''; + } + } else { + el.innerHTML = ' No trigger key configured'; + det.innerHTML = '
Generate a key to enable automated weather refresh from cron services.
'; + urlBox.style.display = 'none'; + btnGen.textContent = 'Generate Key'; + btnGen.style.display = ''; + btnRevoke.style.display = 'none'; + } + } + + async function generateCronToken() { + if (!confirm('Generate a new trigger key? Any existing key will be replaced.')) return; + try { + const r = await fetch('/api/settings/cron-token', { method: 'POST', credentials: 'include' }); + if (!r.ok) { + const d = await r.json().catch(() => ({})); + toast(d.error || 'Failed to generate key', true); + return; + } + const d = await r.json(); + const url = window.location.origin + '/api/weather/refresh/trigger?key=' + d.token; + + // Show URL in input + const urlBox = document.getElementById('cron-url-box'); + document.getElementById('cron-url').value = url; + urlBox.dataset.justGenerated = '1'; + urlBox.style.display = 'block'; + + // Update status inline (avoid loadSettings wiping the URL) + document.getElementById('status-cron').innerHTML = ' Trigger key active (source: redis)'; + document.getElementById('cron-details').innerHTML = ''; + document.getElementById('btn-gen-cron').textContent = 'Rotate Key'; + document.getElementById('btn-revoke-cron').style.display = ''; + + // Auto-copy + try { await navigator.clipboard.writeText(url); toast('Key generated β€” URL copied to clipboard!'); } + catch (_) { toast('Key generated β€” copy the URL above'); } + } catch { toast('Failed to generate key', true); } + } + + async function revokeCronToken() { + if (!confirm('Revoke the trigger key? Cron jobs using it will stop working.')) return; + try { + const r = await fetch('/api/settings/cron-token', { method: 'DELETE', credentials: 'include' }); + if (!r.ok) { + const d = await r.json().catch(() => ({})); + toast(d.error || 'Failed to revoke key', true); + return; + } + document.getElementById('cron-url-box').style.display = 'none'; + delete document.getElementById('cron-url-box').dataset.justGenerated; + toast('Trigger key revoked'); + loadSettings(); + } catch { toast('Failed to revoke key', true); } + } + + function copyCronUrl() { + const url = document.getElementById('cron-url').value; + navigator.clipboard.writeText(url) + .then(() => toast('URL copied to clipboard')) + .catch(() => toast('Failed to copy', true)); + } + async function refreshWeather() { toast('Refreshing weather data…'); try { diff --git a/server/index.js b/server/index.js index a152a7f..02dde93 100644 --- a/server/index.js +++ b/server/index.js @@ -106,6 +106,13 @@ const refreshLimiter = rateLimit({ message: { error: "Too many refresh requests, please wait" }, }); +// Rate limit cron trigger (stricter β€” cron services only need a few calls per day) +const triggerLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 3, + message: { error: "Too many trigger requests" }, +}); + // Disable caching for API routes app.use("/api/*", (req, res, next) => { res.set({ @@ -279,6 +286,57 @@ app.get("/api/weather/summary", async (req, res) => { // ===== PUBLIC API: auth check ===== // (already defined above) +// ===== CRON TRIGGER (public β€” token-authenticated, not session-authenticated) ===== +const REDIS_KEY_CRON_TOKEN = "app:cron_trigger_token"; + +async function getCronTokenData() { + try { + const stored = await redis.get(REDIS_KEY_CRON_TOKEN); + if (stored) return stored; + } catch (_) {} + return null; +} + +app.post("/api/weather/refresh/trigger", triggerLimiter, async (req, res) => { + const key = typeof req.query.key === "string" ? req.query.key : ""; + if (!key) { + return res.status(401).json({ error: "Missing trigger key" }); + } + + // Env var takes priority (consistent with other API keys) + const envToken = process.env.CRON_TRIGGER_TOKEN; + if (envToken) { + const a = crypto.createHash("sha256").update(key).digest(); + const b = crypto.createHash("sha256").update(envToken).digest(); + if (!crypto.timingSafeEqual(a, b)) { + return res.status(401).json({ error: "Invalid trigger key" }); + } + } else { + // Check Redis-stored hash + const tokenData = await getCronTokenData(); + if (!tokenData || !tokenData.hash) { + return res.status(401).json({ error: "No trigger key configured" }); + } + const providedHash = crypto.createHash("sha256").update(key).digest("hex"); + if (providedHash.length !== tokenData.hash.length || + !crypto.timingSafeEqual(Buffer.from(providedHash), Buffer.from(tokenData.hash))) { + return res.status(401).json({ error: "Invalid trigger key" }); + } + // Update lastUsedAt + try { + await redis.set(REDIS_KEY_CRON_TOKEN, { ...tokenData, lastUsedAt: new Date().toISOString() }); + } catch (_) {} + } + + try { + await weatherService.getWeatherData(true); + res.json({ ok: true }); + } catch (error) { + console.error("Cron trigger refresh error:", error); + res.status(500).json({ error: "Refresh failed" }); + } +}); + // ===== PROTECTED ROUTES β€” everything below requires auth ===== app.use("/api/*", requireAuth); @@ -450,12 +508,14 @@ async function getTelescopiusKey() { // ===== AGGREGATE SETTINGS API ===== app.get("/api/settings", async (req, res) => { try { - const [loc, meteoblueKey, telescopiusKey, downloadStatus] = await Promise.all([ + const [loc, meteoblueKey, telescopiusKey, downloadStatus, cronTokenData] = await Promise.all([ getObserverLocation(), getMeteoblueKey(), getTelescopiusKey(), weatherService.getDownloadStatus(), + getCronTokenData(), ]); + const cronSource = process.env.CRON_TRIGGER_TOKEN ? "env" : (cronTokenData ? "redis" : "none"); res.json({ location: loc, apiKeys: { @@ -464,6 +524,12 @@ app.get("/api/settings", async (req, res) => { }, auth: { enabled: !!MASTER_PASSWORD }, download: downloadStatus, + cronTrigger: { + configured: !!process.env.CRON_TRIGGER_TOKEN || !!cronTokenData, + source: cronSource, + createdAt: cronTokenData?.createdAt || null, + lastUsedAt: cronTokenData?.lastUsedAt || null, + }, }); } catch (error) { console.error("Error getting settings:", error); @@ -471,6 +537,55 @@ app.get("/api/settings", async (req, res) => { } }); +// ===== CRON TOKEN MANAGEMENT ===== +app.get("/api/settings/cron-token", async (req, res) => { + try { + if (process.env.CRON_TRIGGER_TOKEN) { + return res.json({ configured: true, source: "env", createdAt: null, lastUsedAt: null }); + } + const data = await getCronTokenData(); + if (data) { + return res.json({ configured: true, source: "redis", createdAt: data.createdAt, lastUsedAt: data.lastUsedAt }); + } + res.json({ configured: false, source: "none" }); + } catch (error) { + res.json({ configured: false, source: "none" }); + } +}); + +app.post("/api/settings/cron-token", async (req, res) => { + try { + if (process.env.CRON_TRIGGER_TOKEN) { + return res.status(400).json({ error: "Token is managed via CRON_TRIGGER_TOKEN environment variable β€” cannot rotate from Settings" }); + } + const rawToken = crypto.randomBytes(32).toString("hex"); + const hash = crypto.createHash("sha256").update(rawToken).digest("hex"); + await redis.set(REDIS_KEY_CRON_TOKEN, { + hash, + createdAt: new Date().toISOString(), + lastUsedAt: null, + }); + // Return raw token ONCE β€” it cannot be retrieved again + res.json({ ok: true, token: rawToken }); + } catch (error) { + console.error("Error generating cron token:", error); + res.status(500).json({ error: "Failed to generate token" }); + } +}); + +app.delete("/api/settings/cron-token", async (req, res) => { + try { + if (process.env.CRON_TRIGGER_TOKEN) { + return res.status(400).json({ error: "Token is managed via CRON_TRIGGER_TOKEN environment variable β€” cannot revoke from Settings" }); + } + await redis.del(REDIS_KEY_CRON_TOKEN); + res.json({ ok: true }); + } catch (error) { + console.error("Error revoking cron token:", error); + res.status(500).json({ error: "Failed to revoke token" }); + } +}); + async function telescopiusFetch(endpoint, params) { const apiKey = await getTelescopiusKey(); if (!apiKey) {