Add public self-host install one-pager with admin on/off toggle.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-10 01:36:37 +02:00
parent 303383ea4e
commit 0b1a2daa20
6 changed files with 435 additions and 0 deletions

View file

@ -1652,3 +1652,72 @@ tr.row-selected {
padding: 2.25rem 2.5rem 2.5rem; padding: 2.25rem 2.5rem 2.5rem;
} }
} }
/* Install one-pager */
.install-lang {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-bottom: 0.5rem;
}
.install-lang button {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.12);
color: #a1a1aa;
border-radius: 8px;
padding: 0.35rem 0.7rem;
font-size: 0.8rem;
cursor: pointer;
}
.install-lang button.active {
color: #f4f4f5;
border-color: rgba(147, 197, 253, 0.45);
background: rgba(147, 197, 253, 0.08);
}
.install-lead {
font-size: 1.05rem;
color: #d4d4d8;
line-height: 1.6;
}
.install-step-label {
margin: 1.1rem 0 0.35rem;
font-size: 0.85rem;
color: #e4e4e7;
font-weight: 600;
}
.install-code {
margin: 0;
padding: 0.9rem 1rem;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
overflow-x: auto;
font-size: 0.8rem;
line-height: 1.5;
color: #e4e4e7;
white-space: pre-wrap;
}
.install-tip {
margin-top: 1.25rem;
padding: 0.9rem 1rem;
border-radius: 10px;
border: 1px solid rgba(250, 204, 21, 0.25);
background: rgba(250, 204, 21, 0.06);
}
.install-tip strong {
color: #fde68a;
font-size: 0.85rem;
}
.install-tip p {
margin: 0.35rem 0 0;
}
.install-ol {
margin: 0.5rem 0 0.75rem;
padding-left: 1.25rem;
}
.install-ol li {
color: #a1a1aa;
line-height: 1.65;
font-size: 0.95rem;
margin-bottom: 0.4rem;
}

View file

@ -0,0 +1,12 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Install — Vonas Media Cluster",
description:
"Self-host Vonas Media Cluster: private media software for your own library, with apps that connect to your server.",
robots: { index: true, follow: true },
};
export default function InstallLayout({ children }: { children: React.ReactNode }) {
return children;
}

View file

