diff --git a/.env.example b/.env.example index c7fb320..9b7d797 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,9 @@ NEXTAUTH_URL="http://localhost:3000" GITEA_URL="http://192.168.0.5:3022" GITEA_TOKEN="" GITEA_REPO="bonna61/Moonbase" + +# Self-update — the admin "Pull & rebuild" button signals the updater sidecar. +# UPDATER_SECRET is shared between the app and the updater (set in the host .env, +# never committed). UPDATER_URL points at the sidecar on the internal network. +UPDATER_URL="http://updater:9000/hooks/moonbase-update" +UPDATER_SECRET="" diff --git a/Dockerfile.updater b/Dockerfile.updater new file mode 100644 index 0000000..57cb81f --- /dev/null +++ b/Dockerfile.updater @@ -0,0 +1,7 @@ +# Privileged self-update sidecar. Holds the docker socket and, on an +# authenticated webhook trigger, runs `git pull && docker compose up -d --build app` +# against the moonbase stack. It only ever runs the fixed updater/update.sh — +# no request data reaches the shell. See docker-compose.yml + updater/hooks.json. +FROM docker:27-cli +RUN apk add --no-cache git docker-cli-compose webhook +ENTRYPOINT ["webhook"] diff --git a/docker-compose.yml b/docker-compose.yml index 4a9a07f..3b2dc44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,12 +29,41 @@ services: GITEA_URL: http://192.168.0.5:3022 GITEA_TOKEN: ${GITEA_TOKEN} GITEA_REPO: bonna61/Moonbase + # Self-update — admin "Pull & rebuild" signals the updater sidecar. + UPDATER_URL: http://updater:9000/hooks/moonbase-update + UPDATER_SECRET: ${UPDATER_SECRET} volumes: # Persist suggestion screenshot uploads across rebuilds. - /mnt/user/appdata/moonbase/uploads:/app/public/uploads networks: - internal + # Privileged self-update sidecar: holds the docker socket and rebuilds `app` + # on an authenticated webhook trigger. Only runs the fixed updater/update.sh. + updater: + build: + context: . + dockerfile: Dockerfile.updater + image: moonbase-updater:latest + restart: unless-stopped + command: + - -hooks + - /appdata/updater/hooks.json + - -template + - -hotreload + - -ip + - 0.0.0.0 + - -port + - "9000" + - -verbose + environment: + UPDATER_SECRET: ${UPDATER_SECRET} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /mnt/user/appdata/moonbase:/appdata + networks: + - internal + networks: internal: driver: bridge diff --git a/src/app/(dashboard)/settings/updates/page.tsx b/src/app/(dashboard)/settings/updates/page.tsx new file mode 100644 index 0000000..f9bde86 --- /dev/null +++ b/src/app/(dashboard)/settings/updates/page.tsx @@ -0,0 +1,14 @@ +import { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { getSession, isAdmin } from "@/lib/auth"; +import { SelfUpdatePanel } from "@/components/settings/self-update-panel"; + +export const metadata: Metadata = { title: "Updates" }; +export const dynamic = "force-dynamic"; + +export default async function UpdatesPage() { + const session = await getSession(); + if (!session) redirect("/login"); + if (!isAdmin(session.user.role)) redirect("/dashboard"); + return ; +} diff --git a/src/app/api/account/self-update/route.ts b/src/app/api/account/self-update/route.ts new file mode 100644 index 0000000..8e63a0a --- /dev/null +++ b/src/app/api/account/self-update/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { getSession, isAdmin } from "@/lib/auth"; + +export const dynamic = "force-dynamic"; + +// Admin-only trigger for the homegrown self-updater. Signals the privileged +// `updater` sidecar (which holds the docker socket) over the internal network +// with a shared secret; the sidecar runs `git pull && docker compose up -d +// --build app` detached. No user input reaches the shell — the command is fixed +// in updater/update.sh. The browser then polls /api/version for the new version. +export async function POST() { + const session = await getSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!isAdmin(session.user.role)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const url = process.env.UPDATER_URL; + const secret = process.env.UPDATER_SECRET; + if (!url || !secret) { + return NextResponse.json({ error: "not_configured" }, { status: 503 }); + } + + try { + const res = await fetch(url, { + method: "POST", + headers: { "X-Update-Token": secret }, + cache: "no-store", + }); + if (!res.ok) { + return NextResponse.json( + { error: `updater responded ${res.status}` }, + { status: 502 } + ); + } + return NextResponse.json({ started: true }); + } catch { + return NextResponse.json({ error: "updater unreachable" }, { status: 502 }); + } +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 7c79c0f..56c004c 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb } from "lucide-react"; +import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw } from "lucide-react"; import { cn } from "@/lib/utils"; const navItems = [ @@ -17,6 +17,7 @@ const navItems = [ const settingsItems = [ { label: "Suggestions", href: "/suggestions", icon: Lightbulb }, { label: "Backup & Restore", href: "/settings/backup", icon: HardDrive }, + { label: "Updates", href: "/settings/updates", icon: RefreshCw }, ]; export function Sidebar() { diff --git a/src/components/settings/self-update-panel.tsx b/src/components/settings/self-update-panel.tsx new file mode 100644 index 0000000..9da7fc3 --- /dev/null +++ b/src/components/settings/self-update-panel.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { RefreshCw, CheckCircle2, AlertTriangle, Loader2 } from "lucide-react"; + +type Status = { version: string; latest: string | null; updateAvailable: boolean }; +type Phase = "idle" | "starting" | "rebuilding" | "done" | "error"; + +export function SelfUpdatePanel() { + const [status, setStatus] = useState(null); + const [phase, setPhase] = useState("idle"); + const [message, setMessage] = useState(""); + const startVersion = useRef(null); + const poll = useRef | null>(null); + + async function loadStatus(): Promise { + try { + const r = await fetch("/api/version", { cache: "no-store" }); + if (!r.ok) return null; + const d = (await r.json()) as Status; + setStatus(d); + return d; + } catch { + return null; + } + } + + useEffect(() => { + loadStatus(); + return () => { + if (poll.current) clearInterval(poll.current); + }; + }, []); + + async function triggerUpdate() { + setPhase("starting"); + setMessage("Starting update…"); + startVersion.current = status?.version ?? null; + + try { + const r = await fetch("/api/account/self-update", { method: "POST" }); + if (!r.ok) { + const j = await r.json().catch(() => ({})); + setPhase("error"); + setMessage( + j.error === "not_configured" + ? "The updater isn't set up on this server yet." + : `Couldn't start the update (${r.status}).` + ); + return; + } + } catch { + setPhase("error"); + setMessage("Couldn't reach the server to start the update."); + return; + } + + setPhase("rebuilding"); + setMessage( + "Pulling the latest version and rebuilding. This can take a couple of minutes — don't close this page." + ); + + let tries = 0; + poll.current = setInterval(async () => { + tries++; + const d = await loadStatus(); + if (d && startVersion.current && d.version !== startVersion.current) { + if (poll.current) clearInterval(poll.current); + setPhase("done"); + setMessage(`Updated to v${d.version}. Reloading…`); + setTimeout(() => window.location.reload(), 2000); + } else if (tries > 90) { + // ~6 minutes with no version change + if (poll.current) clearInterval(poll.current); + setPhase("error"); + setMessage( + "The update is taking longer than expected. Check the server, then reload this page." + ); + } + }, 4000); + } + + const busy = phase === "starting" || phase === "rebuilding"; + const updateAvailable = !!status?.updateAvailable && !!status.latest; + + return ( +
+
+

