diff --git a/backend/package-lock.json b/backend/package-lock.json index 120652a..4831a5e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1671,9 +1671,9 @@ } }, "node_modules/fast-json-stringify/node_modules/fast-uri": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.1.tgz", - "integrity": "sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.2.tgz", + "integrity": "sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==", "funding": [ { "type": "github", @@ -1696,9 +1696,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -1770,9 +1770,9 @@ } }, "node_modules/find-my-way": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", - "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1865,9 +1865,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -2592,9 +2592,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts index 0ec1887..2f94ffc 100644 --- a/backend/src/api/admin.ts +++ b/backend/src/api/admin.ts @@ -4,9 +4,7 @@ import * as sourcesDb from '../storage/db/sources.js'; import * as eventsDb from '../storage/db/events.js'; import * as articlesDb from '../storage/db/articles.js'; import * as categoriesDb from '../storage/db/categories.js'; -import * as stocksDb from '../storage/db/stocks.js'; -import * as bookmarksDb from '../storage/db/bookmarks.js'; -import * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js'; +import * as installedWidgetsDb from '../storage/db/installedWidgets.js'; import { clearSourceContent, reissueSourceContent, reissueArticle, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js'; import { totalStorageBytes } from '../storage/media/index.js'; import { OllamaProvider } from '../inference/ollama-provider.js'; @@ -16,11 +14,22 @@ import { logger, listLogs } from '../storage/db/logs.js'; import * as backlogStats from '../queue/backlogStats.js'; import * as ollamaStats from '../inference/stats.js'; import * as telegramClient from '../telegram/client.js'; -import { geocodeLocation } from '../weather/client.js'; -import { pollWeatherNow } from '../weather/poller.js'; -import { pollStocksNow } from '../stocks/poller.js'; -import { browseCurrencies, fetchCurrentLeague } from '../poe2/client.js'; -import { pollPoe2Now } from '../poe2/poller.js'; +import { loadedWidgets } from '../widgets/registry.js'; +import { installUploadedWidget } from '../widgets/install.js'; +import { uninstallWidget } from '../widgets/uninstall.js'; +import { swapLiveServer } from '../server.js'; +import type { GlobalSettings } from '../storage/db/types.js'; + +// Rebuilds and swaps in the live Fastify instance to pick up a widget's newly +// (de)registered routes — MUST run after the triggering request has already sent its +// response, never awaited inline in that handler, since the swap closes the very +// instance serving it (see widgets/install.ts's and uninstall.ts's doc comments for +// why — this dropped the response entirely when tried inline during testing). +function scheduleServerSwap(context: string) { + setImmediate(() => { + swapLiveServer().catch((err) => logger.error('server', `Route swap after ${context} failed: ${(err as Error).message}`)); + }); +} // Not part of GlobalSettings itself (nothing to persist) — computed fresh on every // settings read/write so the Retention tab's "currently using" line and usage bar @@ -44,24 +53,19 @@ export async function registerAdminRoutes(app: FastifyInstance) { } const before = settingsDb.getSettings(); const settings = withStorageUsed(settingsDb.updateSettings(body)); - if (body.weather) { - // Poll immediately rather than waiting for the next scheduler tick (up to 45 - // minutes) — the admin just changed the location/unit and expects to see it reflected. - pollWeatherNow().catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`)); - } if (body.widgets) { // Re-enabling a widget (see the Widgets tab) should show fresh data right away - // instead of waiting out its normal cadence (up to 45m/15m/1h) — scheduler.ts - // skips polling entirely while a widget is disabled, so there's nothing recent - // to fall back on otherwise. - if (body.widgets.weather && !before.widgets.weather) { - pollWeatherNow().catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`)); - } - if (body.widgets.stocks && !before.widgets.stocks) { - pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`)); - } - if (body.widgets.poe2 && !before.widgets.poe2) { - pollPoe2Now().catch((err) => logger.error('poe2', `Immediate poll failed: ${err.message}`)); + // instead of waiting out its normal cadence — scheduler.ts skips polling + // entirely while a widget is disabled, so there's nothing recent to fall back + // on otherwise. Generic over every loaded widget with a poll hook, rather than + // one hardcoded branch per widget. + for (const id of Object.keys(body.widgets) as (keyof GlobalSettings['widgets'])[]) { + if (body.widgets[id] && !before.widgets[id]) { + loadedWidgets + .get(id) + ?.poll?.run() + .catch((err) => logger.error(id, `Immediate poll failed: ${err.message}`)); + } } } return { ...settings, categoryPriority: categoriesDb.listCategories() }; @@ -298,107 +302,50 @@ export async function registerAdminRoutes(app: FastifyInstance) { return reply.code(200).send(telegramClient.getStatus()); }); - // --- Weather (config lives in global_settings — see PATCH /api/admin/settings above) --- - app.get('/api/admin/weather/geocode', async (req, reply) => { - const { query } = req.query as { query?: string }; - if (!query || !query.trim()) return reply.code(400).send({ error: 'query required' }); - try { - return await geocodeLocation(query.trim()); - } catch (err) { - return reply.code(502).send({ error: `Geocoding service unreachable: ${(err as Error).message}` }); - } + // Per-widget routes (weather geocode, stocks CRUD, bookmarks CRUD, poe2 browse/ + // watchlist) are registered by each widget's own plugin — see widgets/registry.ts's + // generic registerAdminRoutes loop in index.ts. What's left here is the + // upload/delete lifecycle for pluggable widgets themselves. + + // --- Pluggable widgets (upload/list/delete — see widgets/install.ts, uninstall.ts) --- + app.get('/api/admin/widgets', async () => installedWidgetsDb.listInstalled()); + + app.post('/api/admin/widgets', async (req, reply) => { + const { manifest, files } = req.body as { manifest?: unknown; files?: unknown }; + const result = await installUploadedWidget(manifest, files); + if (!result.ok) return reply.code(400).send({ error: result.error }); + reply.code(201).send({ id: result.id }); + if (result.needsServerSwap) scheduleServerSwap(`installing "${result.id}"`); }); - // --- Stocks --- - app.get('/api/admin/stocks', async () => stocksDb.listStockTickers()); - - app.post('/api/admin/stocks', async (req, reply) => { - const { label, symbol } = req.body as { label?: string; symbol?: string }; - if (!label || !label.trim() || !symbol || !symbol.trim()) { - return reply.code(400).send({ error: 'label and symbol are required' }); - } - const created = stocksDb.createStockTicker(label.trim(), symbol.trim()); - // Poll immediately rather than waiting for the next tick (up to 15 minutes) — cheap, - // and refreshes every existing ticker's price too. - pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`)); - return reply.code(201).send(created); - }); - - app.patch('/api/admin/stocks/:id', async (req, reply) => { + app.patch('/api/admin/widgets/:id', async (req, reply) => { const { id } = req.params as { id: string }; - const updated = stocksDb.updateStockTicker(id, req.body as any); - if (!updated) return reply.code(404).send({ error: 'not found' }); - return updated; - }); - - app.delete('/api/admin/stocks/:id', async (req, reply) => { - const { id } = req.params as { id: string }; - stocksDb.deleteStockTicker(id); - return reply.code(204).send(); - }); - - // --- Bookmarks --- - app.get('/api/admin/bookmarks', async () => bookmarksDb.listBookmarks()); - - app.post('/api/admin/bookmarks', async (req, reply) => { - const { name, url, isPrivate } = req.body as { name?: string; url?: string; isPrivate?: boolean }; - if (!name || !name.trim() || !url || !url.trim()) { - return reply.code(400).send({ error: 'name and url are required' }); + const { enabled } = req.body as { enabled?: boolean }; + const widget = installedWidgetsDb.getInstalled(id); + if (!widget) return reply.code(404).send({ error: 'not found' }); + if (typeof enabled === 'boolean') { + installedWidgetsDb.setEnabled(id, enabled); + // Re-enabling should show fresh data right away rather than waiting out the + // widget's own poll interval — same "immediate poll on enable" behavior the + // 4 built-ins get via PATCH /api/admin/settings above. + if (enabled && !widget.enabled) { + loadedWidgets + .get(id) + ?.poll?.run() + .catch((err) => logger.error(id, `Immediate poll failed: ${err.message}`)); + } } - const created = bookmarksDb.createBookmark(name.trim(), url.trim(), !!isPrivate); - return reply.code(201).send(created); + return installedWidgetsDb.getInstalled(id); }); - app.patch('/api/admin/bookmarks/:id', async (req, reply) => { + app.delete('/api/admin/widgets/:id', async (req, reply) => { const { id } = req.params as { id: string }; - const updated = bookmarksDb.updateBookmark(id, req.body as any); - if (!updated) return reply.code(404).send({ error: 'not found' }); - return updated; - }); - - app.delete('/api/admin/bookmarks/:id', async (req, reply) => { - const { id } = req.params as { id: string }; - bookmarksDb.deleteBookmark(id); - return reply.code(204).send(); - }); - - // --- PoE2 (league is always auto-detected, never admin-set — see poe2/poller.ts) --- - app.get('/api/admin/poe2/browse', async (_req, reply) => { - try { - const league = await fetchCurrentLeague(); - return await browseCurrencies(league.id); - } catch (err) { - return reply.code(502).send({ error: `poe.ninja unreachable: ${(err as Error).message}` }); - } - }); - - app.get('/api/admin/poe2/watchlist', async () => poe2WatchlistDb.listWatchlist()); - - app.post('/api/admin/poe2/watchlist', async (req, reply) => { - const { base, quote } = req.body as { - base?: { currencyId?: string; name?: string }; - quote?: { currencyId?: string; name?: string }; - }; - if (!base?.currencyId || !base?.name || !quote?.currencyId || !quote?.name) { - return reply.code(400).send({ error: 'base and quote currencies (currencyId, name) are required' }); - } - if (base.currencyId === quote.currencyId) { - return reply.code(400).send({ error: 'Base and quote currencies must be different' }); - } - const created = poe2WatchlistDb.addWatchlistEntry( - { currencyId: base.currencyId, name: base.name }, - { currencyId: quote.currencyId, name: quote.name } - ); - // Poll immediately rather than waiting for the next tick (up to 1 hour) — cheap, - // and refreshes every existing entry's rate too. - pollPoe2Now().catch((err) => logger.error('poe2', `Immediate poll failed: ${err.message}`)); - return reply.code(201).send(created); - }); - - app.delete('/api/admin/poe2/watchlist/:id', async (req, reply) => { - const { id } = req.params as { id: string }; - poe2WatchlistDb.removeWatchlistEntry(id); - return reply.code(204).send(); + const widget = installedWidgetsDb.getInstalled(id); + if (!widget) return reply.code(404).send({ error: 'not found' }); + if (widget.source === 'builtin') return reply.code(400).send({ error: 'built-in widgets cannot be deleted' }); + const hadRoutes = await uninstallWidget(id); + reply.code(204).send(); + if (hadRoutes) scheduleServerSwap(`deleting "${id}"`); }); // --- Logs --- diff --git a/backend/src/api/public.ts b/backend/src/api/public.ts index 1dc876b..1c2d689 100644 --- a/backend/src/api/public.ts +++ b/backend/src/api/public.ts @@ -4,9 +4,9 @@ import * as tagsDb from '../storage/db/tags.js'; import * as eventsDb from '../storage/db/events.js'; import * as categoriesDb from '../storage/db/categories.js'; import * as settingsDb from '../storage/db/settings.js'; -import * as stocksDb from '../storage/db/stocks.js'; -import * as bookmarksDb from '../storage/db/bookmarks.js'; -import * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js'; +import * as installedWidgetsDb from '../storage/db/installedWidgets.js'; +import { getKv } from '../storage/db/widgetKv.js'; +import type { WidgetReport } from '../widgets/report.js'; import { hasPrivateAccess } from './privateAccess.js'; export async function registerPublicRoutes(app: FastifyInstance) { @@ -76,27 +76,34 @@ export async function registerPublicRoutes(app: FastifyInstance) { return categories.filter((c) => !c.isPrivate); }); - // Per-widget enable flags + display order — see the admin panel's consolidated - // "Widgets" tab. Weather/Stocks/PoE2's backend pollers are also gated on these - // flags (see scheduler.ts); Sidebar.svelte renders in exactly this order. + // Per-widget enable flags + display order for the 4 built-ins — see the admin panel's + // consolidated "Widgets" tab. Weather/Stocks/PoE2's backend pollers are also gated on + // these flags (see scheduler.ts); Sidebar.svelte renders in exactly this order. The + // `pluggable` array lists enabled *uploaded* widgets separately (their ids aren't part + // of the closed weather|stocks|bookmarks|poe2 union `order` uses) — see + // widgets/registry.ts, Sidebar.svelte's GenericWidgetCard/DynamicWidgetSlot. app.get('/api/widgets', async () => { const { widgets, widgetOrder } = settingsDb.getSettings(); - return { ...widgets, order: widgetOrder }; + const pluggable = installedWidgetsDb + .listInstalled() + .filter((w) => w.source === 'uploaded' && w.enabled) + .map((w) => ({ id: w.id, displayName: w.displayName, frontendEntry: w.frontendEntry })); + return { ...widgets, order: widgetOrder, pluggable }; }); - // Sidebar widgets — see WeatherTab/StocksTab/BookmarksTab in the admin panel. - app.get('/api/weather', async () => settingsDb.getSettings().weather); - - app.get('/api/stocks', async () => stocksDb.listStockTickers()); - - app.get('/api/bookmarks', async (req) => { - const bookmarks = bookmarksDb.listBookmarks(); - if (hasPrivateAccess(req)) return bookmarks; - return bookmarks.filter((b) => !b.isPrivate); + // Generic live-data feed any widget (built-in or uploaded) can publish to via + // setKv(id, 'report', ...) in its own poll.run() — see widgets/report.ts. Registered + // once here rather than per-widget, so it works for a widget uploaded after this + // process started, with zero new route registration (Fastify refuses routes added + // after app.listen(), so this is what makes "poll live -> sidebar shows it live" work + // for an uploaded widget without a restart). + app.get('/api/widget/:id/report', async (req, reply) => { + const { id } = req.params as { id: string }; + if (!installedWidgetsDb.getInstalled(id)?.enabled) return reply.code(404).send(); + return { data: getKv(id, 'report') }; }); - app.get('/api/poe2', async () => { - const { leagueName, updatedAt } = settingsDb.getSettings().poe2; - return { leagueName, updatedAt, entries: poe2WatchlistDb.listWatchlist() }; - }); + // Per-widget public routes (GET /api/widget/weather, /stocks, /bookmarks, /poe2) are + // registered by each widget's own plugin — see widgets/registry.ts's generic + // registerPublicRoutes loop in index.ts. } diff --git a/backend/src/index.ts b/backend/src/index.ts index 6fbe6d2..e51250c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,23 +1,11 @@ -import Fastify from 'fastify'; -import cors from '@fastify/cors'; -import cookie from '@fastify/cookie'; -import fs from 'node:fs'; -import path from 'node:path'; import { migrate } from './storage/db/index.js'; import { ADMIN_API_KEY } from './api/apiKey.js'; -import { registerAuth } from './api/auth.js'; -import { registerPublicRoutes } from './api/public.js'; -import { registerAdminRoutes } from './api/admin.js'; -import { registerMediaProxy } from './api/mediaProxy.js'; -import { registerTelegramMediaProxy } from './api/telegramMediaProxy.js'; -import { registerPrivateAccess, privateAccessConfigured } from './api/privateAccess.js'; +import { privateAccessConfigured } from './api/privateAccess.js'; import { startScheduler } from './queue/scheduler.js'; import { initFromSavedSession } from './telegram/client.js'; import { logger } from './storage/db/logs.js'; - -const PORT = Number(process.env.PORT) || 4000; -const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173'; -const MEDIA_DIR = process.env.MEDIA_DIR || './data/media'; +import { loadAllWidgets } from './widgets/registry.js'; +import { reloadServerRoutes } from './server.js'; function printApiKeyBanner() { const line = '='.repeat(64); @@ -27,7 +15,9 @@ function printApiKeyBanner() { console.log(`\n${line}`); console.log(' Homefeed admin API key (required for every /api/admin/* request)'); console.log(` ${ADMIN_API_KEY}`); - console.log(' This key is generated fresh on every restart — it will not be the same next time.'); + console.log(' This key is generated fresh on every process restart — it will not be'); + console.log(' the same next time. Installing/deleting a widget does NOT restart the'); + console.log(' process (see server.ts) and does not change this key.'); console.log(`${line}\n`); } @@ -35,74 +25,8 @@ async function main() { migrate(); printApiKeyBanner(); await initFromSavedSession(); - - const app = Fastify({ logger: false }); - - // Cross-origin is expected — see project-structure.md "Cross-origin and security - // implications". Not a wildcard: only the configured frontend origin is allowed. - // @fastify/cors defaults to GET/HEAD/POST only — without an explicit methods list, - // every PATCH (settings saves) and DELETE (removing sources/events) gets silently - // blocked by the browser at the CORS preflight stage, before the request ever - // reaches a route handler. - // credentials: true is required for the browser to send/accept the private-category - // login cookie cross-origin — safe only because origin is a specific value above, - // never a wildcard (the two are mutually exclusive per the CORS spec anyway). - await app.register(cors, { - origin: FRONTEND_ORIGIN, - credentials: true, - methods: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT', 'OPTIONS'] - }); - - await app.register(cookie); - - // Overrides Fastify's default JSON body parser, which throws "Body cannot be empty - // when content-type is set to 'application/json'" for any bodyless request (DELETE, - // or POST with no payload) that still carries a Content-Type header — exactly what - // browsers' fetch() does when a client sets that header unconditionally. An empty - // body is just as valid as `{}` for routes that don't read req.body at all. - app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => { - if (typeof body !== 'string' || body.trim() === '') return done(null, {}); - try { - done(null, JSON.parse(body)); - } catch (err) { - done(err as Error, undefined); - } - }); - - await registerAuth(app); - await registerPublicRoutes(app); - await registerAdminRoutes(app); - await registerPrivateAccess(app); - - // Fastify's own logger is off (see below) — without this, an unhandled exception - // in any route handler produces a bare 500 with zero trace anywhere, including the - // admin panel's own Logs tab. This is what "Save failed" with no log entry was. - app.setErrorHandler((err: Error & { statusCode?: number }, req, reply) => { - logger.error('server', `${req.method} ${req.url} failed: ${err.message}`); - reply.code(err.statusCode ?? 500).send({ error: err.message }); - }); - - // Locally hosted media (see storage/media) — served directly rather than via a - // heavier static-file plugin, since this is a small, flat directory. - app.get('/media/:filename', async (req, reply) => { - const { filename } = req.params as { filename: string }; - if (filename.includes('..') || filename.includes('/')) return reply.code(400).send(); - const filePath = path.join(MEDIA_DIR, filename); - if (!fs.existsSync(filePath)) return reply.code(404).send(); - return reply.send(fs.createReadStream(filePath)); - }); - - // Static "/media/proxy" and "/media/telegram-proxy" take priority over the - // "/media/:filename" param route above regardless of registration order - // (find-my-way, Fastify's router, always prefers a static segment over a parametric - // one at the same depth). - await registerMediaProxy(app); - await registerTelegramMediaProxy(app); - - app.get('/health', async () => ({ ok: true })); - - await app.listen({ port: PORT, host: '0.0.0.0' }); - logger.info('server', `Listening on :${PORT} (frontend origin: ${FRONTEND_ORIGIN})`); + await loadAllWidgets(); + await reloadServerRoutes(); if (!privateAccessConfigured()) { logger.info('server', 'Private categories disabled — set PRIVATE_ACCESS_PASSWORD to enable'); } diff --git a/backend/src/poe2/poller.ts b/backend/src/poe2/poller.ts deleted file mode 100644 index e6cd503..0000000 --- a/backend/src/poe2/poller.ts +++ /dev/null @@ -1,63 +0,0 @@ -import * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js'; -import * as settingsDb from '../storage/db/settings.js'; -import { logger } from '../storage/db/logs.js'; -import { fetchCurrentLeague, fetchCurrencyValues } from './client.js'; - -const DAY_MS = 24 * 60 * 60_000; - -function pctChange(current: number, past: number | null): number | null { - if (past === null || past === 0) return null; - return ((current - past) / past) * 100; -} - -// Called on a schedule (see queue/scheduler.ts) and immediately after the admin adds a pair -// (see api/admin.ts) — always re-detects the current challenge league fresh (cheap, guarantees -// correctness across league rotations with no separate staleness logic), then one overview -// request covers the whole watchlist. A pair whose base or quote currency is no longer traded -// this league gets its own lastError, it never aborts the rest of the batch. -export async function pollPoe2Now(): Promise { - let league; - try { - league = await fetchCurrentLeague(); - } catch (err) { - logger.error('poe2', `League lookup failed: ${(err as Error).message}`); - return; - } - - const entries = poe2WatchlistDb.listWatchlist(); - if (entries.length === 0) { - const { poe2 } = settingsDb.getSettings(); - settingsDb.updateSettings({ - poe2: { ...poe2, leagueId: league.id, leagueName: league.name, updatedAt: new Date().toISOString() } - }); - return; - } - - try { - const valuesById = await fetchCurrencyValues(league.id); - const now = new Date(); - const nowIso = now.toISOString(); - const cutoff24h = new Date(now.getTime() - DAY_MS).toISOString(); - - for (const entry of entries) { - const baseValue = valuesById.get(entry.baseCurrencyId); - const quoteValue = valuesById.get(entry.quoteCurrencyId); - if (baseValue === undefined || quoteValue === undefined) { - poe2WatchlistDb.markPolled(entry.id, null, null, 'One or both currencies no longer traded in this league'); - continue; - } - - const rate = baseValue / quoteValue; - const change24h = pctChange(rate, poe2WatchlistDb.rateAtOrBefore(entry.id, cutoff24h)); - poe2WatchlistDb.recordRate(entry.id, rate, nowIso); - poe2WatchlistDb.markPolled(entry.id, rate, change24h, null); - } - - poe2WatchlistDb.pruneOldHistory(); - settingsDb.updateSettings({ - poe2: { leagueId: league.id, leagueName: league.name, updatedAt: nowIso } - }); - } catch (err) { - logger.error('poe2', `Watchlist poll failed: ${(err as Error).message}`); - } -} diff --git a/backend/src/queue/scheduler.ts b/backend/src/queue/scheduler.ts index b790bfe..ac931bb 100644 --- a/backend/src/queue/scheduler.ts +++ b/backend/src/queue/scheduler.ts @@ -4,18 +4,15 @@ import { runEventRecaps } from './eventsRecap.js'; import { runRetentionSweep } from './retention.js'; import { OllamaProvider } from '../inference/ollama-provider.js'; import * as settingsDb from '../storage/db/settings.js'; +import * as installedWidgetsDb from '../storage/db/installedWidgets.js'; import { logger } from '../storage/db/logs.js'; -import { pollWeatherNow } from '../weather/poller.js'; -import { pollStocksNow } from '../stocks/poller.js'; -import { pollPoe2Now } from '../poe2/poller.js'; +import { loadedWidgets } from '../widgets/registry.js'; +import type { WidgetPlugin } from '../widgets/types.js'; const POLL_TICK_MS = 60_000; // checks which sources are due every minute; each source's own interval governs actual fetch frequency const DIRECT_PUBLISH_TICK_MS = 60_000; const SYNTHESIS_TICK_MS = 60_000; const RETENTION_TICK_MS = 60 * 60_000; // hourly -const WEATHER_TICK_MS = 45 * 60_000; -const STOCKS_TICK_MS = 15 * 60_000; // per admin spec — stock prices move faster than weather -const POE2_TICK_MS = 60 * 60_000; // poe.ninja's own overview data doesn't refresh faster than hourly, so polling more often than this just re-fetches the same numbers /** * Runs fn on every tick, but skips a tick outright if the previous one is still in @@ -46,6 +43,40 @@ function everyTickSkippingOverlap(ms: number, fn: () => Promise) { }, ms); } +// Per-widget setInterval handles, keyed by widget id — lets a single widget's polling be +// started/stopped independently (on live upload/delete, or an enable toggle) without +// touching any other widget's interval. Exported so widgets/install.ts and +// widgets/uninstall.ts can drive it directly. +export const widgetIntervals = new Map(); + +// Starts (or re-starts) polling for one widget — an immediate poll if it's currently +// enabled (unlike RSS sources, whose "due" check makes a brand-new source eligible on the +// very next 1-minute tick, a widget has no such shortcut; without this the sidebar would +// sit empty for up to a full poll interval after every restart or fresh install), then a +// recurring interval that re-checks the enabled flag on every tick — so disabling a widget +// stops the actual external polling, not just hides it in the sidebar. +export function startWidgetPolling(plugin: WidgetPlugin) { + if (!plugin.poll) return; + stopWidgetPolling(plugin.id); + + if (installedWidgetsDb.getInstalled(plugin.id)?.enabled) { + plugin.poll.run().catch((err) => logger.error(plugin.id, `Initial poll failed: ${(err as Error).message}`)); + } + const handle = setInterval(() => { + if (!installedWidgetsDb.getInstalled(plugin.id)?.enabled) return; + plugin.poll!.run().catch((err) => logger.error(plugin.id, `Poll tick failed: ${(err as Error).message}`)); + }, plugin.poll.intervalMs); + widgetIntervals.set(plugin.id, handle); +} + +export function stopWidgetPolling(id: string) { + const handle = widgetIntervals.get(id); + if (handle) { + clearInterval(handle); + widgetIntervals.delete(id); + } +} + export function startScheduler() { const provider = () => { const s = settingsDb.getSettings(); @@ -115,40 +146,12 @@ export function startScheduler() { } }); - // Immediate first call for all three — unlike RSS sources (whose "due" check makes a - // brand-new source eligible on the very next 1-minute tick), weather/stocks/poe2 have - // no such shortcut; without this the sidebar is empty for up to 45/15/60 minutes after - // every restart. Each is also gated on its Widgets-tab enabled flag (see - // admin/settings' consolidated Widgets tab) — disabling a widget stops these external - // calls entirely rather than just hiding the sidebar box, so there's no pointless - // polling for something nobody's looking at. Re-enabling it triggers an immediate - // poll instead (see admin.ts's PATCH /api/admin/settings), same as this initial call. - if (settingsDb.getSettings().widgets.weather) { - pollWeatherNow().catch((err) => logger.error('weather', `Initial poll failed: ${err.message}`)); + for (const plugin of loadedWidgets.values()) { + startWidgetPolling(plugin); } - setInterval(() => { - if (!settingsDb.getSettings().widgets.weather) return; - pollWeatherNow().catch((err) => logger.error('weather', `Poll tick failed: ${err.message}`)); - }, WEATHER_TICK_MS); - - if (settingsDb.getSettings().widgets.stocks) { - pollStocksNow().catch((err) => logger.error('stocks', `Initial poll failed: ${err.message}`)); - } - setInterval(() => { - if (!settingsDb.getSettings().widgets.stocks) return; - pollStocksNow().catch((err) => logger.error('stocks', `Poll tick failed: ${err.message}`)); - }, STOCKS_TICK_MS); - - if (settingsDb.getSettings().widgets.poe2) { - pollPoe2Now().catch((err) => logger.error('poe2', `Initial poll failed: ${err.message}`)); - } - setInterval(() => { - if (!settingsDb.getSettings().widgets.poe2) return; - pollPoe2Now().catch((err) => logger.error('poe2', `Poll tick failed: ${err.message}`)); - }, POE2_TICK_MS); logger.info( 'scheduler', - 'Started: poll every 1m, direct-publish every 1m, synthesis every 1m, retention every 1h, weather every 45m, stocks every 15m, poe2 every 1h' + `Started: poll every 1m, direct-publish every 1m, synthesis every 1m, retention every 1h, ${loadedWidgets.size} widget(s) polling on their own intervals` ); } diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..3b01a44 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,178 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import cors from '@fastify/cors'; +import cookie from '@fastify/cookie'; +import fs from 'node:fs'; +import path from 'node:path'; +import { registerAuth } from './api/auth.js'; +import { registerPublicRoutes } from './api/public.js'; +import { registerAdminRoutes } from './api/admin.js'; +import { registerMediaProxy } from './api/mediaProxy.js'; +import { registerTelegramMediaProxy } from './api/telegramMediaProxy.js'; +import { registerPrivateAccess } from './api/privateAccess.js'; +import { loadedWidgets } from './widgets/registry.js'; +import { logger } from './storage/db/logs.js'; + +const PORT = Number(process.env.PORT) || 4000; +const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'http://localhost:5173'; +const MEDIA_DIR = process.env.MEDIA_DIR || './data/media'; +const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed'; + +let currentApp: FastifyInstance | null = null; + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + + // Cross-origin is expected — see project-structure.md "Cross-origin and security + // implications". Not a wildcard: only the configured frontend origin is allowed. + // @fastify/cors defaults to GET/HEAD/POST only — without an explicit methods list, + // every PATCH (settings saves) and DELETE (removing sources/events) gets silently + // blocked by the browser at the CORS preflight stage, before the request ever + // reaches a route handler. + // credentials: true is required for the browser to send/accept the private-category + // login cookie cross-origin — safe only because origin is a specific value above, + // never a wildcard (the two are mutually exclusive per the CORS spec anyway). + await app.register(cors, { + origin: FRONTEND_ORIGIN, + credentials: true, + methods: ['GET', 'POST', 'PATCH', 'DELETE', 'PUT', 'OPTIONS'] + }); + + await app.register(cookie); + + // Overrides Fastify's default JSON body parser, which throws "Body cannot be empty + // when content-type is set to 'application/json'" for any bodyless request (DELETE, + // or POST with no payload) that still carries a Content-Type header — exactly what + // browsers' fetch() does when a client sets that header unconditionally. An empty + // body is just as valid as `{}` for routes that don't read req.body at all. + app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => { + if (typeof body !== 'string' || body.trim() === '') return done(null, {}); + try { + done(null, JSON.parse(body)); + } catch (err) { + done(err as Error, undefined); + } + }); + + await registerAuth(app); + await registerPublicRoutes(app); + await registerAdminRoutes(app); + await registerPrivateAccess(app); + + // Each loaded widget (built-in or uploaded — see widgets/registry.ts) registers its + // own routes here rather than being hardcoded into public.ts/admin.ts. Runs after + // registerAuth so any /api/admin/* route a widget registers is gated by the same + // X-Api-Key preHandler automatically. + for (const plugin of loadedWidgets.values()) { + plugin.registerPublicRoutes?.(app); + plugin.registerAdminRoutes?.(app); + } + + // Fastify's own logger is off (see below) — without this, an unhandled exception + // in any route handler produces a bare 500 with zero trace anywhere, including the + // admin panel's own Logs tab. This is what "Save failed" with no log entry was. + app.setErrorHandler((err: Error & { statusCode?: number }, req, reply) => { + logger.error('server', `${req.method} ${req.url} failed: ${err.message}`); + reply.code(err.statusCode ?? 500).send({ error: err.message }); + }); + + // Locally hosted media (see storage/media) — served directly rather than via a + // heavier static-file plugin, since this is a small, flat directory. + app.get('/media/:filename', async (req, reply) => { + const { filename } = req.params as { filename: string }; + if (filename.includes('..') || filename.includes('/')) return reply.code(400).send(); + const filePath = path.join(MEDIA_DIR, filename); + if (!fs.existsSync(filePath)) return reply.code(404).send(); + return reply.send(fs.createReadStream(filePath)); + }); + + // A widget's optional pre-built frontend bundle (see widgets/manifest.ts's + // frontendEntry) — one generic wildcard route rather than one per widget, so it works + // for a widget uploaded after this process started, with no restart (unlike a + // widget's own custom API routes, which need reloadServerRoutes() below to activate). + // Explicit Content-Type is required here (unlike /media/:filename above) — browsers + // reject a dynamically-imported module whose response isn't served as a JS MIME + // type. The wildcard also lets a bundle's own relative imports (e.g. `import + // './helper.mjs'`) resolve automatically, since the browser requests those against + // this same route. + app.get('/widget-assets/:id/*', async (req, reply) => { + const { id } = req.params as { id: string }; + const rel = (req.params as { '*': string })['*']; + if (rel.includes('..')) return reply.code(400).send(); + const filePath = path.join(WIDGETS_INSTALLED_DIR, id, rel); + if (!fs.existsSync(filePath)) return reply.code(404).send(); + if (rel.endsWith('.mjs') || rel.endsWith('.js')) reply.type('text/javascript'); + else if (rel.endsWith('.css')) reply.type('text/css'); + return reply.send(fs.createReadStream(filePath)); + }); + + // Static "/media/proxy" and "/media/telegram-proxy" take priority over the + // "/media/:filename" param route above regardless of registration order + // (find-my-way, Fastify's router, always prefers a static segment over a parametric + // one at the same depth). + await registerMediaProxy(app); + await registerTelegramMediaProxy(app); + + app.get('/health', async () => ({ ok: true })); + + return app; +} + +/** + * Builds a fresh Fastify instance with every currently loaded widget's routes and swaps + * it in for the running one — the only way to pick up a route a live-uploaded widget + * declares, since Fastify refuses to add routes to an already-listening instance (throws + * "instance is already listening" synchronously). Called once at process startup (with no + * previous instance to close) and again after any widget install/uninstall that changes + * the route set (see widgets/install.ts, uninstall.ts). + * + * Deliberately reuses everything else already live in this process — the DB connection, + * the in-memory widget registry, the scheduler's setInterval loops, the Telegram client's + * session, and the admin API key all stay untouched. Only the HTTP server + its router are + * rebuilt, which is what makes this meaningfully better than a full process restart: none + * of that state is lost, and in particular the admin API key (regenerated only on true + * process start) stays valid, so installing a widget never logs the admin out. + * + * Split into two steps rather than one, because of a real deadlock/dropped-response bug + * hit in testing: the install/delete admin routes that trigger a reload are themselves + * served BY the live app instance. Awaiting the full swap (which closes that very instance) + * from inside its own still-executing request handler closed the connection before the + * response could be flushed — the client saw a bare connection reset, not a 204/201. + * + * validateRoutesBuildable() never touches the live server at all (builds on an OS-assigned + * ephemeral port and closes it again), so it's safe to await synchronously inside a + * request handler — that's what lets a broken widget's install be rejected/rolled back in + * the same response. swapLiveServer() is the part that actually closes the current + * instance; callers that are themselves inside a request handler for the live instance + * MUST defer this past sending their response (e.g. via setImmediate — see + * api/admin.ts's widget install/delete routes). Boot-time startup (see index.ts) has no + * in-flight request to worry about, so it just awaits both in sequence via + * reloadServerRoutes() below. + */ +export async function validateRoutesBuildable(): Promise { + const candidate = await buildApp(); + await candidate.listen({ port: 0, host: '127.0.0.1' }); + await candidate.close(); +} + +export async function swapLiveServer(): Promise { + const oldApp = currentApp; + // The new instance binds the same fixed PORT the old one holds, so the old one has to + // let go of it first — there's a brief window (typically well under a second) where + // nothing is listening on PORT. Acceptable for a self-hosted single-admin tool where + // this only fires right after an admin's own widget install/delete action. + if (oldApp) await oldApp.close(); + const newApp = await buildApp(); + await newApp.listen({ port: PORT, host: '0.0.0.0' }); + currentApp = newApp; + + logger.info( + 'server', + `Listening on :${PORT} (frontend origin: ${FRONTEND_ORIGIN}) — ${loadedWidgets.size} widget(s) registered` + ); +} + +/** Boot-time convenience — validate then swap in one call. Only safe when there's no in-flight request being served by the instance being replaced (i.e. process startup). */ +export async function reloadServerRoutes(): Promise { + await validateRoutesBuildable(); + await swapLiveServer(); +} diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts index d16ad59..8253932 100644 --- a/backend/src/storage/db/index.ts +++ b/backend/src/storage/db/index.ts @@ -18,6 +18,25 @@ db.exec('PRAGMA journal_mode = WAL;'); db.exec('PRAGMA foreign_keys = ON;'); export function migrate() { + const tableExists = (name: string) => + !!db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`).get(name); + + // Renames oldName to newName, but only if newName isn't already taken — a DB that's + // been through an interrupted migration, or an earlier build of the widget system + // that created the widget__-named table directly (e.g. via plugin.migrate()'s + // CREATE TABLE IF NOT EXISTS running before this rename ever got a chance to), can + // end up with both names present. In that case the widget__-named table is the + // one actually in use (every read/write goes through it), so the old-named leftover + // is just dropped rather than failing outright on the name collision. + const renameTableIfSafe = (oldName: string, newName: string) => { + if (!tableExists(oldName)) return; + if (tableExists(newName)) { + db.exec(`DROP TABLE "${oldName}";`); + return; + } + db.exec(`ALTER TABLE "${oldName}" RENAME TO "${newName}";`); + }; + // Admin auth moved from username/password + sessions to a per-launch API key (see // api/apiKey.ts, api/auth.ts) — these tables, and any stored password hash or live // session in them, have no further purpose and are dropped rather than left as @@ -33,6 +52,26 @@ export function migrate() { db.exec('DROP TABLE IF EXISTS poe2_watchlist;'); } + // PoE2 moved into the pluggable-widget system (backend/src/widgets/poe2/) and its + // schema now follows the widget__ naming convention its plugin.migrate() owns — + // a plain rename (metadata-only in SQLite, no row copy) rather than grandfathering + // the old names in via an exceptions list, since it's cheap and PoE2 is meant to be + // the reference implementation of the convention it establishes. + renameTableIfSafe('poe2_watchlist', 'widget_poe2_watchlist'); + renameTableIfSafe('poe2_rate_history', 'widget_poe2_rate_history'); + db.exec('DROP INDEX IF EXISTS idx_poe2_rate_history_watchlist;'); + + // Stocks and Bookmarks moved into the pluggable-widget system too (backend/src/widgets/ + // stocks/, widgets/bookmarks/) — same plain-rename treatment as PoE2 above, now owned by + // each widget's own plugin.migrate() instead of this file's CREATE TABLE block. + renameTableIfSafe('stock_tickers', 'widget_stocks_tickers'); + // "bookmarks" is generic enough to collide with something else entirely — check for the + // old bookmarks shape specifically (its distinctive is_private column) before renaming. + const oldBookmarksCols = db.prepare(`PRAGMA table_info(bookmarks)`).all() as { name: string }[]; + if (oldBookmarksCols.some((c) => c.name === 'is_private')) { + renameTableIfSafe('bookmarks', 'widget_bookmarks_items'); + } + db.exec(` CREATE TABLE IF NOT EXISTS sources ( id TEXT PRIMARY KEY, @@ -215,60 +254,32 @@ export function migrate() { poe2_updated_at TEXT ); - -- Sidebar "Stocks" widget — polled every 15 minutes from Yahoo Finance (see - -- stocks/poller.ts). Price/change/poll-state live directly on the row, same as - -- sources.last_polled_at, rather than a separate quote-cache table. - CREATE TABLE IF NOT EXISTS stock_tickers ( - id TEXT PRIMARY KEY, - label TEXT NOT NULL, - symbol TEXT NOT NULL, -- Yahoo symbol syntax, e.g. "^DJI", "AAPL", "BTC-USD" - priority_rank INTEGER NOT NULL, - last_price REAL, - last_change_percent REAL, - last_polled_at TEXT, - last_error TEXT, - created_at TEXT NOT NULL + -- Generic config/cache store for pluggable widgets (see widgets/types.ts, + -- storage/db/widgetKv.ts) — lets a widget stay fully prunable by widget_id alone on + -- uninstall without a bespoke table for simple key/value state. + CREATE TABLE IF NOT EXISTS widget_kv ( + widget_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, -- JSON + updated_at TEXT NOT NULL, + PRIMARY KEY (widget_id, key) ); - -- Sidebar "PoE2" widget — tracks exchange rates between arbitrary currency pairs - -- (see poe2/poller.ts), always against the current challenge league (auto-detected, - -- no admin config). Rate is "1 base = last_rate quote"; both currencies' names are - -- captured at add-time from the browse picker, not re-resolved. No icon columns — - -- the UI doesn't display them. - CREATE TABLE IF NOT EXISTS poe2_watchlist ( + -- Registry of installed sidebar widgets, both built-in (weather/stocks/bookmarks, + -- and poe2 as the pluggable reference implementation) and uploaded (see + -- widgets/registry.ts, widgets/install.ts). Replaces the old closed + -- widgets/widgetOrder unions on global_settings — see storage/db/settings.ts. + CREATE TABLE IF NOT EXISTS installed_widgets ( id TEXT PRIMARY KEY, - base_currency_id TEXT NOT NULL, -- opaque id from the exchange overview's lines[].id, e.g. "exalted" - base_name TEXT NOT NULL, - quote_currency_id TEXT NOT NULL, - quote_name TEXT NOT NULL, + display_name TEXT NOT NULL, + source TEXT NOT NULL, -- 'builtin' | 'uploaded' + code_path TEXT NOT NULL, -- builtin: identifying module path segment; uploaded: on-disk install dir under ./data/ + enabled INTEGER NOT NULL DEFAULT 1, priority_rank INTEGER NOT NULL, - last_rate REAL, - last_change_24h REAL, - last_polled_at TEXT, - last_error TEXT, - created_at TEXT NOT NULL - ); - - -- Per-poll rate snapshots for the pairs above — poe.ninja doesn't expose a matching - -- 24h change window, so it's computed ourselves from this history (see - -- poe2/poller.ts). Pruned to the last 2 days on every poll. - CREATE TABLE IF NOT EXISTS poe2_rate_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - watchlist_id TEXT NOT NULL, - rate REAL NOT NULL, - recorded_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_poe2_rate_history_watchlist ON poe2_rate_history(watchlist_id, recorded_at); - - -- Sidebar "Bookmarks" widget — admin-curated links, each independently hidden/public - -- via is_private (same private-access lock feature as categories.is_private). - CREATE TABLE IF NOT EXISTS bookmarks ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - url TEXT NOT NULL, - priority_rank INTEGER NOT NULL, - is_private INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL + version TEXT NOT NULL DEFAULT '1.0.0', + owned_tables TEXT NOT NULL DEFAULT '[]', -- JSON array, self-reported by the plugin — used by the uninstall safety-net sweep + frontend_entry TEXT, -- relative path to an optional pre-built browser JS bundle, served from /widget-assets//* + installed_at TEXT NOT NULL ); -- Singleton row (see storage/crypto.ts) — encrypted Telegram API credentials and @@ -389,22 +400,8 @@ export function migrate() { `ALTER TABLE global_settings ADD COLUMN widget_order TEXT NOT NULL DEFAULT '["weather","stocks","poe2","bookmarks"]'` ); } - - // Seed a handful of sensible default tickers so the Stocks widget isn't empty on a - // fresh install — the admin can remove/replace any of them via the Stocks tab. - const tickerCount = db.prepare('SELECT COUNT(*) as c FROM stock_tickers').get() as { c: number }; - if (tickerCount.c === 0) { - const defaults: [string, string][] = [ - ['Dow Jones', '^DJI'], - ['S&P 500', '^GSPC'], - ['Bitcoin', 'BTC-USD'] - ]; - const stmt = db.prepare( - 'INSERT INTO stock_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)' - ); - defaults.forEach(([label, symbol], i) => { - stmt.run(`stk-${symbol.replace(/[^a-z0-9]+/gi, '-')}`, label, symbol, i + 1, new Date().toISOString()); - }); + if (!hasColumn('installed_widgets', 'frontend_entry')) { + db.exec('ALTER TABLE installed_widgets ADD COLUMN frontend_entry TEXT'); } if (!hasColumn('global_settings', 'synthesis_style_preset')) { db.exec("ALTER TABLE global_settings ADD COLUMN synthesis_style_preset TEXT NOT NULL DEFAULT 'default'"); @@ -445,4 +442,94 @@ export function migrate() { ); } } + + // One-time seed of the installed_widgets registry from whatever the pre-registry + // install had (upgrade path) or sensible defaults (fresh install) — see + // widgets/registry.ts, storage/db/installedWidgets.ts. Raw SQL rather than importing + // installedWidgets.ts here to avoid a circular import (it imports `db` from this + // file). + const widgetCount = db.prepare('SELECT COUNT(*) as c FROM installed_widgets').get() as { c: number }; + if (widgetCount.c === 0) { + const settingsRow = db.prepare('SELECT * FROM global_settings WHERE id = 1').get() as any; + const order: string[] = settingsRow + ? JSON.parse(settingsRow.widget_order ?? '["weather","stocks","poe2","bookmarks"]') + : ['weather', 'stocks', 'poe2', 'bookmarks']; + const displayNames: Record = { weather: 'Weather', stocks: 'Stocks', bookmarks: 'Bookmarks', poe2: 'PoE2' }; + const codePaths: Record = { + weather: 'widgets/weather', + stocks: 'widgets/stocks', + bookmarks: 'widgets/bookmarks', + poe2: 'widgets/poe2' + }; + const ownedTables: Record = { poe2: ['widget_poe2_watchlist', 'widget_poe2_rate_history'] }; + const enabledOf = (id: string) => (settingsRow ? !!settingsRow[`widget_${id}_enabled`] : true); + const insertWidget = db.prepare( + `INSERT INTO installed_widgets (id, display_name, source, code_path, enabled, priority_rank, owned_tables, installed_at) + VALUES (?, ?, 'builtin', ?, ?, ?, ?, ?)` + ); + const installedAt = new Date().toISOString(); + order.forEach((id, i) => { + insertWidget.run( + id, + displayNames[id] ?? id, + codePaths[id] ?? id, + enabledOf(id) ? 1 : 0, + i + 1, + JSON.stringify(ownedTables[id] ?? []), + installedAt + ); + }); + } + + // PoE2's league cache (previously bare global_settings columns) moves into the + // generic widget_kv store so it's prunable via the same deleteAllKv('poe2') path as + // everything else the widget owns, rather than needing bespoke column-nulling logic + // on uninstall. One-time copy, guarded on the widget_kv row not already existing. + const poe2CacheRow = db.prepare('SELECT poe2_league_id, poe2_league_name, poe2_updated_at FROM global_settings WHERE id = 1').get() as + | { poe2_league_id: string | null; poe2_league_name: string | null; poe2_updated_at: string | null } + | undefined; + const hasPoe2Kv = db.prepare("SELECT 1 FROM widget_kv WHERE widget_id = 'poe2' AND key = 'leagueCache'").get(); + if (poe2CacheRow?.poe2_league_id && !hasPoe2Kv) { + db.prepare( + `INSERT INTO widget_kv (widget_id, key, value, updated_at) VALUES ('poe2', 'leagueCache', ?, ?)` + ).run( + JSON.stringify({ + leagueId: poe2CacheRow.poe2_league_id, + leagueName: poe2CacheRow.poe2_league_name, + updatedAt: poe2CacheRow.poe2_updated_at + }), + new Date().toISOString() + ); + } + + // Weather's config (admin-set location/units) and cache (last-polled forecast) move off + // their bare global_settings columns into two widget_kv entries — same "every widget + // owns its own data" consistency as PoE2's league cache above. Old columns left in + // place, unread. + const weatherRow = db.prepare('SELECT * FROM global_settings WHERE id = 1').get() as any; + const hasWeatherConfigKv = db.prepare("SELECT 1 FROM widget_kv WHERE widget_id = 'weather' AND key = 'config'").get(); + if (weatherRow?.weather_location_name && !hasWeatherConfigKv) { + const nowIso = new Date().toISOString(); + db.prepare(`INSERT INTO widget_kv (widget_id, key, value, updated_at) VALUES ('weather', 'config', ?, ?)`).run( + JSON.stringify({ + locationName: weatherRow.weather_location_name, + latitude: weatherRow.weather_latitude, + longitude: weatherRow.weather_longitude, + unit: weatherRow.weather_unit, + windUnit: weatherRow.weather_wind_unit, + pressureUnit: weatherRow.weather_pressure_unit + }), + nowIso + ); + db.prepare(`INSERT INTO widget_kv (widget_id, key, value, updated_at) VALUES ('weather', 'cache', ?, ?)`).run( + JSON.stringify({ + current: weatherRow.weather_current ? JSON.parse(weatherRow.weather_current) : null, + hourly: JSON.parse(weatherRow.weather_hourly ?? '[]'), + daily: JSON.parse(weatherRow.weather_daily ?? '[]'), + alerts: JSON.parse(weatherRow.weather_alerts ?? '[]'), + updatedAt: weatherRow.weather_updated_at + }), + nowIso + ); + } } diff --git a/backend/src/storage/db/installedWidgets.ts b/backend/src/storage/db/installedWidgets.ts new file mode 100644 index 0000000..ee1850c --- /dev/null +++ b/backend/src/storage/db/installedWidgets.ts @@ -0,0 +1,73 @@ +import { db } from './index.js'; +import type { InstalledWidget } from './types.js'; + +function rowToWidget(row: any): InstalledWidget { + return { + id: row.id, + displayName: row.display_name, + source: row.source, + codePath: row.code_path, + enabled: !!row.enabled, + priorityRank: row.priority_rank, + version: row.version, + ownedTables: JSON.parse(row.owned_tables), + frontendEntry: row.frontend_entry ?? null, + installedAt: row.installed_at + }; +} + +export function listInstalled(): InstalledWidget[] { + return (db.prepare('SELECT * FROM installed_widgets ORDER BY priority_rank').all() as any[]).map(rowToWidget); +} + +export function getInstalled(id: string): InstalledWidget | null { + const row = db.prepare('SELECT * FROM installed_widgets WHERE id = ?').get(id); + return row ? rowToWidget(row) : null; +} + +export function insertWidget(widget: { + id: string; + displayName: string; + source: 'builtin' | 'uploaded'; + codePath: string; + enabled?: boolean; + priorityRank?: number; + version?: string; + ownedTables?: string[]; + frontendEntry?: string | null; +}): InstalledWidget { + const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM installed_widgets').get() as { m: number }; + const priorityRank = widget.priorityRank ?? maxRank.m + 1; + const installedAt = new Date().toISOString(); + db.prepare( + `INSERT INTO installed_widgets (id, display_name, source, code_path, enabled, priority_rank, version, owned_tables, frontend_entry, installed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + widget.id, + widget.displayName, + widget.source, + widget.codePath, + widget.enabled === false ? 0 : 1, + priorityRank, + widget.version ?? '1.0.0', + JSON.stringify(widget.ownedTables ?? []), + widget.frontendEntry ?? null, + installedAt + ); + return getInstalled(widget.id)!; +} + +export function setEnabled(id: string, enabled: boolean) { + db.prepare('UPDATE installed_widgets SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, id); +} + +// Ids in display order — every id must already exist as a row; unlisted ids keep their +// current rank (mirrors the old widget_order JSON array's "admin-sortable" behavior). +export function reorder(ids: string[]) { + const stmt = db.prepare('UPDATE installed_widgets SET priority_rank = ? WHERE id = ?'); + ids.forEach((id, i) => stmt.run(i + 1, id)); +} + +export function deleteInstalled(id: string) { + db.prepare('DELETE FROM installed_widgets WHERE id = ?').run(id); +} diff --git a/backend/src/storage/db/settings.ts b/backend/src/storage/db/settings.ts index 73206d3..68782e2 100644 --- a/backend/src/storage/db/settings.ts +++ b/backend/src/storage/db/settings.ts @@ -1,6 +1,33 @@ import { db } from './index.js'; import type { GlobalSettings } from './types.js'; +import * as installedWidgetsDb from './installedWidgets.js'; +const BUILTIN_WIDGET_IDS = ['weather', 'stocks', 'bookmarks', 'poe2'] as const; + +// widgets/widgetOrder are computed from the installed_widgets registry (see +// storage/db/installedWidgets.ts, widgets/registry.ts) rather than stored as their own +// global_settings columns — the registry is the single source of truth for enable state +// and ordering for every widget, built-in or uploaded. Only the four built-in ids are +// reflected here since GlobalSettings.widgets/widgetOrder are closed unions the frontend +// depends on (uploaded widgets have no frontend representation yet). +function widgetsAndOrder(): Pick { + const installed = installedWidgetsDb.listInstalled(); + const byId = new Map(installed.map((w) => [w.id, w])); + const widgets = { + weather: !!byId.get('weather')?.enabled, + stocks: !!byId.get('stocks')?.enabled, + bookmarks: !!byId.get('bookmarks')?.enabled, + poe2: !!byId.get('poe2')?.enabled + }; + const widgetOrder = installed.map((w) => w.id).filter((id): id is (typeof BUILTIN_WIDGET_IDS)[number] => + (BUILTIN_WIDGET_IDS as readonly string[]).includes(id) + ); + return { widgets, widgetOrder }; +} + +// Every widget's own config/data now lives behind its own /api/widget/ routes (see +// widgets/weather/db.ts, widgets/poe2/poll.ts's league cache, etc.) — this settings blob +// is just the scalar pipeline knobs plus the 4 builtins' enable/order flags. function rowToSettings(row: any): GlobalSettings { return { mergeStrictness: row.merge_strictness, @@ -20,38 +47,13 @@ function rowToSettings(row: any): GlobalSettings { synthesisCustomInstructions: row.synthesis_custom_instructions, synthesisNumCtx: row.synthesis_num_ctx, synthesisNumPredict: row.synthesis_num_predict, - widgets: { - weather: !!row.widget_weather_enabled, - stocks: !!row.widget_stocks_enabled, - bookmarks: !!row.widget_bookmarks_enabled, - poe2: !!row.widget_poe2_enabled - }, - widgetOrder: JSON.parse(row.widget_order), + ...widgetsAndOrder(), retention: { publishedArticleMaxAgeDays: row.published_article_max_age_days, rawItemMaxAgeDays: row.raw_item_max_age_days, storageCapEnabled: !!row.storage_cap_enabled, storageCapValue: row.storage_cap_value, storageCapUnit: row.storage_cap_unit - }, - weather: { - locationName: row.weather_location_name, - latitude: row.weather_latitude, - longitude: row.weather_longitude, - unit: row.weather_unit, - windUnit: row.weather_wind_unit, - pressureUnit: row.weather_pressure_unit, - // Unlike retention, this is genuinely absent pre-first-poll (and pre-location-config) — null-safe parse. - current: row.weather_current ? JSON.parse(row.weather_current) : null, - hourly: JSON.parse(row.weather_hourly), - daily: JSON.parse(row.weather_daily), - alerts: JSON.parse(row.weather_alerts), - updatedAt: row.weather_updated_at - }, - poe2: { - leagueId: row.poe2_league_id, - leagueName: row.poe2_league_name, - updatedAt: row.poe2_updated_at } }; } @@ -68,10 +70,18 @@ export function updateSettings(patch: Partial): GlobalSettings { ...patch, retention: { ...current.retention, ...(patch.retention ?? {}) }, selectedModels: { ...current.selectedModels, ...(patch.selectedModels ?? {}) }, - weather: { ...current.weather, ...(patch.weather ?? {}) }, - poe2: { ...current.poe2, ...(patch.poe2 ?? {}) }, widgets: { ...current.widgets, ...(patch.widgets ?? {}) } }; + + if (patch.widgets) { + for (const id of BUILTIN_WIDGET_IDS) { + if (patch.widgets[id] !== undefined) installedWidgetsDb.setEnabled(id, patch.widgets[id]); + } + } + if (patch.widgetOrder) { + installedWidgetsDb.reorder(patch.widgetOrder); + } + // Named params (rather than positional `?`) so this list can be reordered or // extended without the column list and the bound-values list silently drifting // out of sync — node:sqlite binds each by its `$name` key, not position. @@ -86,16 +96,8 @@ export function updateSettings(patch: Partial): GlobalSettings { telegram_media_mode=$telegram_media_mode, synthesis_style_preset=$synthesis_style_preset, synthesis_custom_instructions=$synthesis_custom_instructions, synthesis_num_ctx=$synthesis_num_ctx, synthesis_num_predict=$synthesis_num_predict, - widget_weather_enabled=$widget_weather_enabled, widget_stocks_enabled=$widget_stocks_enabled, - widget_bookmarks_enabled=$widget_bookmarks_enabled, widget_poe2_enabled=$widget_poe2_enabled, - widget_order=$widget_order, published_article_max_age_days=$published_article_max_age_days, raw_item_max_age_days=$raw_item_max_age_days, - storage_cap_enabled=$storage_cap_enabled, storage_cap_value=$storage_cap_value, storage_cap_unit=$storage_cap_unit, - weather_location_name=$weather_location_name, weather_latitude=$weather_latitude, weather_longitude=$weather_longitude, - weather_unit=$weather_unit, weather_wind_unit=$weather_wind_unit, weather_pressure_unit=$weather_pressure_unit, - weather_current=$weather_current, weather_hourly=$weather_hourly, weather_daily=$weather_daily, - weather_alerts=$weather_alerts, weather_updated_at=$weather_updated_at, - poe2_league_id=$poe2_league_id, poe2_league_name=$poe2_league_name, poe2_updated_at=$poe2_updated_at + storage_cap_enabled=$storage_cap_enabled, storage_cap_value=$storage_cap_value, storage_cap_unit=$storage_cap_unit WHERE id = 1` ).run({ $merge_strictness: merged.mergeStrictness, @@ -115,30 +117,11 @@ export function updateSettings(patch: Partial): GlobalSettings { $synthesis_custom_instructions: merged.synthesisCustomInstructions, $synthesis_num_ctx: merged.synthesisNumCtx, $synthesis_num_predict: merged.synthesisNumPredict, - $widget_weather_enabled: merged.widgets.weather ? 1 : 0, - $widget_stocks_enabled: merged.widgets.stocks ? 1 : 0, - $widget_bookmarks_enabled: merged.widgets.bookmarks ? 1 : 0, - $widget_poe2_enabled: merged.widgets.poe2 ? 1 : 0, - $widget_order: JSON.stringify(merged.widgetOrder), $published_article_max_age_days: merged.retention.publishedArticleMaxAgeDays, $raw_item_max_age_days: merged.retention.rawItemMaxAgeDays, $storage_cap_enabled: merged.retention.storageCapEnabled ? 1 : 0, $storage_cap_value: merged.retention.storageCapValue, - $storage_cap_unit: merged.retention.storageCapUnit, - $weather_location_name: merged.weather.locationName, - $weather_latitude: merged.weather.latitude, - $weather_longitude: merged.weather.longitude, - $weather_unit: merged.weather.unit, - $weather_wind_unit: merged.weather.windUnit, - $weather_pressure_unit: merged.weather.pressureUnit, - $weather_current: merged.weather.current ? JSON.stringify(merged.weather.current) : null, - $weather_hourly: JSON.stringify(merged.weather.hourly), - $weather_daily: JSON.stringify(merged.weather.daily), - $weather_alerts: JSON.stringify(merged.weather.alerts), - $weather_updated_at: merged.weather.updatedAt, - $poe2_league_id: merged.poe2.leagueId, - $poe2_league_name: merged.poe2.leagueName, - $poe2_updated_at: merged.poe2.updatedAt + $storage_cap_unit: merged.retention.storageCapUnit }); return getSettings(); } diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts index ad597f7..366de36 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -214,21 +214,6 @@ export interface Category { disableAi: boolean; } -export interface WeatherHourEntry { - time: string; - temp: number; - conditionText: string; - icon: string; -} - -export interface WeatherDayEntry { - date: string; - tempMax: number; - tempMin: number; - conditionText: string; - icon: string; -} - export interface StockTicker { id: string; label: string; @@ -262,6 +247,23 @@ export interface Poe2WatchlistEntry { createdAt: string; } +/** A row in `installed_widgets` — the registry of both built-in and uploaded sidebar widgets (see widgets/registry.ts). */ +export interface InstalledWidget { + id: string; + displayName: string; + source: 'builtin' | 'uploaded'; + /** Builtin: the module's identifying path segment (e.g. 'weather', 'widgets/poe2'). Uploaded: the on-disk install directory, e.g. './data/widgets-installed/'. */ + codePath: string; + enabled: boolean; + priorityRank: number; + version: string; + /** Self-reported by the plugin (WidgetPlugin.ownedTables) at install/load time — used by the uninstall safety-net sweep. */ + ownedTables: string[]; + /** Relative path (within the widget's install dir) to an optional pre-built browser JS bundle, served from /widget-assets//* and dynamic-import()ed by the sidebar's DynamicWidgetSlot. Null for a widget with no custom frontend (falls back to the generic report card). */ + frontendEntry: string | null; + installedAt: string; +} + export interface Bookmark { id: string; name: string; @@ -313,56 +315,4 @@ export interface GlobalSettings { storageCapValue: number; storageCapUnit: 'MB' | 'GB'; }; - /** Sidebar "Weather" widget config + cache — see weather/poller.ts. Singleton, since there's only ever one configured location. */ - weather: { - locationName: string | null; - latitude: number | null; - longitude: number | null; - unit: 'celsius' | 'fahrenheit'; - windUnit: 'mph' | 'kph'; - pressureUnit: 'inHg' | 'hPa'; - current: { - temp: number; - /** Apparent temperature (Open-Meteo's own heat-index/wind-chill blend) — "Feels like". */ - feelsLike: number; - conditionText: string; - icon: string; - /** Percent, 0-100. */ - humidity: number; - /** Percent, 0-100 — the current hour's forecast precipitation probability (there's no true instantaneous "chance of rain" measurement). */ - precipitationChance: number; - /** Already in the admin's configured windUnit. */ - windSpeed: number; - /** 8-point compass abbreviation, e.g. "NW". */ - windDirection: string; - /** Already in the admin's configured pressureUnit. */ - pressure: number; - sunrise: string; - sunset: string; - } | null; - hourly: WeatherHourEntry[]; - daily: WeatherDayEntry[]; - /** Active NWS alerts (flash flood, hurricane, blizzard, etc.) for the configured location — US-only, empty elsewhere. See weather/client.ts's fetchActiveAlerts. */ - alerts: WeatherAlert[]; - updatedAt: string | null; - }; - /** - * Sidebar "PoE2" widget cache — see poe2/poller.ts. No admin-set config (unlike weather): - * the league is always auto-detected as the current challenge league, so this is purely - * a cache of what the last poll learned. Watchlist entries themselves live in the - * poe2_watchlist table, not here — same split as stock_tickers vs. this settings row. - */ - poe2: { - leagueId: string | null; - leagueName: string | null; - updatedAt: string | null; - }; -} - -export interface WeatherAlert { - id: string; - event: string; - headline: string; - severity: string; - expires: string; } diff --git a/backend/src/storage/db/widgetKv.ts b/backend/src/storage/db/widgetKv.ts new file mode 100644 index 0000000..b2a3bf2 --- /dev/null +++ b/backend/src/storage/db/widgetKv.ts @@ -0,0 +1,24 @@ +import { db } from './index.js'; + +// Generic config/cache store for pluggable widgets (see widgets/types.ts) — a widget with +// simple needs (weather-shaped: "last fetched value + timestamp") uses this instead of +// getting bolted-on global_settings columns, so it stays fully prunable by widget_id alone +// on uninstall (see widgets/uninstall.ts) without any host-side schema change. + +export function getKv(widgetId: string, key: string): T | null { + const row = db.prepare('SELECT value FROM widget_kv WHERE widget_id = ? AND key = ?').get(widgetId, key) as + | { value: string } + | undefined; + return row ? (JSON.parse(row.value) as T) : null; +} + +export function setKv(widgetId: string, key: string, value: unknown) { + db.prepare( + `INSERT INTO widget_kv (widget_id, key, value, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(widget_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at` + ).run(widgetId, key, JSON.stringify(value), new Date().toISOString()); +} + +export function deleteAllKv(widgetId: string) { + db.prepare('DELETE FROM widget_kv WHERE widget_id = ?').run(widgetId); +} diff --git a/backend/src/storage/db/bookmarks.ts b/backend/src/widgets/bookmarks/db.ts similarity index 50% rename from backend/src/storage/db/bookmarks.ts rename to backend/src/widgets/bookmarks/db.ts index 0882ca9..912db69 100644 --- a/backend/src/storage/db/bookmarks.ts +++ b/backend/src/widgets/bookmarks/db.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; -import { db } from './index.js'; -import type { Bookmark } from './types.js'; +import { db } from '../../storage/db/index.js'; +import { getKv, setKv } from '../../storage/db/widgetKv.js'; +import type { Bookmark } from '../../storage/db/types.js'; function rowToBookmark(row: any): Bookmark { return { @@ -14,33 +15,44 @@ function rowToBookmark(row: any): Bookmark { } // Always returns every bookmark, private or not — filtering for unauthenticated visitors -// happens at the route layer (GET /api/bookmarks), same as categoriesDb.listCategories(). +// happens at the route layer (GET /api/widget/bookmarks), same as categoriesDb.listCategories(). export function listBookmarks(): Bookmark[] { - const rows = db.prepare('SELECT * FROM bookmarks ORDER BY priority_rank').all(); + const rows = db.prepare('SELECT * FROM widget_bookmarks_items ORDER BY priority_rank').all(); return rows.map(rowToBookmark); } export function createBookmark(name: string, url: string, isPrivate = false): Bookmark { const id = `bm-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`; - const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM bookmarks').get() as { m: number }; + const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM widget_bookmarks_items').get() as { m: number }; const createdAt = new Date().toISOString(); db.prepare( - 'INSERT INTO bookmarks (id, name, url, priority_rank, is_private, created_at) VALUES (?, ?, ?, ?, ?, ?)' + 'INSERT INTO widget_bookmarks_items (id, name, url, priority_rank, is_private, created_at) VALUES (?, ?, ?, ?, ?, ?)' ).run(id, name, url, maxRank.m + 1, isPrivate ? 1 : 0, createdAt); return { id, name, url, priorityRank: maxRank.m + 1, isPrivate, createdAt }; } export function updateBookmark(id: string, patch: { name?: string; url?: string; isPrivate?: boolean }): Bookmark | null { - const existing = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(id); + const existing = db.prepare('SELECT * FROM widget_bookmarks_items WHERE id = ?').get(id); if (!existing) return null; const current = rowToBookmark(existing); const merged = { ...current, ...patch }; - db.prepare('UPDATE bookmarks SET name = ?, url = ?, is_private = ? WHERE id = ?').run( + db.prepare('UPDATE widget_bookmarks_items SET name = ?, url = ?, is_private = ? WHERE id = ?').run( merged.name, merged.url, merged.isPrivate ? 1 : 0, id ); return merged; } export function deleteBookmark(id: string) { - db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id); + db.prepare('DELETE FROM widget_bookmarks_items WHERE id = ?').run(id); +} + +// How many columns the sidebar/admin panel lays the bookmark list out in — stored in +// widget_kv rather than a bespoke table since it's a single scalar, same idiom as weather's +// config (see widgets/weather/db.ts). +export function getColumns(): 1 | 2 | 3 { + return getKv<{ columns: 1 | 2 | 3 }>('bookmarks', 'config')?.columns ?? 1; +} + +export function setColumns(columns: 1 | 2 | 3) { + setKv('bookmarks', 'config', { columns }); } diff --git a/backend/src/widgets/bookmarks/plugin.ts b/backend/src/widgets/bookmarks/plugin.ts new file mode 100644 index 0000000..d2e4093 --- /dev/null +++ b/backend/src/widgets/bookmarks/plugin.ts @@ -0,0 +1,79 @@ +import type { DatabaseSync } from 'node:sqlite'; +import type { WidgetPlugin } from '../types.js'; +import * as bookmarksDb from './db.js'; +import { hasPrivateAccess } from '../../api/privateAccess.js'; + +const OWNED_TABLES = ['widget_bookmarks_items']; + +// Sidebar "Bookmarks" widget — admin-curated links, each independently hidden/public via +// is_private (same private-access lock feature as categories.is_private). No poll (purely +// admin-curated, no external fetch). Built-in and non-deletable, but otherwise a full +// WidgetPlugin like an uploaded one. +export const bookmarksPlugin: WidgetPlugin = { + id: 'bookmarks', + displayName: 'Bookmarks', + version: '1.0.0', + ownedTables: OWNED_TABLES, + + migrate(db: DatabaseSync) { + db.exec(` + CREATE TABLE IF NOT EXISTS widget_bookmarks_items ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + url TEXT NOT NULL, + priority_rank INTEGER NOT NULL, + is_private INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + `); + }, + + registerPublicRoutes(app) { + app.get('/api/widget/bookmarks', async (req) => { + const bookmarks = bookmarksDb.listBookmarks(); + const items = hasPrivateAccess(req) ? bookmarks : bookmarks.filter((b) => !b.isPrivate); + return { items, columns: bookmarksDb.getColumns() }; + }); + }, + + registerAdminRoutes(app) { + app.get('/api/admin/widget/bookmarks', async () => bookmarksDb.listBookmarks()); + + app.get('/api/admin/widget/bookmarks/config', async () => ({ columns: bookmarksDb.getColumns() })); + + app.patch('/api/admin/widget/bookmarks/config', async (req, reply) => { + const { columns } = req.body as { columns?: number }; + if (columns !== 1 && columns !== 2 && columns !== 3) { + return reply.code(400).send({ error: 'columns must be 1, 2, or 3' }); + } + bookmarksDb.setColumns(columns); + return { columns }; + }); + + app.post('/api/admin/widget/bookmarks', async (req, reply) => { + const { name, url, isPrivate } = req.body as { name?: string; url?: string; isPrivate?: boolean }; + if (!name || !name.trim() || !url || !url.trim()) { + return reply.code(400).send({ error: 'name and url are required' }); + } + const created = bookmarksDb.createBookmark(name.trim(), url.trim(), !!isPrivate); + return reply.code(201).send(created); + }); + + app.patch('/api/admin/widget/bookmarks/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + const updated = bookmarksDb.updateBookmark(id, req.body as any); + if (!updated) return reply.code(404).send({ error: 'not found' }); + return updated; + }); + + app.delete('/api/admin/widget/bookmarks/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + bookmarksDb.deleteBookmark(id); + return reply.code(204).send(); + }); + }, + + uninstall(db: DatabaseSync) { + db.exec('DROP TABLE IF EXISTS widget_bookmarks_items;'); + } +}; diff --git a/backend/src/widgets/install.ts b/backend/src/widgets/install.ts new file mode 100644 index 0000000..11b2a1a --- /dev/null +++ b/backend/src/widgets/install.ts @@ -0,0 +1,88 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import * as installedWidgetsDb from '../storage/db/installedWidgets.js'; +import { loadUploadedWidget, loadedWidgets } from './registry.js'; +import { startWidgetPolling } from '../queue/scheduler.js'; +import { validateManifest, type WidgetManifest } from './manifest.js'; +import { validateRoutesBuildable } from '../server.js'; +import { logger } from '../storage/db/logs.js'; + +const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed'; + +export type InstallResult = + | { ok: true; id: string; needsServerSwap: boolean } + | { ok: false; error: string }; + +// Installs and hot-loads a widget uploaded live to the running backend (see +// api/admin.ts's POST /api/admin/widgets) — writes its files under ./data/, never +// dist/ or src/, so it survives a rebuild/redeploy of the core app. Its migrate() +// runs and its poll interval (if declared) starts immediately. If it declares +// registerPublicRoutes/registerAdminRoutes, `needsServerSwap` comes back true — the +// caller (api/admin.ts's POST route) must call server.ts's swapLiveServer() itself, +// AFTER sending its own response, never inline here: this function runs inside the +// very request handler whose underlying Fastify instance a swap would close, so +// awaiting the swap here would drop the response before the client ever sees it (hit +// this for real in testing). validateRoutesBuildable() is safe to await here — it +// never touches the live server, only a throwaway instance on an ephemeral port — so +// a widget whose routes are actually broken (e.g. a path collision) is still caught +// and rolled back within this same call, before anything user-visible commits. +// +// Either way this is a full HTTP-server rebuild within the running process, not a +// process restart — the DB connection, scheduler intervals, Telegram session, and +// (critically) the admin API key all survive; a process restart would regenerate the +// key and log the admin out. +export async function installUploadedWidget(manifest: unknown, files: unknown): Promise { + const validationError = validateManifest(manifest, files); + if (validationError) return { ok: false, error: validationError }; + const m = manifest as WidgetManifest; + const f = files as Record; + + if (installedWidgetsDb.getInstalled(m.id)) { + return { ok: false, error: `widget "${m.id}" is already installed` }; + } + + const dir = path.join(WIDGETS_INSTALLED_DIR, m.id); + fs.mkdirSync(dir, { recursive: true }); + for (const [relPath, content] of Object.entries(f)) { + const filePath = path.join(dir, relPath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); + } + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(m, null, 2), 'utf8'); + + const plugin = await loadUploadedWidget(m.id, dir); + if (!plugin) { + fs.rmSync(dir, { recursive: true, force: true }); + return { ok: false, error: "widget failed to load — check its entry file's default export satisfies WidgetPlugin" }; + } + + installedWidgetsDb.insertWidget({ + id: m.id, + displayName: m.displayName, + source: 'uploaded', + codePath: dir, + version: m.version, + ownedTables: plugin.ownedTables ?? [], + frontendEntry: m.frontendEntry ?? null + }); + + const needsServerSwap = !!(plugin.registerPublicRoutes || plugin.registerAdminRoutes); + if (needsServerSwap) { + try { + await validateRoutesBuildable(); + } catch (err) { + // A widget whose routes break Fastify's registration (e.g. a path collision) + // isn't a successful install — the site itself was never at risk since this + // only ever touched a throwaway ephemeral-port instance, but this widget still + // needs to be fully rolled back rather than left half-installed. + logger.error('widgets', `Install of "${m.id}" rolled back — its routes failed to register: ${(err as Error).message}`); + loadedWidgets.delete(m.id); + installedWidgetsDb.deleteInstalled(m.id); + fs.rmSync(dir, { recursive: true, force: true }); + return { ok: false, error: `widget's routes failed to register: ${(err as Error).message}` }; + } + } + + startWidgetPolling(plugin); + return { ok: true, id: m.id, needsServerSwap }; +} diff --git a/backend/src/widgets/manifest.ts b/backend/src/widgets/manifest.ts new file mode 100644 index 0000000..c7fc125 --- /dev/null +++ b/backend/src/widgets/manifest.ts @@ -0,0 +1,47 @@ +const ID_PATTERN = /^[a-z0-9-]{1,40}$/; +const RESERVED_IDS = new Set(['weather', 'stocks', 'bookmarks', 'poe2']); +const MAX_TOTAL_BYTES = 5 * 1024 * 1024; + +export interface WidgetManifest { + id: string; + displayName: string; + version: string; + /** Relative path within `files` to the ESM entry point, e.g. "index.mjs" — a default export satisfying WidgetPlugin. */ + entry: string; + /** Optional relative path within `files` to a pre-built browser JS bundle (plain vanilla JS, not raw Svelte source — see widgets/registry.ts's frontend-loading notes) exporting a default `{ mount(container, ctx) }`. Served from /widget-assets//* and dynamic-import()ed by the sidebar's DynamicWidgetSlot. Omit for a widget with no custom UI — it falls back to the generic report card. */ + frontendEntry?: string; +} + +// Upload body shape: { manifest, files: { "index.mjs": "", ... } } — plain +// JSON rather than a literal zip, so no archive/multipart dependency is needed (see +// widgets/install.ts). Returns a human-readable error string, or null if valid. +export function validateManifest(manifest: unknown, files: unknown): string | null { + if (!manifest || typeof manifest !== 'object') return 'manifest is required'; + const m = manifest as Record; + if (typeof m.id !== 'string' || !ID_PATTERN.test(m.id)) return 'manifest.id must match /^[a-z0-9-]{1,40}$/'; + if (RESERVED_IDS.has(m.id)) return `id "${m.id}" is reserved for a built-in widget`; + if (typeof m.displayName !== 'string' || !m.displayName.trim()) return 'manifest.displayName is required'; + if (typeof m.version !== 'string' || !m.version.trim()) return 'manifest.version is required'; + if (typeof m.entry !== 'string' || !m.entry.trim()) return 'manifest.entry is required'; + if (m.frontendEntry !== undefined && (typeof m.frontendEntry !== 'string' || !m.frontendEntry.trim())) { + return 'manifest.frontendEntry must be a non-empty string when present'; + } + + if (!files || typeof files !== 'object' || Array.isArray(files)) return 'files must be a non-empty object'; + const entries = Object.entries(files as Record); + if (entries.length === 0) return 'files must be a non-empty object'; + if (!(m.entry in (files as Record))) return `entry "${m.entry as string}" not found in files`; + if (m.frontendEntry !== undefined && !(m.frontendEntry in (files as Record))) { + return `frontendEntry "${m.frontendEntry as string}" not found in files`; + } + + let totalBytes = 0; + for (const [relPath, content] of entries) { + if (typeof content !== 'string') return `files["${relPath}"] must be a string`; + if (relPath.startsWith('/') || relPath.split('/').some((seg) => seg === '..')) return `invalid file path "${relPath}"`; + totalBytes += Buffer.byteLength(content, 'utf8'); + } + if (totalBytes > MAX_TOTAL_BYTES) return `bundle exceeds ${MAX_TOTAL_BYTES}-byte limit`; + + return null; +} diff --git a/backend/src/poe2/client.ts b/backend/src/widgets/poe2/client.ts similarity index 90% rename from backend/src/poe2/client.ts rename to backend/src/widgets/poe2/client.ts index d3755db..74ef227 100644 --- a/backend/src/poe2/client.ts +++ b/backend/src/widgets/poe2/client.ts @@ -1,5 +1,5 @@ // poe.ninja's public PoE2 economy API — free, no account or API key required. This is the -// only file that talks to it; poller.ts orchestrates when/how results get saved, same +// only file that talks to it; poll.ts orchestrates when/how results get saved, same // separation as backend/src/telegram/ and backend/src/weather/ keep between the raw client // and their callers. // @@ -13,7 +13,7 @@ // change, which poe.ninja's overview doesn't expose directly. Every line's `primaryValue` is // expressed in the same (unspecified, and irrelevant) reference currency, so any pair's rate // is just baseValue / quoteValue with the reference cancelling out — see fetchCurrencyValues -// below and poe2/poller.ts, which self-computes change from its own polling history instead. +// below and poll.ts, which self-computes change from its own polling history instead. const BASE_URL = 'https://poe.ninja'; export interface LeagueInfo { @@ -57,8 +57,9 @@ async function fetchCurrencyOverview(leagueId: string): Promise { const { lines, items } = await fetchCurrencyOverview(leagueId); const nameById = new Map(items.map((item) => [item.id, item.name])); diff --git a/backend/src/storage/db/poe2Watchlist.ts b/backend/src/widgets/poe2/db.ts similarity index 75% rename from backend/src/storage/db/poe2Watchlist.ts rename to backend/src/widgets/poe2/db.ts index beba793..8cd6500 100644 --- a/backend/src/storage/db/poe2Watchlist.ts +++ b/backend/src/widgets/poe2/db.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { db } from './index.js'; -import type { Poe2WatchlistEntry } from './types.js'; +import { db } from '../../storage/db/index.js'; +import type { Poe2WatchlistEntry } from '../../storage/db/types.js'; interface CurrencyRef { currencyId: string; @@ -24,21 +24,21 @@ function rowToEntry(row: any): Poe2WatchlistEntry { } export function listWatchlist(): Poe2WatchlistEntry[] { - const rows = db.prepare('SELECT * FROM poe2_watchlist ORDER BY priority_rank').all(); + const rows = db.prepare('SELECT * FROM widget_poe2_watchlist ORDER BY priority_rank').all(); return rows.map(rowToEntry); } -// No update() — currencies are picked from a live browse list (see poe2/client.ts's +// No update() — currencies are picked from a live browse list (see widgets/poe2/client.ts's // browseCurrencies), not typed, so there's nothing to edit; remove and re-add covers the // rare "picked the wrong one" case. export function addWatchlistEntry(base: CurrencyRef, quote: CurrencyRef): Poe2WatchlistEntry { const id = `poe2-${base.currencyId.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${quote.currencyId.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${randomUUID().slice(0, 6)}` .replace(/-+/g, '-') .replace(/(^-|-$)/g, ''); - const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM poe2_watchlist').get() as { m: number }; + const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM widget_poe2_watchlist').get() as { m: number }; const createdAt = new Date().toISOString(); db.prepare( - `INSERT INTO poe2_watchlist + `INSERT INTO widget_poe2_watchlist (id, base_currency_id, base_name, quote_currency_id, quote_name, priority_rank, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)` ).run(id, base.currencyId, base.name, quote.currencyId, quote.name, maxRank.m + 1, createdAt); @@ -58,14 +58,14 @@ export function addWatchlistEntry(base: CurrencyRef, quote: CurrencyRef): Poe2Wa } export function removeWatchlistEntry(id: string) { - db.prepare('DELETE FROM poe2_rate_history WHERE watchlist_id = ?').run(id); - db.prepare('DELETE FROM poe2_watchlist WHERE id = ?').run(id); + db.prepare('DELETE FROM widget_poe2_rate_history WHERE watchlist_id = ?').run(id); + db.prepare('DELETE FROM widget_poe2_watchlist WHERE id = ?').run(id); } -// One snapshot per poll (see poe2/poller.ts) — the raw material 24h change is computed +// One snapshot per poll (see widgets/poe2/poll.ts) — the raw material 24h change is computed // from, since poe.ninja itself doesn't expose per-pair rates or a matching change window. export function recordRate(watchlistId: string, rate: number, recordedAt: string) { - db.prepare('INSERT INTO poe2_rate_history (watchlist_id, rate, recorded_at) VALUES (?, ?, ?)').run( + db.prepare('INSERT INTO widget_poe2_rate_history (watchlist_id, rate, recorded_at) VALUES (?, ?, ?)').run( watchlistId, rate, recordedAt @@ -77,14 +77,14 @@ export function recordRate(watchlistId: string, rate: number, recordedAt: string // than fabricating a 0% figure. export function rateAtOrBefore(watchlistId: string, cutoffIso: string): number | null { const row = db - .prepare('SELECT rate FROM poe2_rate_history WHERE watchlist_id = ? AND recorded_at <= ? ORDER BY recorded_at DESC LIMIT 1') + .prepare('SELECT rate FROM widget_poe2_rate_history WHERE watchlist_id = ? AND recorded_at <= ? ORDER BY recorded_at DESC LIMIT 1') .get(watchlistId, cutoffIso) as { rate: number } | undefined; return row ? row.rate : null; } export function markPolled(id: string, rate: number | null, change24h: number | null, error: string | null) { db.prepare( - `UPDATE poe2_watchlist + `UPDATE widget_poe2_watchlist SET last_rate = ?, last_change_24h = ?, last_polled_at = ?, last_error = ? WHERE id = ?` ).run(rate, change24h, new Date().toISOString(), error, id); @@ -94,5 +94,5 @@ export function markPolled(id: string, rate: number | null, change24h: number | // poll to be briefly late without losing the data point it needs. export function pruneOldHistory() { const cutoff = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); - db.prepare('DELETE FROM poe2_rate_history WHERE recorded_at < ?').run(cutoff); + db.prepare('DELETE FROM widget_poe2_rate_history WHERE recorded_at < ?').run(cutoff); } diff --git a/backend/src/widgets/poe2/plugin.ts b/backend/src/widgets/poe2/plugin.ts new file mode 100644 index 0000000..730bdbd --- /dev/null +++ b/backend/src/widgets/poe2/plugin.ts @@ -0,0 +1,107 @@ +import type { DatabaseSync } from 'node:sqlite'; +import type { WidgetPlugin } from '../types.js'; +import { deleteAllKv } from '../../storage/db/widgetKv.js'; +import { logger } from '../../storage/db/logs.js'; +import * as poe2Db from './db.js'; +import { browseCurrencies, fetchCurrentLeague } from './client.js'; +import { pollPoe2Now, getLeagueCache } from './poll.js'; + +const OWNED_TABLES = ['widget_poe2_watchlist', 'widget_poe2_rate_history']; + +// Sidebar "PoE2" widget — tracks exchange rates between arbitrary currency pairs, always +// against the current challenge league (auto-detected, no admin config). The pilot +// implementation of the pluggable-widget system (see widgets/types.ts) — every other +// widget still ships in-process, but this one exercises the full migrate/poll/routes/ +// uninstall lifecycle a live-uploaded widget would also go through. +export const poe2Plugin: WidgetPlugin = { + id: 'poe2', + displayName: 'PoE2', + version: '1.0.0', + ownedTables: OWNED_TABLES, + + migrate(db: DatabaseSync) { + db.exec(` + CREATE TABLE IF NOT EXISTS widget_poe2_watchlist ( + id TEXT PRIMARY KEY, + base_currency_id TEXT NOT NULL, + base_name TEXT NOT NULL, + quote_currency_id TEXT NOT NULL, + quote_name TEXT NOT NULL, + priority_rank INTEGER NOT NULL, + last_rate REAL, + last_change_24h REAL, + last_polled_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS widget_poe2_rate_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + watchlist_id TEXT NOT NULL, + rate REAL NOT NULL, + recorded_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_widget_poe2_rate_history_watchlist ON widget_poe2_rate_history(watchlist_id, recorded_at); + `); + }, + + poll: { + // poe.ninja's own overview data doesn't refresh faster than hourly, so polling + // more often than this just re-fetches the same numbers. + intervalMs: 60 * 60_000, + run: pollPoe2Now + }, + + registerPublicRoutes(app) { + app.get('/api/widget/poe2', async () => { + const { leagueName, updatedAt } = getLeagueCache(); + return { leagueName, updatedAt, entries: poe2Db.listWatchlist() }; + }); + }, + + registerAdminRoutes(app) { + // League is always auto-detected, never admin-set. + app.get('/api/admin/widget/poe2/browse', async (_req, reply) => { + try { + const league = await fetchCurrentLeague(); + return await browseCurrencies(league.id); + } catch (err) { + return reply.code(502).send({ error: `poe.ninja unreachable: ${(err as Error).message}` }); + } + }); + + app.get('/api/admin/widget/poe2/watchlist', async () => poe2Db.listWatchlist()); + + app.post('/api/admin/widget/poe2/watchlist', async (req, reply) => { + const { base, quote } = req.body as { + base?: { currencyId?: string; name?: string }; + quote?: { currencyId?: string; name?: string }; + }; + if (!base?.currencyId || !base?.name || !quote?.currencyId || !quote?.name) { + return reply.code(400).send({ error: 'base and quote currencies (currencyId, name) are required' }); + } + if (base.currencyId === quote.currencyId) { + return reply.code(400).send({ error: 'Base and quote currencies must be different' }); + } + const created = poe2Db.addWatchlistEntry( + { currencyId: base.currencyId, name: base.name }, + { currencyId: quote.currencyId, name: quote.name } + ); + // Poll immediately rather than waiting for the next tick (up to 1 hour) — cheap, + // and refreshes every existing entry's rate too. + pollPoe2Now().catch((err) => logger.error('poe2', `Immediate poll failed: ${err.message}`)); + return reply.code(201).send(created); + }); + + app.delete('/api/admin/widget/poe2/watchlist/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + poe2Db.removeWatchlistEntry(id); + return reply.code(204).send(); + }); + }, + + uninstall(db: DatabaseSync) { + db.exec('DROP TABLE IF EXISTS widget_poe2_rate_history;'); + db.exec('DROP TABLE IF EXISTS widget_poe2_watchlist;'); + deleteAllKv('poe2'); + } +}; diff --git a/backend/src/widgets/poe2/poll.ts b/backend/src/widgets/poe2/poll.ts new file mode 100644 index 0000000..86804a9 --- /dev/null +++ b/backend/src/widgets/poe2/poll.ts @@ -0,0 +1,74 @@ +import * as poe2Db from './db.js'; +import { getKv, setKv } from '../../storage/db/widgetKv.js'; +import { logger } from '../../storage/db/logs.js'; +import { fetchCurrentLeague, fetchCurrencyValues } from './client.js'; + +const DAY_MS = 24 * 60 * 60_000; +const WIDGET_ID = 'poe2'; + +interface LeagueCache { + leagueId: string | null; + leagueName: string | null; + updatedAt: string | null; +} + +// Cache of what the last poll learned about the current league — no admin-set config +// (unlike weather): the league is always auto-detected, so this is purely a cache. Lives +// in the generic widget_kv store (see storage/db/widgetKv.ts) so it's prunable via the +// same deleteAllKv('poe2') path as everything else this widget owns. +export function getLeagueCache(): LeagueCache { + return getKv(WIDGET_ID, 'leagueCache') ?? { leagueId: null, leagueName: null, updatedAt: null }; +} + +function pctChange(current: number, past: number | null): number | null { + if (past === null || past === 0) return null; + return ((current - past) / past) * 100; +} + +// Called on a schedule (see queue/scheduler.ts, via plugin.poll) and immediately after the +// admin adds a pair (see plugin.ts's admin routes) — always re-detects the current +// challenge league fresh (cheap, guarantees correctness across league rotations with no +// separate staleness logic), then one overview request covers the whole watchlist. A pair +// whose base or quote currency is no longer traded this league gets its own lastError, it +// never aborts the rest of the batch. +export async function pollPoe2Now(): Promise { + let league; + try { + league = await fetchCurrentLeague(); + } catch (err) { + logger.error('poe2', `League lookup failed: ${(err as Error).message}`); + return; + } + + const entries = poe2Db.listWatchlist(); + if (entries.length === 0) { + setKv(WIDGET_ID, 'leagueCache', { leagueId: league.id, leagueName: league.name, updatedAt: new Date().toISOString() }); + return; + } + + try { + const valuesById = await fetchCurrencyValues(league.id); + const now = new Date(); + const nowIso = now.toISOString(); + const cutoff24h = new Date(now.getTime() - DAY_MS).toISOString(); + + for (const entry of entries) { + const baseValue = valuesById.get(entry.baseCurrencyId); + const quoteValue = valuesById.get(entry.quoteCurrencyId); + if (baseValue === undefined || quoteValue === undefined) { + poe2Db.markPolled(entry.id, null, null, 'One or both currencies no longer traded in this league'); + continue; + } + + const rate = baseValue / quoteValue; + const change24h = pctChange(rate, poe2Db.rateAtOrBefore(entry.id, cutoff24h)); + poe2Db.recordRate(entry.id, rate, nowIso); + poe2Db.markPolled(entry.id, rate, change24h, null); + } + + poe2Db.pruneOldHistory(); + setKv(WIDGET_ID, 'leagueCache', { leagueId: league.id, leagueName: league.name, updatedAt: nowIso }); + } catch (err) { + logger.error('poe2', `Watchlist poll failed: ${(err as Error).message}`); + } +} diff --git a/backend/src/widgets/registry.ts b/backend/src/widgets/registry.ts new file mode 100644 index 0000000..de9b4e9 --- /dev/null +++ b/backend/src/widgets/registry.ts @@ -0,0 +1,69 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { db } from '../storage/db/index.js'; +import * as installedWidgetsDb from '../storage/db/installedWidgets.js'; +import { logger } from '../storage/db/logs.js'; +import type { WidgetPlugin } from './types.js'; +import { sweepOrphanedWidgetData } from './sweep.js'; +import { weatherPlugin } from './weather/plugin.js'; +import { stocksPlugin } from './stocks/plugin.js'; +import { bookmarksPlugin } from './bookmarks/plugin.js'; +import { poe2Plugin } from './poe2/plugin.js'; + +// Both built-in and uploaded widgets funnel into this one map — scheduler dispatch and +// route registration (see index.ts, queue/scheduler.ts) iterate it generically instead of +// hardcoding a block per widget, mirroring ingestion/poller.ts's +// Record dispatch, just keyed by a dynamic string id. +export const loadedWidgets = new Map(); + +function registerBuiltinWidget(plugin: WidgetPlugin) { + plugin.migrate?.(db); + loadedWidgets.set(plugin.id, plugin); +} + +// Called once at startup (see index.ts, after migrate() and before route registration / +// startScheduler()) and again after a live upload (see widgets/install.ts) to pick up just +// the newly-installed one without reloading everything else. +export async function loadAllWidgets(): Promise { + loadedWidgets.clear(); + + registerBuiltinWidget(weatherPlugin); + registerBuiltinWidget(stocksPlugin); + registerBuiltinWidget(bookmarksPlugin); + registerBuiltinWidget(poe2Plugin); + + for (const row of installedWidgetsDb.listInstalled().filter((w) => w.source === 'uploaded')) { + await loadUploadedWidget(row.id, row.codePath); + } + + sweepOrphanedWidgetData( + db, + installedWidgetsDb.listInstalled().map((w) => w.id) + ); +} + +// A widget whose code fails to load logs an error and is simply absent from +// loadedWidgets — scheduler/route registration skip an id with no entry. Its +// installed_widgets row stays, so it's still visible and deletable from the admin side +// (see widgets/uninstall.ts, which doesn't require the widget's own code to be loadable). +export async function loadUploadedWidget(id: string, codePath: string): Promise { + try { + const manifest = JSON.parse(fs.readFileSync(path.join(codePath, 'manifest.json'), 'utf8')) as { entry: string }; + const entryUrl = pathToFileURL(path.join(codePath, manifest.entry)).href; + const mod = await import(entryUrl); + const plugin: WidgetPlugin = mod.default; + if (!plugin || typeof plugin.id !== 'string') { + throw new Error('module has no default-exported WidgetPlugin'); + } + if (plugin.id !== id) { + throw new Error(`plugin id "${plugin.id}" does not match installed id "${id}"`); + } + plugin.migrate?.(db); + loadedWidgets.set(id, plugin); + return plugin; + } catch (err) { + logger.error('widgets', `Failed to load uploaded widget "${id}": ${(err as Error).message}`); + return null; + } +} diff --git a/backend/src/widgets/report.ts b/backend/src/widgets/report.ts new file mode 100644 index 0000000..2a1e837 --- /dev/null +++ b/backend/src/widgets/report.ts @@ -0,0 +1,13 @@ +/** + * Generic live-data shape a widget's poll.run() can publish via + * setKv(id, 'report', report) (see storage/db/widgetKv.ts) for the sidebar's generic + * report card to render — see api/public.ts's GET /api/widget/:id/report, registered once + * at startup so it works for any widget id including ones uploaded after boot, with zero + * per-widget route registration (sidesteps Fastify's "no routes after listen()" limit). + */ +export interface WidgetReport { + title: string; + headline?: { value: string; delta?: string } | null; + rows?: { label: string; value: string }[]; + updatedAt: string | null; +} diff --git a/backend/src/stocks/client.ts b/backend/src/widgets/stocks/client.ts similarity index 100% rename from backend/src/stocks/client.ts rename to backend/src/widgets/stocks/client.ts diff --git a/backend/src/widgets/stocks/db.ts b/backend/src/widgets/stocks/db.ts new file mode 100644 index 0000000..0b78608 --- /dev/null +++ b/backend/src/widgets/stocks/db.ts @@ -0,0 +1,54 @@ +import { randomUUID } from 'node:crypto'; +import { db } from '../../storage/db/index.js'; +import type { StockTicker } from '../../storage/db/types.js'; + +function rowToTicker(row: any): StockTicker { + return { + id: row.id, + label: row.label, + symbol: row.symbol, + priorityRank: row.priority_rank, + lastPrice: row.last_price, + lastChangePercent: row.last_change_percent, + lastPolledAt: row.last_polled_at, + lastError: row.last_error, + createdAt: row.created_at + }; +} + +export function listStockTickers(): StockTicker[] { + const rows = db.prepare('SELECT * FROM widget_stocks_tickers ORDER BY priority_rank').all(); + return rows.map(rowToTicker); +} + +export function createStockTicker(label: string, symbol: string): StockTicker { + const id = `stk-${symbol.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`; + const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM widget_stocks_tickers').get() as { m: number }; + const createdAt = new Date().toISOString(); + db.prepare( + 'INSERT INTO widget_stocks_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)' + ).run(id, label, symbol, maxRank.m + 1, createdAt); + return { + id, label, symbol, priorityRank: maxRank.m + 1, + lastPrice: null, lastChangePercent: null, lastPolledAt: null, lastError: null, createdAt + }; +} + +export function updateStockTicker(id: string, patch: { label?: string; symbol?: string }): StockTicker | null { + const existing = db.prepare('SELECT * FROM widget_stocks_tickers WHERE id = ?').get(id); + if (!existing) return null; + const current = rowToTicker(existing); + const merged = { ...current, ...patch }; + db.prepare('UPDATE widget_stocks_tickers SET label = ?, symbol = ? WHERE id = ?').run(merged.label, merged.symbol, id); + return { ...merged }; +} + +export function deleteStockTicker(id: string) { + db.prepare('DELETE FROM widget_stocks_tickers WHERE id = ?').run(id); +} + +export function markStockPolled(id: string, price: number | null, changePercent: number | null, error: string | null) { + db.prepare( + 'UPDATE widget_stocks_tickers SET last_price = ?, last_change_percent = ?, last_polled_at = ?, last_error = ? WHERE id = ?' + ).run(price, changePercent, new Date().toISOString(), error, id); +} diff --git a/backend/src/widgets/stocks/plugin.ts b/backend/src/widgets/stocks/plugin.ts new file mode 100644 index 0000000..519f75c --- /dev/null +++ b/backend/src/widgets/stocks/plugin.ts @@ -0,0 +1,94 @@ +import type { DatabaseSync } from 'node:sqlite'; +import type { WidgetPlugin } from '../types.js'; +import { logger } from '../../storage/db/logs.js'; +import * as stocksDb from './db.js'; +import { pollStocksNow } from './poll.js'; + +const OWNED_TABLES = ['widget_stocks_tickers']; + +// Sidebar "Stocks" widget — polled every 15 minutes from Yahoo Finance (see poll.ts). +// Price/change/poll-state live directly on its own table, same as sources.last_polled_at, +// rather than a separate quote-cache table. Built-in and non-deletable, but otherwise a +// full WidgetPlugin like an uploaded one. +export const stocksPlugin: WidgetPlugin = { + id: 'stocks', + displayName: 'Stocks', + version: '1.0.0', + ownedTables: OWNED_TABLES, + + migrate(db: DatabaseSync) { + db.exec(` + CREATE TABLE IF NOT EXISTS widget_stocks_tickers ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + symbol TEXT NOT NULL, + priority_rank INTEGER NOT NULL, + last_price REAL, + last_change_percent REAL, + last_polled_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL + ); + `); + + // Seed a handful of sensible default tickers so the widget isn't empty on a fresh + // install — the admin can remove/replace any of them. + const tickerCount = db.prepare('SELECT COUNT(*) as c FROM widget_stocks_tickers').get() as { c: number }; + if (tickerCount.c === 0) { + const defaults: [string, string][] = [ + ['Dow Jones', '^DJI'], + ['S&P 500', '^GSPC'], + ['Bitcoin', 'BTC-USD'] + ]; + const stmt = db.prepare( + 'INSERT INTO widget_stocks_tickers (id, label, symbol, priority_rank, created_at) VALUES (?, ?, ?, ?, ?)' + ); + defaults.forEach(([label, symbol], i) => { + stmt.run(`stk-${symbol.replace(/[^a-z0-9]+/gi, '-')}`, label, symbol, i + 1, new Date().toISOString()); + }); + } + }, + + poll: { + // Per admin spec — stock prices move faster than weather. + intervalMs: 15 * 60_000, + run: pollStocksNow + }, + + registerPublicRoutes(app) { + app.get('/api/widget/stocks', async () => stocksDb.listStockTickers()); + }, + + registerAdminRoutes(app) { + app.get('/api/admin/widget/stocks', async () => stocksDb.listStockTickers()); + + app.post('/api/admin/widget/stocks', async (req, reply) => { + const { label, symbol } = req.body as { label?: string; symbol?: string }; + if (!label || !label.trim() || !symbol || !symbol.trim()) { + return reply.code(400).send({ error: 'label and symbol are required' }); + } + const created = stocksDb.createStockTicker(label.trim(), symbol.trim()); + // Poll immediately rather than waiting for the next tick (up to 15 minutes) — cheap, + // and refreshes every existing ticker's price too. + pollStocksNow().catch((err) => logger.error('stocks', `Immediate poll failed: ${err.message}`)); + return reply.code(201).send(created); + }); + + app.patch('/api/admin/widget/stocks/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + const updated = stocksDb.updateStockTicker(id, req.body as any); + if (!updated) return reply.code(404).send({ error: 'not found' }); + return updated; + }); + + app.delete('/api/admin/widget/stocks/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + stocksDb.deleteStockTicker(id); + return reply.code(204).send(); + }); + }, + + uninstall(db: DatabaseSync) { + db.exec('DROP TABLE IF EXISTS widget_stocks_tickers;'); + } +}; diff --git a/backend/src/stocks/poller.ts b/backend/src/widgets/stocks/poll.ts similarity index 65% rename from backend/src/stocks/poller.ts rename to backend/src/widgets/stocks/poll.ts index 84ccb26..58fdd75 100644 --- a/backend/src/stocks/poller.ts +++ b/backend/src/widgets/stocks/poll.ts @@ -1,11 +1,11 @@ -import * as stocksDb from '../storage/db/stocks.js'; -import { logger } from '../storage/db/logs.js'; +import * as stocksDb from './db.js'; +import { logger } from '../../storage/db/logs.js'; import { fetchQuotes } from './client.js'; -// Called on a schedule (see queue/scheduler.ts) and immediately after the admin adds a -// ticker (see api/admin.ts) — one request per configured ticker (see client.ts for why -// there's no batch endpoint here). A symbol Yahoo can't resolve gets its own lastError, -// it never aborts the rest of the batch. +// Called on a schedule (see queue/scheduler.ts, via plugin.poll) and immediately after the +// admin adds a ticker (see plugin.ts's admin routes) — one request per configured ticker +// (see client.ts for why there's no batch endpoint here). A symbol Yahoo can't resolve +// gets its own lastError, it never aborts the rest of the batch. export async function pollStocksNow(): Promise { const tickers = stocksDb.listStockTickers(); if (tickers.length === 0) return; diff --git a/backend/src/widgets/sweep.ts b/backend/src/widgets/sweep.ts new file mode 100644 index 0000000..0184ad1 --- /dev/null +++ b/backend/src/widgets/sweep.ts @@ -0,0 +1,48 @@ +import type { DatabaseSync } from 'node:sqlite'; + +// Host-side safety net for widget data pruning (see widgets/types.ts's uninstall contract +// and the plan's "data isolation strategy"). This alone is sufficient to fully clean a +// widget's data even with zero cooperation from its own code — a widget's own uninstall() +// hook (if present) is an optimization/extension point, not a requirement for correctness. + +function ownedTablesForId(db: DatabaseSync, widgetId: string, extraOwnedTables: string[] = []): string[] { + const prefix = `widget_${widgetId}_`; + const rows = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ? ESCAPE '\\'`) + .all(`${prefix.replace(/_/g, '\\_')}%`) as { name: string }[]; + return Array.from(new Set([...rows.map((r) => r.name), ...extraOwnedTables])); +} + +// Drops every table owned by a single widget (by the widget__ naming convention, plus +// any self-reported extraOwnedTables for a grandfathered name), and clears its widget_kv +// rows. Safe to call even if the widget's own code is missing/broken/never uninstall()ed. +export function sweepWidgetData(db: DatabaseSync, widgetId: string, extraOwnedTables: string[] = []) { + for (const table of ownedTablesForId(db, widgetId, extraOwnedTables)) { + db.exec(`DROP TABLE IF EXISTS "${table}";`); + } + db.prepare('DELETE FROM widget_kv WHERE widget_id = ?').run(widgetId); +} + +// Full-registry sweep, run once at every startup after the registry loads (see +// widgets/registry.ts) — catches anything left behind by a process crash mid-uninstall, or +// a manually-edited installed_widgets table, that the per-widget sweep above never got a +// chance to run for. +export function sweepOrphanedWidgetData(db: DatabaseSync, knownWidgetIds: string[]) { + const known = new Set(knownWidgetIds); + const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'widget\\_%' ESCAPE '\\'`).all() as { + name: string; + }[]; + for (const { name } of tables) { + if (name === 'widget_kv') continue; // the shared kv table itself, not a per-widget owned table + const id = name.slice('widget_'.length).split('_')[0]; + if (!known.has(id)) { + db.exec(`DROP TABLE IF EXISTS "${name}";`); + } + } + const kvWidgetIds = db.prepare('SELECT DISTINCT widget_id FROM widget_kv').all() as { widget_id: string }[]; + const orphanedKvIds = kvWidgetIds.map((r) => r.widget_id).filter((id) => !known.has(id)); + if (orphanedKvIds.length > 0) { + const placeholders = orphanedKvIds.map(() => '?').join(','); + db.prepare(`DELETE FROM widget_kv WHERE widget_id IN (${placeholders})`).run(...orphanedKvIds); + } +} diff --git a/backend/src/widgets/types.ts b/backend/src/widgets/types.ts new file mode 100644 index 0000000..b445d2b --- /dev/null +++ b/backend/src/widgets/types.ts @@ -0,0 +1,31 @@ +import type { DatabaseSync } from 'node:sqlite'; +import type { FastifyInstance } from 'fastify'; + +/** + * A widget's own lifecycle hooks — deliberately all-optional (unlike + * ingestion/adapters/base.ts's SourceAdapter, which has one mandatory fetch()) because + * unlike source adapters, widgets don't share a common downstream table or data shape + * to normalize into; each is a self-contained module that opts into only what it needs. + */ +export interface WidgetPlugin { + /** Stable id — also the table-name (`widget__*`) and directory-name namespace. Validated on install: /^[a-z0-9-]{1,40}$/ */ + id: string; + displayName: string; + version: string; + + /** Widget-owned schema. Must be idempotent (CREATE TABLE IF NOT EXISTS style), same contract as the host's own migrate(). */ + migrate?(db: DatabaseSync): void; + + /** Self-reported table names, for the uninstall safety-net sweep — belt-and-suspenders alongside the widget__ naming convention (see widgets/sweep.ts). */ + ownedTables?: string[]; + + /** Host owns the setInterval/enable-gating (see queue/scheduler.ts); the plugin just does the fetch-and-persist work. */ + poll?: { intervalMs: number; run(): Promise }; + + /** Called once at load time (startup, or immediately after a live upload). Admin routes registered here are auto-gated by the existing X-Api-Key preHandler (see api/auth.ts), since it matches on any /api/admin/* path. */ + registerPublicRoutes?(app: FastifyInstance): void; + registerAdminRoutes?(app: FastifyInstance): void; + + /** Best-effort cleanup, called before the host's safety-net sweep on delete. Should not assume its own migrate() succeeded or that external APIs are reachable — the sweep is the real guarantee, this is just an extension point. */ + uninstall?(db: DatabaseSync): void; +} diff --git a/backend/src/widgets/uninstall.ts b/backend/src/widgets/uninstall.ts new file mode 100644 index 0000000..6d6426e --- /dev/null +++ b/backend/src/widgets/uninstall.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { db } from '../storage/db/index.js'; +import * as installedWidgetsDb from '../storage/db/installedWidgets.js'; +import { logger } from '../storage/db/logs.js'; +import { loadedWidgets } from './registry.js'; +import { sweepWidgetData } from './sweep.js'; +import { stopWidgetPolling } from '../queue/scheduler.js'; + +const WIDGETS_INSTALLED_DIR = process.env.WIDGETS_INSTALLED_DIR || './data/widgets-installed'; +const WIDGETS_DATA_DIR = process.env.WIDGETS_DATA_DIR || './data/widgets-data'; + +// Fully removes an uploaded widget — safe to call even if its code is already +// broken/missing (the plugin.uninstall() step is best-effort; sweepWidgetData is the real +// guarantee, matching every table/kv row/on-disk file regardless of the widget's own +// cooperation). Callers (see api/admin.ts's DELETE /api/admin/widgets/:id) are +// responsible for rejecting built-in widgets before calling this. +// +// Returns whether the deleted widget had declared routes, i.e. whether the caller needs +// to swap the live server afterward (see server.ts's swapLiveServer()) — deliberately NOT +// done inline here: this runs inside the DELETE route's own request handler, and that +// handler is served by the very Fastify instance a swap would close, which drops the +// response before the client ever sees it (hit this for real in testing). The caller must +// send its response first, then swap — see api/admin.ts. +export async function uninstallWidget(id: string): Promise { + stopWidgetPolling(id); + + const plugin = loadedWidgets.get(id); + const hadRoutes = !!(plugin?.registerPublicRoutes || plugin?.registerAdminRoutes); + if (plugin?.uninstall) { + try { + plugin.uninstall(db); + } catch (err) { + logger.error('widgets', `uninstall() hook failed for "${id}": ${(err as Error).message}`); + } + } + + const row = installedWidgetsDb.getInstalled(id); + sweepWidgetData(db, id, row?.ownedTables ?? []); + + fs.rmSync(path.join(WIDGETS_INSTALLED_DIR, id), { recursive: true, force: true }); + fs.rmSync(path.join(WIDGETS_DATA_DIR, id), { recursive: true, force: true }); + + loadedWidgets.delete(id); + installedWidgetsDb.deleteInstalled(id); + + return hadRoutes; +} diff --git a/backend/src/weather/client.ts b/backend/src/widgets/weather/client.ts similarity index 100% rename from backend/src/weather/client.ts rename to backend/src/widgets/weather/client.ts diff --git a/backend/src/widgets/weather/db.ts b/backend/src/widgets/weather/db.ts new file mode 100644 index 0000000..52f4189 --- /dev/null +++ b/backend/src/widgets/weather/db.ts @@ -0,0 +1,103 @@ +import { getKv, setKv } from '../../storage/db/widgetKv.js'; + +export interface WeatherHourEntry { + time: string; + temp: number; + conditionText: string; + icon: string; +} + +export interface WeatherDayEntry { + date: string; + tempMax: number; + tempMin: number; + conditionText: string; + icon: string; +} + +export interface WeatherAlert { + id: string; + event: string; + headline: string; + severity: string; + expires: string; +} + +export interface WeatherConfig { + locationName: string | null; + latitude: number | null; + longitude: number | null; + unit: 'celsius' | 'fahrenheit'; + windUnit: 'mph' | 'kph'; + pressureUnit: 'inHg' | 'hPa'; +} + +export interface WeatherCache { + current: { + temp: number; + /** Apparent temperature (Open-Meteo's own heat-index/wind-chill blend) — "Feels like". */ + feelsLike: number; + conditionText: string; + icon: string; + /** Percent, 0-100. */ + humidity: number; + /** Percent, 0-100 — the current hour's forecast precipitation probability (there's no true instantaneous "chance of rain" measurement). */ + precipitationChance: number; + /** Already in the admin's configured windUnit. */ + windSpeed: number; + /** 8-point compass abbreviation, e.g. "NW". */ + windDirection: string; + /** Already in the admin's configured pressureUnit. */ + pressure: number; + sunrise: string; + sunset: string; + } | null; + hourly: WeatherHourEntry[]; + daily: WeatherDayEntry[]; + /** Active NWS alerts (flash flood, hurricane, blizzard, etc.) for the configured location — US-only, empty elsewhere. See client.ts's fetchActiveAlerts. */ + alerts: WeatherAlert[]; + updatedAt: string | null; +} + +const WIDGET_ID = 'weather'; + +const DEFAULT_CONFIG: WeatherConfig = { + locationName: null, + latitude: null, + longitude: null, + unit: 'fahrenheit', + windUnit: 'mph', + pressureUnit: 'inHg' +}; + +const DEFAULT_CACHE: WeatherCache = { + current: null, + hourly: [], + daily: [], + alerts: [], + updatedAt: null +}; + +// Config (admin-settable: location/units) and cache (poll-computed forecast) are stored as +// two separate widget_kv keys — mirrors the singleton-row split stock_tickers/bookmarks +// tables already had from global_settings, just via the generic kv store instead of a +// bespoke table (this widget has no data shaped like rows, so no dedicated table is needed). +export function getConfig(): WeatherConfig { + return getKv(WIDGET_ID, 'config') ?? DEFAULT_CONFIG; +} + +export function setConfig(patch: Partial): WeatherConfig { + const merged = { ...getConfig(), ...patch }; + setKv(WIDGET_ID, 'config', merged); + return merged; +} + +export function getCache(): WeatherCache { + return getKv(WIDGET_ID, 'cache') ?? DEFAULT_CACHE; +} + +export function setCache(patch: Partial): WeatherCache { + const merged = { ...getCache(), ...patch }; + setKv(WIDGET_ID, 'cache', merged); + return merged; +} diff --git a/backend/src/widgets/weather/plugin.ts b/backend/src/widgets/weather/plugin.ts new file mode 100644 index 0000000..3a786b3 --- /dev/null +++ b/backend/src/widgets/weather/plugin.ts @@ -0,0 +1,47 @@ +import type { WidgetPlugin } from '../types.js'; +import { logger } from '../../storage/db/logs.js'; +import { geocodeLocation } from './client.js'; +import * as weatherDb from './db.js'; +import { pollWeatherNow } from './poll.js'; + +// Sidebar "Weather" widget — config (location/units) and cache (forecast) live entirely in +// widget_kv (see db.ts); no bespoke table needed. Built-in and non-deletable (source: +// 'builtin' in installed_widgets), but otherwise a full WidgetPlugin like an uploaded one. +export const weatherPlugin: WidgetPlugin = { + id: 'weather', + displayName: 'Weather', + version: '1.0.0', + + poll: { + intervalMs: 45 * 60_000, + run: pollWeatherNow + }, + + registerPublicRoutes(app) { + app.get('/api/widget/weather', async () => ({ ...weatherDb.getConfig(), ...weatherDb.getCache() })); + }, + + registerAdminRoutes(app) { + // Returns config + cache together (same shape as the public route) so the admin + // tab can show "currently showing X" status alongside the location/unit form. + app.get('/api/admin/widget/weather', async () => ({ ...weatherDb.getConfig(), ...weatherDb.getCache() })); + + app.patch('/api/admin/widget/weather', async (req) => { + weatherDb.setConfig(req.body as any); + // Poll immediately rather than waiting for the next scheduler tick (up to 45 + // minutes) — the admin just changed the location/unit and expects to see it reflected. + pollWeatherNow().catch((err) => logger.error('weather', `Immediate poll failed: ${err.message}`)); + return { ...weatherDb.getConfig(), ...weatherDb.getCache() }; + }); + + app.get('/api/admin/widget/weather/geocode', async (req, reply) => { + const { query } = req.query as { query?: string }; + if (!query || !query.trim()) return reply.code(400).send({ error: 'query required' }); + try { + return await geocodeLocation(query.trim()); + } catch (err) { + return reply.code(502).send({ error: `Geocoding service unreachable: ${(err as Error).message}` }); + } + }); + } +}; diff --git a/backend/src/weather/poller.ts b/backend/src/widgets/weather/poll.ts similarity index 52% rename from backend/src/weather/poller.ts rename to backend/src/widgets/weather/poll.ts index 18ac676..e1ffbe0 100644 --- a/backend/src/weather/poller.ts +++ b/backend/src/widgets/weather/poll.ts @@ -1,26 +1,29 @@ -import * as settingsDb from '../storage/db/settings.js'; -import { logger } from '../storage/db/logs.js'; +import { logger } from '../../storage/db/logs.js'; import { fetchForecast, fetchActiveAlerts } from './client.js'; +import * as weatherDb from './db.js'; +import type { WeatherCache } from './db.js'; -// Called on a schedule (see queue/scheduler.ts) and immediately after the admin changes -// the weather location/units (see api/admin.ts) — writes straight into global_settings' -// weather_* columns via settingsDb, same singleton-row approach as retention. +// Called on a schedule (see queue/scheduler.ts, via plugin.poll) and immediately after the +// admin changes the weather location/units (see plugin.ts's admin routes) — writes into +// the widget's own widget_kv cache entry via weatherDb, same singleton-cache approach as +// before, just no longer riding on global_settings. export async function pollWeatherNow(): Promise { - const { weather } = settingsDb.getSettings(); - if (weather.latitude === null || weather.longitude === null) { + const config = weatherDb.getConfig(); + if (config.latitude === null || config.longitude === null) { // No location configured yet — not an error, just nothing to do. return; } - let forecastUpdate: Partial = {}; + const cache = weatherDb.getCache(); + let forecastUpdate: Partial = {}; let forecastSucceeded = false; try { const { current, hourly, daily } = await fetchForecast( - weather.latitude, - weather.longitude, - weather.unit, - weather.windUnit, - weather.pressureUnit + config.latitude, + config.longitude, + config.unit, + config.windUnit, + config.pressureUnit ); forecastUpdate = { current, hourly, daily }; forecastSucceeded = true; @@ -33,20 +36,17 @@ export async function pollWeatherNow(): Promise { // reliably (and expectedly) for every non-US location. A failure here shouldn't // touch the forecast update above, and unlike a stale forecast, a stale alert that's // since expired is worse to keep showing than none at all — clear to empty on failure. - let alerts = weather.alerts; + let alerts = cache.alerts; try { - alerts = await fetchActiveAlerts(weather.latitude, weather.longitude); + alerts = await fetchActiveAlerts(config.latitude, config.longitude); } catch (err) { alerts = []; logger.warn('weather', `Alerts poll failed (expected outside the US): ${(err as Error).message}`); } - settingsDb.updateSettings({ - weather: { - ...weather, - ...forecastUpdate, - alerts, - updatedAt: forecastSucceeded ? new Date().toISOString() : weather.updatedAt - } + weatherDb.setCache({ + ...forecastUpdate, + alerts, + updatedAt: forecastSucceeded ? new Date().toISOString() : cache.updatedAt }); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d91bfd1..2ad912c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -915,9 +915,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.69.2", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.2.tgz", - "integrity": "sha512-CMdPDbYjRwRu4KXTxBVMuOpFPCt1i/v0ANennotec+K9Cmb2e3w2yYzJiC6Vh/WSvm9Khi5sJMZa0rJPqfHlDw==", + "version": "2.70.2", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.2.tgz", + "integrity": "sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==", "dev": true, "license": "MIT", "dependencies": { @@ -1578,9 +1578,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "dev": true, "funding": [ { @@ -1638,9 +1638,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -1658,7 +1658,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts index 22bbeb1..c385039 100644 --- a/frontend/src/lib/adminApi.ts +++ b/frontend/src/lib/adminApi.ts @@ -12,8 +12,12 @@ import type { GeocodeResult, AdminStockTicker, AdminBookmark, + AdminBookmarksConfig, Poe2BrowseEntry, AdminPoe2Entry, + AdminWeatherSettings, + InstalledWidget, + WidgetUploadManifest, PipelineStats, ModelContextInfo, ForceRecapResult @@ -195,47 +199,63 @@ export const getLogs = (filters: { level?: 'info' | 'warn' | 'error'; limit?: nu export const getPipelineStats = (fetchFn?: typeof fetch) => request('/api/admin/pipeline-stats', {}, fetchFn); -// Weather — config/cache lives on AdminSettings.weather (see updateSettings above); this -// is just the geocoding lookup used to resolve a typed city name to lat/lon. +// Weather — config/cache now live behind the widget's own dedicated admin route (see +// backend/src/widgets/weather/plugin.ts) rather than riding along on AdminSettings. +export const getWeatherConfig = (fetchFn?: typeof fetch) => + request('/api/admin/widget/weather', {}, fetchFn); + +export const updateWeatherConfig = (patch: Partial, fetchFn?: typeof fetch) => + request('/api/admin/widget/weather', { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn); + export const geocodeLocation = (query: string, fetchFn?: typeof fetch) => - request(`/api/admin/weather/geocode?query=${encodeURIComponent(query)}`, {}, fetchFn); + request(`/api/admin/widget/weather/geocode?query=${encodeURIComponent(query)}`, {}, fetchFn); // Stocks export const getStockTickers = (fetchFn?: typeof fetch) => - request('/api/admin/stocks', {}, fetchFn); + request('/api/admin/widget/stocks', {}, fetchFn); export const addStockTicker = (label: string, symbol: string, fetchFn?: typeof fetch) => - request('/api/admin/stocks', { method: 'POST', body: JSON.stringify({ label, symbol }) }, fetchFn); + request('/api/admin/widget/stocks', { method: 'POST', body: JSON.stringify({ label, symbol }) }, fetchFn); export const updateStockTicker = (id: string, patch: { label?: string; symbol?: string }, fetchFn?: typeof fetch) => - request(`/api/admin/stocks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn); + request(`/api/admin/widget/stocks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn); export const deleteStockTicker = (id: string, fetchFn?: typeof fetch) => - request(`/api/admin/stocks/${id}`, { method: 'DELETE' }, fetchFn); + request(`/api/admin/widget/stocks/${id}`, { method: 'DELETE' }, fetchFn); // Bookmarks export const getAdminBookmarks = (fetchFn?: typeof fetch) => - request('/api/admin/bookmarks', {}, fetchFn); + request('/api/admin/widget/bookmarks', {}, fetchFn); export const addBookmark = (name: string, url: string, isPrivate = false, fetchFn?: typeof fetch) => request( - '/api/admin/bookmarks', + '/api/admin/widget/bookmarks', { method: 'POST', body: JSON.stringify({ name, url, isPrivate }) }, fetchFn ); export const updateBookmark = (id: string, patch: { name?: string; url?: string; isPrivate?: boolean }, fetchFn?: typeof fetch) => - request(`/api/admin/bookmarks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn); + request(`/api/admin/widget/bookmarks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn); export const deleteBookmark = (id: string, fetchFn?: typeof fetch) => - request(`/api/admin/bookmarks/${id}`, { method: 'DELETE' }, fetchFn); + request(`/api/admin/widget/bookmarks/${id}`, { method: 'DELETE' }, fetchFn); -// PoE2 — league is always auto-detected, never admin-set (see poe2/poller.ts). +export const getBookmarksConfig = (fetchFn?: typeof fetch) => + request('/api/admin/widget/bookmarks/config', {}, fetchFn); + +export const updateBookmarksConfig = (columns: 1 | 2 | 3, fetchFn?: typeof fetch) => + request( + '/api/admin/widget/bookmarks/config', + { method: 'PATCH', body: JSON.stringify({ columns }) }, + fetchFn + ); + +// PoE2 — league is always auto-detected, never admin-set (see widgets/poe2/poll.ts). export const browsePoe2Currencies = (fetchFn?: typeof fetch) => - request('/api/admin/poe2/browse', {}, fetchFn); + request('/api/admin/widget/poe2/browse', {}, fetchFn); export const getPoe2Watchlist = (fetchFn?: typeof fetch) => - request('/api/admin/poe2/watchlist', {}, fetchFn); + request('/api/admin/widget/poe2/watchlist', {}, fetchFn); export const addPoe2WatchlistEntry = ( base: { currencyId: string; name: string }, @@ -243,10 +263,24 @@ export const addPoe2WatchlistEntry = ( fetchFn?: typeof fetch ) => request( - '/api/admin/poe2/watchlist', + '/api/admin/widget/poe2/watchlist', { method: 'POST', body: JSON.stringify({ base, quote }) }, fetchFn ); export const removePoe2WatchlistEntry = (id: string, fetchFn?: typeof fetch) => - request(`/api/admin/poe2/watchlist/${id}`, { method: 'DELETE' }, fetchFn); + request(`/api/admin/widget/poe2/watchlist/${id}`, { method: 'DELETE' }, fetchFn); + +// Pluggable widgets (upload/list/enable/delete) — see backend/src/widgets/install.ts, +// uninstall.ts. Built-in widgets (source: 'builtin') 400 on deleteWidget. +export const listWidgets = (fetchFn?: typeof fetch) => + request('/api/admin/widgets', {}, fetchFn); + +export const installWidget = (manifest: WidgetUploadManifest, files: Record, fetchFn?: typeof fetch) => + request<{ id: string }>('/api/admin/widgets', { method: 'POST', body: JSON.stringify({ manifest, files }) }, fetchFn); + +export const setWidgetEnabled = (id: string, enabled: boolean, fetchFn?: typeof fetch) => + request(`/api/admin/widgets/${id}`, { method: 'PATCH', body: JSON.stringify({ enabled }) }, fetchFn); + +export const deleteWidget = (id: string, fetchFn?: typeof fetch) => + request(`/api/admin/widgets/${id}`, { method: 'DELETE' }, fetchFn); diff --git a/frontend/src/lib/adminTypes.ts b/frontend/src/lib/adminTypes.ts index f7041c9..ac127cf 100644 --- a/frontend/src/lib/adminTypes.ts +++ b/frontend/src/lib/adminTypes.ts @@ -95,6 +95,11 @@ export interface AdminBookmark { isPrivate: boolean; } +/** Response from GET/PATCH /api/admin/widget/bookmarks/config — how many columns the sidebar/widget layout uses. */ +export interface AdminBookmarksConfig { + columns: 1 | 2 | 3; +} + export interface Poe2BrowseEntry { id: string; name: string; @@ -113,12 +118,6 @@ export interface AdminPoe2Entry { lastError: string | null; } -export interface AdminPoe2Settings { - leagueId: string | null; - leagueName: string | null; - updatedAt: string | null; -} - export interface AdminWidgetsEnabled { weather: boolean; stocks: boolean; @@ -126,6 +125,27 @@ export interface AdminWidgetsEnabled { poe2: boolean; } +/** A row from the installed_widgets registry — see backend/src/storage/db/installedWidgets.ts. */ +export interface InstalledWidget { + id: string; + displayName: string; + source: 'builtin' | 'uploaded'; + enabled: boolean; + priorityRank: number; + version: string; + frontendEntry: string | null; + installedAt: string; +} + +/** Body for POST /api/admin/widgets — see backend/src/widgets/manifest.ts. */ +export interface WidgetUploadManifest { + id: string; + displayName: string; + version: string; + entry: string; + frontendEntry?: string; +} + export interface AdminSettings { mergeStrictness: 1 | 2 | 3 | 4 | 5; holdBeforePublishMinutes: number; @@ -150,8 +170,6 @@ export interface AdminSettings { widgetOrder: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[]; retention: RetentionSettings; categoryPriority: CategoryPriority[]; - weather: AdminWeatherSettings; - poe2: AdminPoe2Settings; } export interface AdminSource { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 387dd23..c76fe33 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,5 @@ import { getBackendUrl } from './config'; -import type { MergedArticle, Tag, TrackedEventPublic, Category, Weather, StockTicker, Bookmark, Poe2Data, WidgetsEnabled } from './types'; +import type { MergedArticle, Tag, TrackedEventPublic, Category, Weather, StockTicker, BookmarksFeed, Poe2Data, WidgetsEnabled } from './types'; async function get(path: string, fetchFn: typeof fetch = fetch): Promise { // credentials: 'include' so the private-access cookie (see lib/privateAccess.ts) @@ -47,19 +47,19 @@ export function getCategories(fetchFn?: typeof fetch): Promise { } export function getWeather(fetchFn?: typeof fetch): Promise { - return get('/api/weather', fetchFn); + return get('/api/widget/weather', fetchFn); } export function getStocks(fetchFn?: typeof fetch): Promise { - return get('/api/stocks', fetchFn); + return get('/api/widget/stocks', fetchFn); } -export function getBookmarks(fetchFn?: typeof fetch): Promise { - return get('/api/bookmarks', fetchFn); +export function getBookmarks(fetchFn?: typeof fetch): Promise { + return get('/api/widget/bookmarks', fetchFn); } export function getPoe2(fetchFn?: typeof fetch): Promise { - return get('/api/poe2', fetchFn); + return get('/api/widget/poe2', fetchFn); } export function getWidgetsEnabled(fetchFn?: typeof fetch): Promise { diff --git a/frontend/src/lib/components/admin/BookmarksTab.svelte b/frontend/src/lib/components/admin/BookmarksTab.svelte index 9cac122..9a6a3f3 100644 --- a/frontend/src/lib/components/admin/BookmarksTab.svelte +++ b/frontend/src/lib/components/admin/BookmarksTab.svelte @@ -1,12 +1,20 @@ {#each widgetOrder as key, i (key)} @@ -57,13 +137,139 @@ onMoveDown={() => move(i, 1)} > {#if key === 'weather'} - + {:else if key === 'stocks'} {:else if key === 'bookmarks'} - + {:else if key === 'poe2'} - + {/if} {/each} + +
+
+ Pluggable widgets + +
+ + {#if showUpload} +
+ + + + + + {#if uploadError}

{uploadError}

{/if} + +
+ {/if} + + {#if pluggable.length === 0} +

No uploaded widgets installed.

+ {:else} +
+ {#each pluggable as w (w.id)} +
+ {w.displayName} + togglePluggable(w)} role="button" tabindex="0"> + {w.enabled ? 'Active' : 'Disabled'} + + +
+ {/each} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/sidebar/BookmarksWidget.svelte b/frontend/src/lib/components/sidebar/BookmarksWidget.svelte index 86b3fd3..cc44775 100644 --- a/frontend/src/lib/components/sidebar/BookmarksWidget.svelte +++ b/frontend/src/lib/components/sidebar/BookmarksWidget.svelte @@ -1,13 +1,13 @@
Bookmarks {#if bookmarks.length > 0} -
+
1} style:grid-template-columns={columns > 1 ? `repeat(${columns}, 1fr)` : undefined}> {#each bookmarks as bookmark (bookmark.id)} {bookmark.name} {/each} @@ -33,6 +33,10 @@ flex-direction: column; margin-top: 8px; } + .list.grid { + display: grid; + gap: 6px; + } .row { font-size: 13px; padding: 6px 0; @@ -42,6 +46,15 @@ .row:first-child { border-top: none; } + .list.grid .row { + border-top: none; + padding: 6px 8px; + background: var(--surface-2); + border-radius: var(--radius); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } .row:hover { color: var(--text-accent); } diff --git a/frontend/src/lib/components/sidebar/DynamicWidgetSlot.svelte b/frontend/src/lib/components/sidebar/DynamicWidgetSlot.svelte new file mode 100644 index 0000000..4bbb7bb --- /dev/null +++ b/frontend/src/lib/components/sidebar/DynamicWidgetSlot.svelte @@ -0,0 +1,67 @@ + + +
+ {#if error} +
{displayName}
+

Failed to load: {error}

+ {/if} +
+
+ + diff --git a/frontend/src/lib/components/sidebar/GenericWidgetCard.svelte b/frontend/src/lib/components/sidebar/GenericWidgetCard.svelte new file mode 100644 index 0000000..528be1f --- /dev/null +++ b/frontend/src/lib/components/sidebar/GenericWidgetCard.svelte @@ -0,0 +1,124 @@ + + +
+
+ {report?.title ?? displayName} +
+ {#if report?.headline} +
+ {report.headline.value} + {#if report.headline.delta}{report.headline.delta}{/if} +
+ {/if} + {#if report?.rows && report.rows.length > 0} +
+ {#each report.rows as row, i (i)} +
+ {row.label} + {row.value} +
+ {/each} +
+ {:else if !report?.headline} +

{loaded ? 'No data yet' : 'Loading…'}

+ {/if} +
+ + diff --git a/frontend/src/lib/components/sidebar/Sidebar.svelte b/frontend/src/lib/components/sidebar/Sidebar.svelte index 8dcda03..c214b36 100644 --- a/frontend/src/lib/components/sidebar/Sidebar.svelte +++ b/frontend/src/lib/components/sidebar/Sidebar.svelte @@ -1,10 +1,12 @@