Add public self-host install one-pager with admin on/off toggle.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
303383ea4e
commit
0b1a2daa20
6 changed files with 435 additions and 0 deletions
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
12
apps/admin-ui/src/app/install/layout.tsx
Normal file
12
apps/admin-ui/src/app/install/layout.tsx
Normal 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;
|
||||||
|
}
|
||||||
221
apps/admin-ui/src/app/install/page.tsx
Normal file
221
apps/admin-ui/src/app/install/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
71
apps/master-api/src/site/routes.ts
Normal file
71
apps/master-api/src/site/routes.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue