From 5319562c94aad3174e2c08f31cbcea98c0d6edcc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 2 May 2026 01:58:30 +0200 Subject: [PATCH] feat: add update-available detection --- VERSION | 2 +- app/[locale]/login/page.tsx | 57 ++++- app/admin/layout.tsx | 31 ++- app/admin/version/page.tsx | 237 ++++++++++++++++++++ app/api/admin/version/route.ts | 66 ++++++ app/api/system/update-status/route.ts | 22 ++ components/layout/navigation-rail.tsx | 30 ++- components/settings/about-data-settings.tsx | 36 ++- components/system/update-banner.tsx | 69 ++++++ instrumentation.node.ts | 44 +--- lib/version-check/fetcher.ts | 82 +++++++ lib/version-check/index.ts | 7 + lib/version-check/sender.ts | 100 +++++++++ lib/version-check/state.ts | 62 +++++ lib/version-check/types.ts | 25 +++ stores/update-store.ts | 148 ++++++++++++ 16 files changed, 969 insertions(+), 49 deletions(-) create mode 100644 app/admin/version/page.tsx create mode 100644 app/api/admin/version/route.ts create mode 100644 app/api/system/update-status/route.ts create mode 100644 components/system/update-banner.tsx create mode 100644 lib/version-check/fetcher.ts create mode 100644 lib/version-check/index.ts create mode 100644 lib/version-check/sender.ts create mode 100644 lib/version-check/state.ts create mode 100644 lib/version-check/types.ts create mode 100644 stores/update-store.ts diff --git a/VERSION b/VERSION index dc1e644a..bc80560f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.0 +1.5.0 diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 4b5590e2..6385f1ee 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -16,6 +16,7 @@ import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; +import { useUpdateStore, selectBanner } from "@/stores/update-store"; const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; @@ -28,7 +29,13 @@ const THEME_OPTIONS = [ function VersionBadge() { const [copied, setCopied] = useState(false); - const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`; + const banner = useUpdateStore(useShallow(selectBanner)); + const dismiss = useUpdateStore((s) => s.dismiss); + const startPolling = useUpdateStore((s) => s.startPolling); + + useEffect(() => { startPolling(); }, [startPolling]); + + const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}${banner?.latest ? `\nLatest: ${banner.latest}` : ""}`; const handleCopy = () => { navigator.clipboard.writeText(versionInfo).then(() => { @@ -37,16 +44,49 @@ function VersionBadge() { }); }; + const isRed = banner?.variant === "red"; + const triggerText = !banner + ? `v${APP_VERSION}` + : banner.severity === "security" + ? "Security update available" + : banner.severity === "deprecated" + ? "Version no longer supported" + : "New version available"; + + const triggerColor = !banner + ? "text-muted-foreground/40" + : isRed + ? "text-red-600/80 dark:text-red-400/80 hover:text-red-600 dark:hover:text-red-400" + : "text-amber-600/80 dark:text-amber-400/80 hover:text-amber-600 dark:hover:text-amber-400"; + + const triggerClass = cn( + "peer text-center text-xs transition-colors", + triggerColor, + banner?.url ? "cursor-pointer underline-offset-2 hover:underline" : "cursor-default", + ); + + const trigger = banner?.url ? ( + + {triggerText} + + ) : ( +

{triggerText}

+ ); + return (
-

- v{APP_VERSION} -

+ {trigger}

Version: {APP_VERSION}

Build: {GIT_COMMIT}

+ {banner?.latest && ( +

Latest: {banner.latest}

+ )} + {banner?.advisory && ( +

{banner.advisory}

+ )}
+ {banner?.dismissible && ( + + )}
diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 6924a22c..48737ae3 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -15,6 +15,7 @@ import { Puzzle, SwatchBook, Activity, + Package, Mail, Calendar, BookUser, @@ -30,6 +31,7 @@ import { useThemeStore } from '@/stores/theme-store'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { useAuthStore } from '@/stores/auth-store'; +import { useUpdateStore, selectHasUpdate } from '@/stores/update-store'; import { apiFetch } from '@/lib/browser-navigation'; const NAV_GROUPS = [ @@ -59,6 +61,7 @@ const NAV_GROUPS = [ { label: 'System', items: [ + { href: '/admin/version', label: 'Version', icon: Package }, { href: '/admin/telemetry', label: 'Telemetry', icon: Activity }, { href: '/admin/logs', label: 'Audit Log', icon: ScrollText }, ], @@ -78,6 +81,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) ? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl) : (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl); + // Match the navigation rail: red for security/deprecated, amber for normal. + const hasUpdate = useUpdateStore(selectHasUpdate); + const updateSeverity = useUpdateStore((s) => s.status?.severity); + const startUpdatePolling = useUpdateStore((s) => s.startPolling); + useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]); + const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated'; + useEffect(() => { setMobileNavOpen(false); }, [pathname]); @@ -170,6 +180,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) {group.items.map(({ href, label, icon: Icon }) => { const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href); + const showDot = href === '/admin/version' && hasUpdate; return ( - + + + {showDot && ( + + )} + {label} ); diff --git a/app/admin/version/page.tsx b/app/admin/version/page.tsx new file mode 100644 index 00000000..398aba28 --- /dev/null +++ b/app/admin/version/page.tsx @@ -0,0 +1,237 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + Loader2, + RefreshCw, + CheckCircle2, + AlertTriangle, + ShieldAlert, + ExternalLink, +} from 'lucide-react'; +import { SettingsSection, SettingItem } from '@/components/settings/settings-section'; +import { apiFetch } from '@/lib/browser-navigation'; +import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types'; + +interface VersionAdminStatus { + current: string; + build: string; + endpoint: string; + defaultEndpoint: string; + disabledByEnv: boolean; + lastCheckedAt: string | null; + lastSuccessAt: string | null; + nextScheduledAt: string | null; + status: UpdateStatus | null; +} + +function timeAgo(iso: string | null): string { + if (!iso) return 'never'; + const d = Date.now() - new Date(iso).getTime(); + if (d < 0) return new Date(iso).toLocaleString(); + const m = Math.floor(d / 60000); + if (m < 1) return 'just now'; + if (m < 60) return `${m} min ago`; + const h = Math.floor(m / 60); + if (h < 48) return `${h} hours ago`; + return `${Math.floor(h / 24)} days ago`; +} + +function severityChip(severity: UpdateSeverity) { + switch (severity) { + case 'security': + return { + label: 'Security update', + className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30', + Icon: ShieldAlert, + }; + case 'deprecated': + return { + label: 'Deprecated', + className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30', + Icon: ShieldAlert, + }; + case 'normal': + return { + label: 'Update available', + className: 'bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30', + Icon: AlertTriangle, + }; + case 'unknown': + return { + label: 'Unknown', + className: 'bg-muted text-muted-foreground border-border', + Icon: AlertTriangle, + }; + case 'none': + default: + return { + label: 'Up to date', + className: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30', + Icon: CheckCircle2, + }; + } +} + +export default function AdminVersionPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [checking, setChecking] = useState(false); + const [checkResult, setCheckResult] = useState<{ ok: boolean; msg: string } | null>(null); + + async function refresh(): Promise { + setLoading(true); + try { + const r = await apiFetch('/api/admin/version'); + if (!r.ok) throw new Error('failed to load'); + setData((await r.json()) as VersionAdminStatus); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + } + useEffect(() => { void refresh(); }, []); + + async function checkNow(): Promise { + setChecking(true); + setCheckResult(null); + try { + const r = await apiFetch('/api/admin/version', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'check-now' }), + }); + const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string }; + setCheckResult({ + ok: !!j.ok, + msg: j.ok ? 'Update check completed.' : `Failed: ${j.error ?? 'unknown'}`, + }); + await refresh(); + } finally { + setChecking(false); + } + } + + if (loading || !data) { + return ( +
+ loading… +
+ ); + } + + const status = data.status; + const chip = severityChip(status?.severity ?? 'none'); + const ChipIcon = chip.Icon; + const releaseUrl = status?.url ?? null; + const newer = status?.latest && status.latest !== data.current ? status.latest : null; + + return ( +
+
+
+

Version

+

+ Hourly check against the Bulwark version server. Severity is decided server-side and + disable with BULWARK_UPDATE_CHECK=off. +

+
+ +
+ + {checkResult && ( +
+ {checkResult.msg} +
+ )} + + + + + + {chip.label} + + + + {data.current} + + {newer && ( + + {releaseUrl ? ( + + {newer} + + ) : ( + {newer} + )} + + )} + {status?.advisory && ( + + {status.advisory} + + )} + + + + + {timeAgo(data.lastCheckedAt)} + + + {timeAgo(data.lastSuccessAt)} + + + {timeAgo(data.nextScheduledAt)} + + {status?.checkedAt && ( + + {new Date(status.checkedAt).toLocaleString()} + + )} + + + + + + {data.endpoint} + + + + + {data.disabledByEnv ? 'Yes' : 'No'} + + + +
+ ); +} diff --git a/app/api/admin/version/route.ts b/app/api/admin/version/route.ts new file mode 100644 index 00000000..813beeb2 --- /dev/null +++ b/app/api/admin/version/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAdminAuth } from '@/lib/admin/session'; +import { logger } from '@/lib/logger'; +import { + loadState, + checkOnce, + effectiveEndpoint, + disabledByEnv, + DEFAULT_VERSION_ENDPOINT, +} from '@/lib/version-check'; + +/** + * GET /api/admin/version + * Returns the cached update status, last check times, and effective config. + */ +export async function GET() { + try { + const auth = await requireAdminAuth(); + if ('error' in auth) return auth.error; + + const state = await loadState(); + return NextResponse.json( + { + current: process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0', + build: process.env.NEXT_PUBLIC_GIT_COMMIT || 'unknown', + endpoint: effectiveEndpoint(state), + defaultEndpoint: DEFAULT_VERSION_ENDPOINT, + disabledByEnv: disabledByEnv(), + lastCheckedAt: state.lastCheckedAt, + lastSuccessAt: state.lastSuccessAt, + nextScheduledAt: state.nextScheduledAt, + status: state.status, + }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } catch (err) { + logger.error('version admin GET error', { + error: err instanceof Error ? err.message : 'unknown', + }); + return NextResponse.json({ error: 'failed' }, { status: 500 }); + } +} + +/** + * POST /api/admin/version + * { action: 'check-now' } — force a fresh upstream fetch. + */ +export async function POST(req: NextRequest) { + try { + const auth = await requireAdminAuth(); + if ('error' in auth) return auth.error; + + const body = (await req.json().catch(() => null)) as { action?: string } | null; + if (!body || body.action !== 'check-now') { + return NextResponse.json({ error: 'unknown action' }, { status: 400 }); + } + + const result = await checkOnce({ reason: 'admin-trigger' }); + return NextResponse.json(result); + } catch (err) { + logger.error('version admin POST error', { + error: err instanceof Error ? err.message : 'unknown', + }); + return NextResponse.json({ error: 'failed' }, { status: 500 }); + } +} diff --git a/app/api/system/update-status/route.ts b/app/api/system/update-status/route.ts new file mode 100644 index 00000000..d3af2131 --- /dev/null +++ b/app/api/system/update-status/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; +import { loadState } from '@/lib/version-check'; + +// Public endpoint that returns the latest cached update status. Fed by the +// background scheduler started in instrumentation.node.ts; we never trigger +// a fresh upstream fetch from this route, so an unauthenticated client cannot +// use it to amplify traffic to the version server. +export async function GET() { + const state = await loadState(); + return NextResponse.json( + { + status: state.status, + lastCheckedAt: state.lastCheckedAt, + lastSuccessAt: state.lastSuccessAt, + }, + { + headers: { + 'Cache-Control': 'no-store', + }, + }, + ); +} diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 11ecedbc..ea94803f 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -16,6 +16,7 @@ import { useSettingsStore } from "@/stores/settings-store"; import { usePolicyStore } from "@/stores/policy-store"; import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; +import { useUpdateStore, selectHasUpdate } from "@/stores/update-store"; import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot"; import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils"; import { cn, formatFileSize } from "@/lib/utils"; @@ -175,6 +176,11 @@ export function NavigationRail({ const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : []; const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0; const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); + const hasUpdate = useUpdateStore(selectHasUpdate); + const updateSeverity = useUpdateStore((s) => s.status?.severity); + const startUpdatePolling = useUpdateStore((s) => s.startPolling); + useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]); + const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated'; // Account list for rail const accounts = useAccountStore((s) => s.accounts); @@ -345,7 +351,18 @@ export function NavigationRail({ "text-muted-foreground hover:text-foreground" )} > - + + + {hasUpdate && ( + + )} + {t("admin") || "Admin"} )} @@ -515,10 +532,19 @@ export function NavigationRail({ {isStalwartAdmin && ( + {hasUpdate && ( + + )} )} diff --git a/components/settings/about-data-settings.tsx b/components/settings/about-data-settings.tsx index 6997fcc1..6488d1cb 100644 --- a/components/settings/about-data-settings.tsx +++ b/components/settings/about-data-settings.tsx @@ -1,18 +1,51 @@ "use client"; -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; import { useConfig } from '@/hooks/use-config'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { Button } from '@/components/ui/button'; import { usePolicyStore } from '@/stores/policy-store'; +import { useUpdateStore } from '@/stores/update-store'; import { ExternalLink } from 'lucide-react'; +import { cn } from '@/lib/utils'; import { SpamSiegeGame } from './spam-siege-game'; const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; +function VersionUpdateTag() { + const status = useUpdateStore((s) => s.status); + const startPolling = useUpdateStore((s) => s.startPolling); + + useEffect(() => { + startPolling(); + }, [startPolling]); + + if (!status?.updateAvailable) return null; + if (status.severity === 'unknown' || status.severity === 'none') return null; + + const important = status.severity === 'security' || status.severity === 'deprecated'; + const label = + status.severity === 'security' ? 'security' + : status.severity === 'deprecated' ? 'deprecated' + : status.latest ?? 'update'; + + return ( + + {important ? label : `update: ${label}`} + + ); +} + export function AboutDataSettings() { const t = useTranslations('settings.advanced'); const tCommon = useTranslations('common'); @@ -106,6 +139,7 @@ export function AboutDataSettings() {

v{APP_VERSION} ({GIT_COMMIT}) +

diff --git a/components/system/update-banner.tsx b/components/system/update-banner.tsx new file mode 100644 index 00000000..5080957a --- /dev/null +++ b/components/system/update-banner.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useEffect } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { AlertTriangle, X } from "lucide-react"; +import { useUpdateStore, selectBanner } from "@/stores/update-store"; +import { cn } from "@/lib/utils"; + +// Single-line, low-key update notice. Lives next to the version badge on the +// login screen — deliberately understated so it doesn't distract from the +// auth flow. Red variants (security / deprecated) still use red text but +// stay the same compact shape. +export function UpdateNotice({ className }: { className?: string }) { + const banner = useUpdateStore(useShallow(selectBanner)); + const dismiss = useUpdateStore((s) => s.dismiss); + const startPolling = useUpdateStore((s) => s.startPolling); + + useEffect(() => { + startPolling(); + }, [startPolling]); + + if (!banner) return null; + + const isRed = banner.variant === "red"; + + const text = + banner.severity === "security" + ? `Security update${banner.latest ? `: ${banner.latest}` : ""}` + : banner.severity === "deprecated" + ? "Version no longer supported" + : `Update available: ${banner.latest ?? ""}`; + + return ( +
+ {isRed && } + {banner.url ? ( + + {text} + + ) : ( + {text} + )} + {banner.dismissible && ( + + )} +
+ ); +} diff --git a/instrumentation.node.ts b/instrumentation.node.ts index 3546ba4c..d4e1525e 100644 --- a/instrumentation.node.ts +++ b/instrumentation.node.ts @@ -2,49 +2,12 @@ import { readFileSync } from "fs"; import { configManager } from "./lib/admin/config-manager"; import { initAdminPassword } from "./lib/admin/password"; -const VERSION_CHECK_URL = - "https://raw.githubusercontent.com/bulwarkmail/webmail/main/VERSION"; - -const SEMVER_RE = /^\d+\.\d+\.\d+$/; - -function compareVersions(current: string, remote: string): number { - const a = current.split(".").map(Number); - const b = remote.split(".").map(Number); - for (let i = 0; i < 3; i++) { - if ((b[i] ?? 0) > (a[i] ?? 0)) return 1; - if ((b[i] ?? 0) < (a[i] ?? 0)) return -1; - } - return 0; -} - const pkg = JSON.parse( readFileSync(`${process.cwd()}/package.json`, "utf-8") ); const current: string = pkg.version ?? "0.0.0"; console.info(`Bulwark Webmail v${current}`); -if (process.env.NODE_ENV === "production") { - fetch(VERSION_CHECK_URL, { - cache: "no-store", - signal: AbortSignal.timeout(5000), - }) - .then((res) => { - if (!res.ok) return; - return res.text(); - }) - .then((text) => { - if (!text) return; - const remote = text.trim(); - if (!SEMVER_RE.test(remote)) return; - if (compareVersions(current, remote) > 0) { - console.info( - `Update available: v${remote} - https://github.com/bulwarkmail/webmail` - ); - } - }) - .catch(() => {}); -} - // Initialize admin config and password bootstrap configManager.load() .then(() => initAdminPassword()) @@ -59,6 +22,13 @@ configManager.load() markProcessStart(); await startScheduler(); }) + .then(async () => { + // Hourly check against version.telemetry.bulwarkmail.org. Disable with + // BULWARK_UPDATE_CHECK=off or override the endpoint with + // BULWARK_UPDATE_CHECK_URL. + const { startScheduler } = await import("./lib/version-check"); + await startScheduler(); + }) .catch((err) => { console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err); }); diff --git a/lib/version-check/fetcher.ts b/lib/version-check/fetcher.ts new file mode 100644 index 00000000..cc125b06 --- /dev/null +++ b/lib/version-check/fetcher.ts @@ -0,0 +1,82 @@ +import { logger } from '@/lib/logger'; +import type { UpdateStatus, UpdateSeverity } from './types'; + +const SEVERITIES: ReadonlySet = new Set([ + 'normal', 'security', 'deprecated', 'none', 'unknown', +]); + +function isString(v: unknown): v is string { + return typeof v === 'string'; +} + +function isNullableString(v: unknown): v is string | null { + return v === null || typeof v === 'string'; +} + +// Validate the response from the version server before we trust it. Returns +// null on any malformed field so a hostile or buggy upstream can't poison the +// UI with arbitrary strings (the URL, in particular, is rendered in ). +export function parseStatus(raw: unknown): UpdateStatus | null { + if (!raw || typeof raw !== 'object') return null; + const r = raw as Record; + if (r.schema !== 1) return null; + if (!isString(r.current)) return null; + if (!isNullableString(r.latest)) return null; + if (typeof r.updateAvailable !== 'boolean') return null; + if (!isString(r.severity) || !SEVERITIES.has(r.severity as UpdateSeverity)) return null; + if (!isNullableString(r.url)) return null; + if (!isNullableString(r.advisory)) return null; + if (!isString(r.checkedAt)) return null; + + // Only http(s) URLs are renderable; reject anything else so we don't end + // up with a javascript: link in the banner. + if (r.url && !/^https?:\/\//i.test(r.url)) return null; + + return { + schema: 1, + current: r.current, + latest: r.latest, + updateAvailable: r.updateAvailable, + severity: r.severity as UpdateSeverity, + url: r.url, + advisory: r.advisory, + checkedAt: r.checkedAt, + }; +} + +export async function fetchStatus( + endpoint: string, + currentVersion: string, +): Promise<{ ok: true; status: UpdateStatus } | { ok: false; error: string }> { + if (!endpoint) return { ok: false, error: 'endpoint blank' }; + if (!currentVersion) return { ok: false, error: 'current version blank' }; + + // Build the URL safely — never inject the version as a raw path component. + let url: URL; + try { + url = new URL(endpoint); + } catch { + return { ok: false, error: 'endpoint not a URL' }; + } + url.searchParams.set('v', currentVersion); + + try { + const res = await fetch(url, { + method: 'GET', + headers: { accept: 'application/json' }, + cache: 'no-store', + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) { + return { ok: false, error: `HTTP ${res.status}` }; + } + const body: unknown = await res.json(); + const parsed = parseStatus(body); + if (!parsed) return { ok: false, error: 'malformed response' }; + return { ok: true, status: parsed }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn('version-check: fetch failed', { error: msg }); + return { ok: false, error: msg }; + } +} diff --git a/lib/version-check/index.ts b/lib/version-check/index.ts new file mode 100644 index 00000000..9b7ec084 --- /dev/null +++ b/lib/version-check/index.ts @@ -0,0 +1,7 @@ +export { startScheduler, stopScheduler, checkOnce } from './sender'; +export { loadState, saveState, effectiveEndpoint, disabledByEnv } from './state'; +export { fetchStatus, parseStatus } from './fetcher'; +export type { + UpdateStatus, UpdateSeverity, VersionCheckStateFile, +} from './types'; +export { DEFAULT_VERSION_ENDPOINT } from './types'; diff --git a/lib/version-check/sender.ts b/lib/version-check/sender.ts new file mode 100644 index 00000000..326cc096 --- /dev/null +++ b/lib/version-check/sender.ts @@ -0,0 +1,100 @@ +import { logger } from '@/lib/logger'; +import { disabledByEnv, effectiveEndpoint, loadState, saveState } from './state'; +import { fetchStatus } from './fetcher'; +import type { UpdateStatus } from './types'; + +const HOUR_MS = 60 * 60 * 1000; +const JITTER_MS = 5 * 60 * 1000; // ± 5 min, keeps containers from syncing +const FIRST_DELAY_MS = 30 * 1000; // first check ~30s after boot +const FAILURE_BACKOFF_MS = 15 * 60 * 1000; // after a failed fetch, retry in 15 min + +let currentTimer: NodeJS.Timeout | null = null; + +function jitteredDelay(base: number): number { + const j = (Math.random() * 2 - 1) * JITTER_MS; + return Math.max(60_000, base + j); +} + +function getCurrentVersion(): string { + return (process.env.NEXT_PUBLIC_APP_VERSION || '').trim(); +} + +export async function checkOnce(opts?: { reason?: string }): Promise<{ + ok: boolean; + status?: UpdateStatus; + error?: string; +}> { + if (disabledByEnv()) return { ok: false, error: 'disabled by env' }; + + const state = await loadState(); + const endpoint = effectiveEndpoint(state); + if (!endpoint) return { ok: false, error: 'endpoint blank' }; + + const current = getCurrentVersion(); + if (!current) return { ok: false, error: 'current version unset' }; + + const now = new Date().toISOString(); + const result = await fetchStatus(endpoint, current); + + const next = await loadState(); + next.lastCheckedAt = now; + if (result.ok) { + next.lastSuccessAt = now; + next.status = result.status; + } + await saveState(next); + + logger.info('version-check: ran', { + ok: result.ok, + severity: result.ok ? result.status.severity : null, + reason: opts?.reason ?? 'scheduled', + }); + + if (result.ok) return { ok: true, status: result.status }; + return { ok: false, error: result.error }; +} + +async function scheduleNext(delayMs: number): Promise { + if (currentTimer) clearTimeout(currentTimer); + const at = new Date(Date.now() + delayMs).toISOString(); + const state = await loadState(); + state.nextScheduledAt = at; + await saveState(state); + currentTimer = setTimeout(() => { void tick(); }, delayMs); + // Don't keep the process alive just for this. + currentTimer.unref?.(); +} + +async function tick(): Promise { + const result = await checkOnce({ reason: 'scheduled' }); + const delay = result.ok ? jitteredDelay(HOUR_MS) : FAILURE_BACKOFF_MS; + await scheduleNext(delay); +} + +// Idempotent — safe to call from instrumentation hot-reload in dev. +export async function startScheduler(): Promise { + if (disabledByEnv()) { + logger.info('version-check: scheduler not started (disabled by env)'); + return; + } + const state = await loadState(); + if (!effectiveEndpoint(state)) { + logger.info('version-check: scheduler not started (no endpoint)'); + return; + } + // If a previous schedule was still in the future, honor it (don't blast on + // every restart). Cap at one hour so a wildly-in-the-future timestamp can't + // permanently silence the check. + let delay = FIRST_DELAY_MS; + if (state.nextScheduledAt) { + const remaining = new Date(state.nextScheduledAt).getTime() - Date.now(); + if (remaining > 0) delay = Math.min(remaining, HOUR_MS + JITTER_MS); + } + await scheduleNext(delay); + logger.info('version-check: scheduler started', { nextInMs: delay }); +} + +export async function stopScheduler(): Promise { + if (currentTimer) clearTimeout(currentTimer); + currentTimer = null; +} diff --git a/lib/version-check/state.ts b/lib/version-check/state.ts new file mode 100644 index 00000000..b6de0037 --- /dev/null +++ b/lib/version-check/state.ts @@ -0,0 +1,62 @@ +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { logger } from '@/lib/logger'; +import type { VersionCheckStateFile } from './types'; +import { DEFAULT_VERSION_ENDPOINT } from './types'; + +function getDir(): string { + return process.env.VERSION_CHECK_DATA_DIR || + path.join(process.cwd(), 'data', 'version-check'); +} + +function statePath(): string { return path.join(getDir(), 'state.json'); } + +const DEFAULTS: VersionCheckStateFile = { + endpoint: DEFAULT_VERSION_ENDPOINT, + lastCheckedAt: null, + lastSuccessAt: null, + nextScheduledAt: null, + status: null, +}; + +export async function ensureDir(): Promise { + if (!existsSync(getDir())) await mkdir(getDir(), { recursive: true }); +} + +export async function loadState(): Promise { + await ensureDir(); + try { + const raw = await readFile(statePath(), 'utf8'); + const parsed = JSON.parse(raw) as Partial; + return { ...DEFAULTS, ...parsed }; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn('version-check: state read failed', { + error: err instanceof Error ? err.message : String(err), + }); + } + return { ...DEFAULTS }; + } +} + +export async function saveState(state: VersionCheckStateFile): Promise { + await ensureDir(); + const tmp = statePath() + '.tmp'; + await writeFile(tmp, JSON.stringify(state, null, 2), 'utf8'); + await rename(tmp, statePath()); +} + +export function disabledByEnv(): boolean { + const v = (process.env.BULWARK_UPDATE_CHECK ?? '').toLowerCase(); + if (v === 'off' || v === 'false' || v === '0' || v === 'no') return true; + return false; +} + +export function effectiveEndpoint(state: VersionCheckStateFile): string { + // Env var wins over state file so an operator can override at runtime + // without editing on-disk state. An explicit empty value disables the check. + const envUrl = process.env.BULWARK_UPDATE_CHECK_URL; + if (envUrl !== undefined) return envUrl.trim(); + return state.endpoint || DEFAULT_VERSION_ENDPOINT; +} diff --git a/lib/version-check/types.ts b/lib/version-check/types.ts new file mode 100644 index 00000000..b624626c --- /dev/null +++ b/lib/version-check/types.ts @@ -0,0 +1,25 @@ +// Update-status payload returned by the version server. Mirrors +// repos/dashboard/version-server/src/registry.ts LookupResult. + +export type UpdateSeverity = 'normal' | 'security' | 'deprecated' | 'none' | 'unknown'; + +export interface UpdateStatus { + schema: 1; + current: string; + latest: string | null; + updateAvailable: boolean; + severity: UpdateSeverity; + url: string | null; + advisory: string | null; + checkedAt: string; +} + +export interface VersionCheckStateFile { + endpoint: string; + lastCheckedAt: string | null; + lastSuccessAt: string | null; + nextScheduledAt: string | null; + status: UpdateStatus | null; +} + +export const DEFAULT_VERSION_ENDPOINT = 'https://version.telemetry.bulwarkmail.org/'; diff --git a/stores/update-store.ts b/stores/update-store.ts new file mode 100644 index 00000000..1cb3d014 --- /dev/null +++ b/stores/update-store.ts @@ -0,0 +1,148 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { apiFetch } from '@/lib/browser-navigation'; +import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types'; + +const POLL_INTERVAL_MS = 15 * 60 * 1000; + +interface UpdateState { + status: UpdateStatus | null; + loading: boolean; + lastFetchedAt: number | null; + // Latest version the user has dismissed the amber banner for. A newer + // release re-shows the banner. Persisted in localStorage. + dismissedVersion: string | null; + + fetchStatus: () => Promise; + startPolling: () => void; + stopPolling: () => void; + dismiss: () => void; +} + +let pollTimer: ReturnType | null = null; +let inFlight: Promise | null = null; + +interface ApiResponse { + status: UpdateStatus | null; + lastCheckedAt: string | null; + lastSuccessAt: string | null; +} + +export const useUpdateStore = create()( + persist( + (set, get) => ({ + status: null, + loading: false, + lastFetchedAt: null, + dismissedVersion: null, + + fetchStatus: async () => { + if (inFlight) return inFlight; + set({ loading: true }); + inFlight = (async () => { + try { + const res = await apiFetch('/api/system/update-status'); + if (!res.ok) return; + const body = (await res.json()) as ApiResponse; + set({ + status: body.status, + lastFetchedAt: Date.now(), + }); + } catch { + // Silent — banner just won't appear, no need to disrupt the UI. + } finally { + set({ loading: false }); + inFlight = null; + } + })(); + return inFlight; + }, + + startPolling: () => { + if (pollTimer) return; + void get().fetchStatus(); + pollTimer = setInterval(() => { + void get().fetchStatus(); + }, POLL_INTERVAL_MS); + }, + + stopPolling: () => { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + }, + + dismiss: () => { + const { status } = get(); + const target = status?.latest ?? status?.current; + if (!target) return; + set({ dismissedVersion: target }); + }, + }), + { + name: 'bulwark-update-dismissed', + // Only persist the dismissal — status is fetched fresh per session. + partialize: (s) => ({ dismissedVersion: s.dismissedVersion }), + }, + ), +); + +// Selectors. Keep them outside the store creator so components subscribing +// to a single derived value don't re-render on unrelated state changes. + +export type BannerVariant = 'amber' | 'red'; + +export interface BannerInfo { + variant: BannerVariant; + severity: UpdateSeverity; + latest: string | null; + url: string | null; + advisory: string | null; + dismissible: boolean; +} + +export function selectBanner(s: UpdateState): BannerInfo | null { + const st = s.status; + if (!st || !st.updateAvailable) return null; + if (st.severity === 'none' || st.severity === 'unknown') return null; + + if (st.severity === 'security') { + return { + variant: 'red', + severity: 'security', + latest: st.latest, + url: st.url, + advisory: st.advisory, + dismissible: false, + }; + } + if (st.severity === 'deprecated') { + return { + variant: 'red', + severity: 'deprecated', + latest: st.latest, + url: st.url, + advisory: null, + dismissible: false, + }; + } + // normal + const target = st.latest ?? st.current; + if (s.dismissedVersion === target) return null; + return { + variant: 'amber', + severity: 'normal', + latest: st.latest, + url: st.url, + advisory: null, + dismissible: true, + }; +} + +// Used by the admin shield to show a dot regardless of dismissal state. We +// always want admins to see that an update is needed, even if the amber +// banner has been dismissed. +export function selectHasUpdate(s: UpdateState): boolean { + return !!s.status?.updateAvailable && s.status.severity !== 'unknown'; +}