@ -0,0 +1,221 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
type Lang = "en" | "nl";
const COPY = {
en: {
kicker: "Vonas Media Cluster",
title: "Self-host your media library",
lead:
"VMC is private media software for your own movies and TV shows — like Emby or Plex, with apps for Android TV, phones, and more. Host it on your NAS or a small server; the Store apps connect to your instance.",
forWhom: "Who is this for?",
forWhomBody:
"Anyone who wants a personal media server at home (or on a VPS) and apps that talk to that server. You keep the files; we ship the software.",
reqTitle: "Requirements",
req: [
"A Linux host, NAS, or VPS with Docker and Docker Compose",
"Storage for your media library",
"A domain or LAN hostname for HTTPS (recommended)",
"Outbound internet for metadata (TMDB) if you want posters and info",
],
stepsTitle: "Install with Docker Compose",
stepsIntro: "On the host that will run Master (API + admin UI):",
afterTitle: "After it is running",
after: [
"Open the admin UI (port 3001 by default) and sign in with ADMIN_EMAIL / ADMIN_PASSWORD from your .env.",
"Add media nodes (Unraid, Synology, …) so libraries can be scanned.",
"Create viewer accounts, then open the VMC app and choose “Connect to your server”.",
"Point the app at your Master URL (for example https://media.example.com) and sign in.",
],
appsTitle: "Apps",
appsBody:
"Install Vonas Media Cluster from the app stores when available, or sideload the APK for Android TV. In the app, pick your own server URL — you are not locked to vonas.nl.",
privacyNote: "Privacy policy",
offlineTitle: "Install guide temporarily unavailable",
offlineBody: "This page is currently turned off by the server administrator.",
langNl: "Nederlands",
langEn: "English",
cloneLabel: "Get the compose stack",
envLabel: "Configure environment",
upLabel: "Start services",
tipTitle: "Tip",
tipBody:
"Change SESSION_SECRET, ADMIN_PASSWORD, and database passwords before exposing the stack to the internet. Set PUBLIC_URL to the HTTPS URL clients will use.",
},
nl: {
kicker: "Vonas Media Cluster",
title: "Host je eigen mediabibliotheek",
lead:
"VMC is privé-mediasoftware voor je eigen films en series — vergelijkbaar met Emby of Plex, met apps voor Android TV, telefoons en meer. Draai het op je NAS of een kleine server; de Store-apps verbinden met jouw instance.",
forWhom: "Voor wie?",
forWhomBody:
"Iedereen die thuis (of op een VPS) een persoonlijke mediaserver wil, met apps die met die server praten. Jij houdt de bestanden; wij leveren de software.",
reqTitle: "Vereisten",
req: [
"Een Linux-host, NAS of VPS met Docker en Docker Compose",
"Opslag voor je mediabibliotheek",
"Een domein of LAN-hostnaam voor HTTPS (aanbevolen)",
"Uitgaand internet voor metadata (TMDB) als je posters en info wilt",
],
stepsTitle: "Installeren met Docker Compose",
stepsIntro: "Op de host die Master (API + admin-UI) draait:",
afterTitle: "Daarna",
after: [
"Open de admin-UI (standaard poort 3001) en log in met ADMIN_EMAIL / ADMIN_PASSWORD uit je .env.",
"Voeg media-nodes toe (Unraid, Synology, …) zodat bibliotheken gescand kunnen worden.",
"Maak kijker-accounts aan, open daarna de VMC-app en kies “Connect to your server”.",
"Vul je Master-URL in (bijv. https://media.voorbeeld.nl) en log in.",
],
appsTitle: "Apps",
appsBody:
"Installeer Vonas Media Cluster via de appstores wanneer beschikbaar, of sideload de APK voor Android TV. In de app kies je je eigen server-URL — je zit niet vast aan vonas.nl.",
privacyNote: "Privacybeleid",
offlineTitle: "Installatiegids tijdelijk niet beschikbaar",
offlineBody: "Deze pagina staat momenteel uit bij de serverbeheerder.",
langNl: "Nederlands",
langEn: "English",
cloneLabel: "Compose-stack ophalen",
envLabel: "Omgeving configureren",
upLabel: "Services starten",
tipTitle: "Tip",
tipBody:
"Wijzig SESSION_SECRET, ADMIN_PASSWORD en databasewachtwoorden voordat je de stack op internet zet. Zet PUBLIC_URL op de HTTPS-URL die clients gebruiken.",
},
} as const;
const CLONE = `git clone https://git.vonas.nl/jos/stremio.git
cd stremio/deploy/docker`;
const ENV = `cp ../master/.env.example ../master/.env
# edit ../master/.env — especially SESSION_SECRET, ADMIN_*, PUBLIC_URL`;
const UP = `docker compose up -d --build`;
export default function InstallPage() {
const [lang, setLang] = useState<Lang>("en");
const [enabled, setEnabled] = useState<boolean | null>(null);
const t = useMemo(() => COPY[lang], [lang]);
useEffect(() => {
const q = new URLSearchParams(window.location.search).get("lang");
if (q === "nl" || q === "en") setLang(q);
}, []);
useEffect(() => {
let cancelled = false;
fetch("/api/v1/public/site")
.then(async (res) => {
if (!res.ok) return { installPageEnabled: true };
return res.json();
})
.then((data) => {
if (!cancelled) setEnabled(data.installPageEnabled !== false);
})
.catch(() => {
if (!cancelled) setEnabled(true);
});
return () => {
cancelled = true;
};
}, []);
function switchLang(next: Lang) {
setLang(next);
const url = next === "en" ? "/install" : `/install?lang=${next}`;
window.history.replaceState(null, "", url);
}
if (enabled === null) {
return (
<main className="legal-page">
<article className="legal-card">
<p className="page-kicker">Vonas Media Cluster</p>
<p className="muted">…</p>
</article>
</main>
);
}
if (!enabled) {
return (
<main className="legal-page">
<article className="legal-card">
<div className="install-lang">
<button type="button" className={lang === "en" ? "active" : ""} onClick={() => switchLang("en")}>
{t.langEn}
</button>
<button type="button" className={lang === "nl" ? "active" : ""} onClick={() => switchLang("nl")}>
{t.langNl}
</button>
</div>
<p className="page-kicker">{t.kicker}</p>
<h1>{t.offlineTitle}</h1>
<p>{t.offlineBody}</p>
</article>
</main>
);
}
return (
<main className="legal-page">
<article className="legal-card">
<div className="install-lang">
<button type="button" className={lang === "en" ? "active" : ""} onClick={() => switchLang("en")}>
{t.langEn}
</button>
<button type="button" className={lang === "nl" ? "active" : ""} onClick={() => switchLang("nl")}>
{t.langNl}
</button>
</div>
<p className="page-kicker">{t.kicker}</p>
<h1>{t.title}</h1>
<p className="install-lead">{t.lead}</p>
<h2>{t.forWhom}</h2>
<p>{t.forWhomBody}</p>
<h2>{t.reqTitle}</h2>
<ul>
{t.req.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<h2>{t.stepsTitle}</h2>
<p>{t.stepsIntro}</p>
<p className="install-step-label">{t.cloneLabel}</p>
<pre className="install-code">{CLONE}</pre>
<p className="install-step-label">{t.envLabel}</p>
<pre className="install-code">{ENV}</pre>
<p className="install-step-label">{t.upLabel}</p>
<pre className="install-code">{UP}</pre>
<div className="install-tip">
<strong>{t.tipTitle}</strong>
<p>{t.tipBody}</p>
</div>
<h2>{t.afterTitle}</h2>
<ol className="install-ol">
{t.after.map((item) => (
<li key={item}>{item}</li>
))}
</ol>
<h2>{t.appsTitle}</h2>
<p>{t.appsBody}</p>
<footer className="legal-footer">
<Link href="/privacy">{t.privacyNote}</Link>
</footer>
</article>
</main>
);
}

View file

@ -118,6 +118,9 @@ export default function SettingsPage() {
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [installPageEnabled, setInstallPageEnabled] = useState(true);
const [installPageBusy, setInstallPageBusy] = useState(false);
const [installPageMsg, setInstallPageMsg] = useState<string | null>(null);
const [pwBusy, setPwBusy] = useState(false); const [pwBusy, setPwBusy] = useState(false);
const [pwMsg, setPwMsg] = useState<string | null>(null); const [pwMsg, setPwMsg] = useState<string | null>(null);
const [pwErr, setPwErr] = useState<string | null>(null); const [pwErr, setPwErr] = useState<string | null>(null);
@ -210,8 +213,38 @@ export default function SettingsPage() {
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setAdminEmail(d.user?.email ?? "")) .then((d) => setAdminEmail(d.user?.email ?? ""))
.catch(() => undefined); .catch(() => undefined);
fetch("/api/v1/admin/site/install-page", { credentials: "include" })
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
if (d && typeof d.installPageEnabled === "boolean") {
setInstallPageEnabled(d.installPageEnabled);
}
})
.catch(() => undefined);
}, [loadShelves, loadDs, loadOpenSubtitles, loadGooglePlay]); }, [loadShelves, loadDs, loadOpenSubtitles, loadGooglePlay]);
async function saveInstallPageEnabled(enabled: boolean) {
setInstallPageBusy(true);
setInstallPageMsg(null);
try {
const res = await fetch("/api/v1/admin/site/install-page", {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setInstallPageMsg(data.error?.message ?? "Opslaan mislukt");
return;
}
setInstallPageEnabled(data.installPageEnabled !== false);
setInstallPageMsg(enabled ? "Install-pagina staat aan." : "Install-pagina staat uit.");
} finally {
setInstallPageBusy(false);
}
}
useEffect(() => { useEffect(() => {
if (!nodeId) return; if (!nodeId) return;
const existing = configs.find((c) => c.nodeId === nodeId); const existing = configs.find((c) => c.nodeId === nodeId);
@ -479,6 +512,33 @@ export default function SettingsPage() {
{pwBusy ? "Bezig…" : "Wachtwoord wijzigen"} {pwBusy ? "Bezig…" : "Wachtwoord wijzigen"}
</button> </button>
</div> </div>
<div className="section-head" style={{ marginTop: "1.5rem" }}>
<h2 className="section-title">Publieke install-pagina</h2>
<p className="muted">
One-pager op{" "}
<Link href="/install" target="_blank">
/install
</Link>{" "}
(EN/NL) voor self-host instructies. Handig voor App Store-review.
</p>
</div>
<div className="card">
{installPageMsg && (
<p className="muted" style={{ marginBottom: "0.75rem" }}>
{installPageMsg}
</p>
)}
<label className="checkbox-row" style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
<input
type="checkbox"
checked={installPageEnabled}
disabled={installPageBusy}
onChange={(e) => void saveInstallPageEnabled(e.target.checked)}
/>
<span>Install-pagina online tonen</span>
</label>
</div>
</section> </section>
)} )}

View file

@ -10,6 +10,7 @@ import { registerNodeRoutes } from "./nodes/routes";
import { registerStremioRoutes } from "./stremio/routes"; import { registerStremioRoutes } from "./stremio/routes";
import { registerAdminRoutes } from "./admin/routes"; import { registerAdminRoutes } from "./admin/routes";
import { registerInstallRoutes } from "./install/routes"; import { registerInstallRoutes } from "./install/routes";
import { registerSiteRoutes } from "./site/routes";
import { registerDownloadRoutes } from "./downloads/routes"; import { registerDownloadRoutes } from "./downloads/routes";
import { registerSettingsRoutes } from "./settings/routes"; import { registerSettingsRoutes } from "./settings/routes";
import { registerViewerRoutes } from "./viewer/routes"; import { registerViewerRoutes } from "./viewer/routes";
@ -77,6 +78,7 @@ async function main() {
await registerStremioRoutes(app, config); await registerStremioRoutes(app, config);
await registerAdminRoutes(app, config); await registerAdminRoutes(app, config);
await registerInstallRoutes(app, config); await registerInstallRoutes(app, config);
registerSiteRoutes(app);
const downloads = registerDownloadRoutes(app, config); const downloads = registerDownloadRoutes(app, config);
registerViewerRoutes(app, config, downloads); registerViewerRoutes(app, config, downloads);
const googlePlay = registerGooglePlayRoutes(app, config); const googlePlay = registerGooglePlayRoutes(app, config);

View file

@ -0,0 +1,71 @@
import type { FastifyInstance } from "fastify";
import { prisma } from "../database/client";
import { requireAdmin } from "../auth/routes";
import { AppError } from "../security/errors";
const SITE_SETTINGS_ID = "public_site";
type PublicSiteConfig = {
installPageEnabled: boolean;
};
function parseConfig(raw: string | null | undefined): Partial<PublicSiteConfig> {
if (!raw) return {};
try {
return JSON.parse(raw) as Partial<PublicSiteConfig>;
} catch {
return {};
}
}
async function readSiteConfig(): Promise<PublicSiteConfig> {
const row = await prisma.integrationSetting.findUnique({ where: { id: SITE_SETTINGS_ID } });
if (!row) {
return { installPageEnabled: true };
}
const parsed = parseConfig(row.configJson);
return {
installPageEnabled: parsed.installPageEnabled ?? row.enabled ?? true,
};
}
async function writeSiteConfig(cfg: PublicSiteConfig) {
await prisma.integrationSetting.upsert({
where: { id: SITE_SETTINGS_ID },
create: {
id: SITE_SETTINGS_ID,
enabled: cfg.installPageEnabled,
configJson: JSON.stringify(cfg),
},
update: {
enabled: cfg.installPageEnabled,
configJson: JSON.stringify(cfg),
},
});
}
export function registerSiteRoutes(app: FastifyInstance) {
app.get("/api/v1/public/site", async () => {
const cfg = await readSiteConfig();
return {
installPageEnabled: cfg.installPageEnabled,
productName: "Vonas Media Cluster",
shortName: "VMC",
docsRepo: "https://git.vonas.nl/jos/stremio.git",
};
});
app.get("/api/v1/admin/site/install-page", { preHandler: requireAdmin }, async () => {
return readSiteConfig();
});
app.put("/api/v1/admin/site/install-page", { preHandler: requireAdmin }, async (request) => {
const body = (request.body || {}) as { enabled?: boolean };
if (typeof body.enabled !== "boolean") {
throw new AppError("INVALID_REQUEST", "enabled (boolean) verplicht", 400);
}
const cfg = { installPageEnabled: body.enabled };
await writeSiteConfig(cfg);
return cfg;
});
}