Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 44 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

---

Expand Down Expand Up @@ -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=<random-64-char-hex>
```

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://<your-app-url>/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://<your-app-url>/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://<your-app-url>/api/weather/refresh/trigger?key=<your-token-value>`.

---

## ⏰ 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=<your-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

Expand Down Expand Up @@ -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)
Expand All @@ -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 |
Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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 |
Expand Down
115 changes: 115 additions & 0 deletions public/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,28 @@ <h2><span class="icon">🔑</span> API Keys</h2>
</div>
</div>

<!-- Automated Weather Refresh -->
<div class="card">
<h2><span class="icon">⏰</span> Automated Weather Refresh</h2>
<p style="font-size:0.85rem;color:#94a3b8;margin-bottom:14px">
Use the trigger URL below with a free cron service (like <a href="https://cron-job.org" target="_blank" rel="noopener" style="color:var(--accent)">cron-job.org</a>) to automatically refresh weather data on a schedule — so forecasts are always ready when you open the dashboard.
</p>
<div class="status-row" id="status-cron"></div>
<div id="cron-details" style="margin-top:6px"></div>
<div id="cron-url-box" style="display:none;margin-top:12px">
<label style="display:block;font-size:0.82rem;color:#94a3b8;margin-bottom:4px;text-transform:uppercase;letter-spacing:0.5px">Trigger URL (use as POST request in your cron service)</label>
<div style="display:flex;gap:8px;align-items:center">
<input type="text" id="cron-url" readonly style="flex:1;padding:8px 12px;border-radius:6px;border:1px solid #475569;background:#0f172a;color:#fff;font-size:0.82rem;font-family:'Courier New',monospace">
<button class="btn btn-secondary" onclick="copyCronUrl()" title="Copy URL">📋 Copy</button>
</div>
<p style="font-size:0.78rem;color:#f59e0b;margin-top:6px">⚠️ Copy this URL now — the key cannot be shown again after you leave this page.</p>
</div>
<div class="actions">
<button class="btn btn-primary" id="btn-gen-cron" onclick="generateCronToken()">Generate Key</button>
<button class="btn btn-secondary" id="btn-revoke-cron" onclick="revokeCronToken()" style="display:none">Revoke Key</button>
</div>
</div>

<!-- System Status -->
<div class="card">
<h2><span class="icon">📊</span> System Status</h2>
Expand Down Expand Up @@ -223,6 +245,9 @@ <h2><span class="icon">✨</span> Features</h2>
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');
Expand Down Expand Up @@ -328,6 +353,96 @@ <h2><span class="icon">✨</span> Features</h2>
} 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 = '<span class="status-dot ok"></span> Trigger key active <span class="status-source">(source: ' + escapeHtml(cron.source) + ')</span>';
let info = '';
if (cron.createdAt) info += 'Created: <span class="val">' + new Date(cron.createdAt).toLocaleString() + '</span>';
if (cron.lastUsedAt) info += (info ? ' &middot; ' : '') + 'Last used: <span class="val">' + new Date(cron.lastUsedAt).toLocaleString() + '</span>';
det.innerHTML = info ? '<div class="download-info">' + info + '</div>' : '';

if (cron.source === 'env') {
urlBox.style.display = 'none';
btnGen.style.display = 'none';
btnRevoke.style.display = 'none';
det.innerHTML += '<div style="font-size:0.82rem;color:#64748b;margin-top:6px">Managed via <code style="color:#94a3b8">CRON_TRIGGER_TOKEN</code> environment variable. Use your trigger URL: <code style="color:#94a3b8">' + escapeHtml(window.location.origin) + '/api/weather/refresh/trigger?key=YOUR_TOKEN</code></div>';
} 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 = '<span class="status-dot warn"></span> No trigger key configured';
det.innerHTML = '<div style="font-size:0.82rem;color:#64748b">Generate a key to enable automated weather refresh from cron services.</div>';
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 = '<span class="status-dot ok"></span> Trigger key active <span class="status-source">(source: redis)</span>';
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 {
Expand Down
Loading