Updates

+

+ Pull the latest version of Moon Base and rebuild the app. +

+
+ + + + + Current version + + v{status?.version ?? "…"} + + {updateAvailable ? ( + + Update available → v{status!.latest} + + ) : status ? ( + + Up to date + + ) : null} + + + + {phase !== "idle" && ( +
+ {phase === "error" ? ( + + ) : phase === "done" ? ( + + ) : ( + + )} + {message} +
+ )} + + +
+
+
+ ); +} diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index 51aa3e9..21510ff 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -8,6 +8,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.9.0", + date: "2026-06-15", + changes: [ + "One-click updates — admins get a new Updates page under Settings with a \"Pull & rebuild\" button. When a new version is available, click it and the app updates itself and reloads. No more waiting on Tony.", + ], + }, { version: "0.8.0", date: "2026-06-15", diff --git a/src/lib/version.ts b/src/lib/version.ts index 89cd851..1065399 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,2 +1,2 @@ // Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`. -export const APP_VERSION = "0.8.0"; +export const APP_VERSION = "0.9.0"; diff --git a/updater/hooks.json b/updater/hooks.json new file mode 100644 index 0000000..dea8a56 --- /dev/null +++ b/updater/hooks.json @@ -0,0 +1,15 @@ +[ + { + "id": "moonbase-update", + "execute-command": "/appdata/updater/update.sh", + "command-working-directory": "/appdata", + "response-message": "update started", + "trigger-rule": { + "match": { + "type": "value", + "value": "{{ getenv \"UPDATER_SECRET\" }}", + "parameter": { "source": "header", "name": "X-Update-Token" } + } + } + } +] diff --git a/updater/update.sh b/updater/update.sh new file mode 100755 index 0000000..7918a71 --- /dev/null +++ b/updater/update.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Triggered by the webhook sidecar when an admin clicks "Pull & rebuild". +# Runs the rebuild DETACHED so the webhook responds immediately — otherwise the +# app container (which the admin's browser is talking to) would be recreated +# mid-request. The app UI then polls /api/version until the new version answers. +# +# Only `app` is rebuilt: postgres and this updater are left running untouched. +set -e + +nohup sh -c ' + cd /appdata + echo "=== update $(date) ===" + git fetch origin master + git reset --hard origin/master + COMPOSE_PROJECT_NAME=moonbase docker compose up -d --build app + echo "=== done $(date) ===" +' >> /appdata/update.log 2>&1 & + +echo "update started"