Move Google Play credentials into admin Settings UI.

Store encrypted service-account and group config in the database so Dokploy env vars are optional.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-01 03:10:59 +02:00
parent 4d96940b20
commit 4f76e39124
12 changed files with 601 additions and 69 deletions

View file

@ -16,26 +16,47 @@ Adminpaneel → backend → Google Cloud Identity Groups API → Google Group
- De Closed Testing-track wordt **eenmalig** gekoppeld aan `PLAY_ACCESS_GROUP_EMAIL`. - De Closed Testing-track wordt **eenmalig** gekoppeld aan `PLAY_ACCESS_GROUP_EMAIL`.
- Het adminpaneel beheert alleen lidmaatschap van die groep. - Het adminpaneel beheert alleen lidmaatschap van die groep.
## Benodigde environment variables ## Benodigde configuratie
Stel deze in op de **master-api** server (niet in de admin UI, niet in Git): **Voorkeur:** vul alles in via het adminpaneel → **Instellingen → Google Play**.
Credentials worden versleuteld in de database opgeslagen (zoals OpenSubtitles).
| Variabele | Verplicht | Beschrijving | Optioneel blijven environment variables werken als fallback (bijv. voor lokale development).
|-----------|-----------|--------------|
| `GOOGLE_CLOUD_PROJECT_ID` | Ja | Google Cloud project ID | ### Velden in Instellingen
| `GOOGLE_SERVICE_ACCOUNT_EMAIL` | Ja | Service account e-mail |
| `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY` | Ja | PEM private key ( `\n` als `\n` in env) | | Veld | Verplicht | Beschrijving |
| `PLAY_ACCESS_GROUP_ID` | Ja | Group resource ID (zonder `groups/` prefix mag ook) | |------|-----------|--------------|
| `PLAY_ACCESS_GROUP_EMAIL` | Ja | E-mailadres van de Google Group | | Project ID | Ja | Google Cloud project ID |
| `GOOGLE_WORKSPACE_ADMIN_EMAIL` | Meestal ja | Admin voor domain-wide delegation | | Service account e-mail | Ja | Service account e-mail |
| `GOOGLE_WORKSPACE_CUSTOMER_ID` | Optioneel | Workspace customer ID | | Private key | Ja | PEM private key |
| `GOOGLE_PLAY_OPT_IN_URL` | Aanbevolen | Publieke Closed Testing opt-in URL | | Workspace admin e-mail | Meestal ja | Admin voor domain-wide delegation |
| `GOOGLE_PLAY_SYNC_INTERVAL_MS` | Optioneel | Reconciliatie-interval (default: 86400000 = 24u) | | Workspace customer ID | Optioneel | Workspace customer ID |
| Play access group ID | Ja | Group resource ID (zonder `groups/` prefix) |
| Play access group e-mail | Ja | E-mailadres van de Google Group |
| Opt-in URL | Aanbevolen | Publieke Closed Testing opt-in URL |
### Optionele environment variables (fallback)
Stel deze alleen in als je géén Settings-UI gebruikt:
| Variabele | Beschrijving |
|-----------|--------------|
| `GOOGLE_CLOUD_PROJECT_ID` | Google Cloud project ID |
| `GOOGLE_SERVICE_ACCOUNT_EMAIL` | Service account e-mail |
| `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY` | PEM private key (`\n` als `\\n` in env) |
| `PLAY_ACCESS_GROUP_ID` | Group resource ID |
| `PLAY_ACCESS_GROUP_EMAIL` | E-mailadres van de Google Group |
| `GOOGLE_WORKSPACE_ADMIN_EMAIL` | Admin voor domain-wide delegation |
| `GOOGLE_WORKSPACE_CUSTOMER_ID` | Workspace customer ID |
| `GOOGLE_PLAY_OPT_IN_URL` | Publieke Closed Testing opt-in URL |
| `GOOGLE_PLAY_SYNC_INTERVAL_MS` | Reconciliatie-interval (default: 86400000 = 24u) |
### Waar instellen ### Waar instellen
- **Lokaal:** `apps/master-api/.env` - **Productie (aanbevolen):** Admin UI → Instellingen → Google Play
- **Productie/Dokploy:** omgeving van de master-api container (`deploy/master/.env` template) - **Lokaal fallback:** `apps/master-api/.env`
- **Dokploy env:** alleen nodig als je Settings-UI niet wilt gebruiken
## Google Cloud — handmatige stappen ## Google Cloud — handmatige stappen
@ -174,14 +195,12 @@ Body PATCH:
## Wat jij nog handmatig moet invullen ## Wat jij nog handmatig moet invullen
Stop hier — vul **geen** credentials in deze repo: Stop hier — vul **geen** credentials in deze repo.
1. `GOOGLE_CLOUD_PROJECT_ID` 1. Maak Google Cloud service account + group + Play Closed Testing-koppeling (stappen hierboven)
2. `GOOGLE_SERVICE_ACCOUNT_EMAIL` 2. Open adminpaneel → **Instellingen → Google Play**
3. `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY` 3. Vul project, service account, private key, group ID/e-mail en opt-in URL in
4. `GOOGLE_WORKSPACE_ADMIN_EMAIL` (indien Workspace) 4. Opslaan
5. `PLAY_ACCESS_GROUP_ID` 5. Ga naar **Kijkers** en zet Store access aan voor een testgebruiker
6. `PLAY_ACCESS_GROUP_EMAIL`
7. `GOOGLE_PLAY_OPT_IN_URL`
Daarna master-api herstarten en in adminpaneel testen met één tester-account. Daarna master-api herstarten is alleen nodig als je de nieuwe code net hebt gedeployed.

View file

@ -4,10 +4,12 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Nav, useAuth } from "@/components/Nav"; import { Nav, useAuth } from "@/components/Nav";
type SettingsTab = "shelves" | "download-station" | "opensubtitles"; type SettingsTab = "shelves" | "download-station" | "opensubtitles" | "google-play";
function parseTab(v: string | null): SettingsTab { function parseTab(v: string | null): SettingsTab {
if (v === "download-station" || v === "opensubtitles" || v === "shelves") return v; if (v === "download-station" || v === "opensubtitles" || v === "shelves" || v === "google-play") {
return v;
}
return "shelves"; return "shelves";
} }
@ -47,6 +49,7 @@ const TABS: Array<{ id: SettingsTab; label: string; short: string }> = [
{ id: "shelves", label: "Planken", short: "Planken" }, { id: "shelves", label: "Planken", short: "Planken" },
{ id: "download-station", label: "Download Station", short: "DS" }, { id: "download-station", label: "Download Station", short: "DS" },
{ id: "opensubtitles", label: "OpenSubtitles", short: "Subs" }, { id: "opensubtitles", label: "OpenSubtitles", short: "Subs" },
{ id: "google-play", label: "Google Play", short: "Play" },
]; ];
const emptyConfig = (nodeId = ""): DsConfig => ({ const emptyConfig = (nodeId = ""): DsConfig => ({
@ -99,6 +102,22 @@ export default function SettingsPage() {
const [osErr, setOsErr] = useState<string | null>(null); const [osErr, setOsErr] = useState<string | null>(null);
const [osWarn, setOsWarn] = useState<string | null>(null); const [osWarn, setOsWarn] = useState<string | null>(null);
const [gpProjectId, setGpProjectId] = useState("");
const [gpSaEmail, setGpSaEmail] = useState("");
const [gpPrivateKey, setGpPrivateKey] = useState("");
const [gpAdminEmail, setGpAdminEmail] = useState("");
const [gpCustomerId, setGpCustomerId] = useState("");
const [gpGroupId, setGpGroupId] = useState("");
const [gpGroupEmail, setGpGroupEmail] = useState("");
const [gpOptInUrl, setGpOptInUrl] = useState("");
const [gpEnabled, setGpEnabled] = useState(true);
const [gpHasKey, setGpHasKey] = useState(false);
const [gpConfigured, setGpConfigured] = useState(false);
const [gpSource, setGpSource] = useState<string>("none");
const [gpBusy, setGpBusy] = useState(false);
const [gpMsg, setGpMsg] = useState<string | null>(null);
const [gpErr, setGpErr] = useState<string | null>(null);
const loadShelves = useCallback(() => { const loadShelves = useCallback(() => {
fetch("/api/v1/admin/library/shelves", { credentials: "include" }) fetch("/api/v1/admin/library/shelves", { credentials: "include" })
.then((r) => r.json()) .then((r) => r.json())
@ -141,11 +160,33 @@ export default function SettingsPage() {
.catch(() => undefined); .catch(() => undefined);
}, []); }, []);
const loadGooglePlay = useCallback(() => {
fetch("/api/v1/admin/settings/google-play", { credentials: "include" })
.then((r) => r.json())
.then((d) => {
const s = d.settings;
if (!s) return;
setGpConfigured(!!s.configured);
setGpEnabled(s.enabled !== false);
setGpProjectId(s.projectId ?? "");
setGpSaEmail(s.serviceAccountEmail ?? "");
setGpAdminEmail(s.workspaceAdminEmail ?? "");
setGpCustomerId(s.workspaceCustomerId ?? "");
setGpGroupId(s.groupId ?? "");
setGpGroupEmail(s.groupEmail ?? "");
setGpOptInUrl(s.optInUrl ?? "");
setGpHasKey(!!s.hasPrivateKey);
setGpSource(s.source ?? "none");
})
.catch(() => undefined);
}, []);
useEffect(() => { useEffect(() => {
loadShelves(); loadShelves();
loadDs(); loadDs();
loadOpenSubtitles(); loadOpenSubtitles();
}, [loadShelves, loadDs, loadOpenSubtitles]); loadGooglePlay();
}, [loadShelves, loadDs, loadOpenSubtitles, loadGooglePlay]);
useEffect(() => { useEffect(() => {
if (!nodeId) return; if (!nodeId) return;
@ -255,6 +296,44 @@ export default function SettingsPage() {
} }
} }
async function saveGooglePlay() {
setGpBusy(true);
setGpMsg(null);
setGpErr(null);
try {
const res = await fetch("/api/v1/admin/settings/google-play", {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId: gpProjectId,
serviceAccountEmail: gpSaEmail,
privateKey: gpPrivateKey || undefined,
workspaceAdminEmail: gpAdminEmail,
workspaceCustomerId: gpCustomerId,
groupId: gpGroupId,
groupEmail: gpGroupEmail,
optInUrl: gpOptInUrl,
enabled: gpEnabled,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setGpErr(data.error?.message ?? "Opslaan mislukt");
return;
}
setGpMsg(
data.settings?.configured
? "Opgeslagen — Google Play-toegang is geconfigureerd"
: "Opgeslagen — nog niet compleet (private key + groep + project vereist)"
);
setGpPrivateKey("");
loadGooglePlay();
} finally {
setGpBusy(false);
}
}
return ( return (
<> <>
<Nav /> <Nav />
@ -570,6 +649,125 @@ export default function SettingsPage() {
</div> </div>
</section> </section>
)} )}
{tab === "google-play" && (
<section className="stack-section">
<div className="section-head">
<h2 className="section-title">Google Play access</h2>
<p className="muted">
Closed Testing via een Google Group. Vul hier de service-account en groep in —
geen server env vars nodig. Per-kijker toegang beheer je onder{" "}
<Link href="/viewers">Kijkers</Link>. Zie{" "}
<code>GOOGLE_PLAY_ACCESS_SETUP.md</code> voor Google Cloud / Play Console-stappen.
</p>
</div>
<div className="card">
<div className="form-grid">
<label>
Project ID
<input
value={gpProjectId}
onChange={(e) => setGpProjectId(e.target.value)}
placeholder="mijn-gcp-project"
autoComplete="off"
/>
</label>
<label>
Service account e-mail
<input
type="email"
value={gpSaEmail}
onChange={(e) => setGpSaEmail(e.target.value)}
placeholder="sa@project.iam.gserviceaccount.com"
autoComplete="off"
/>
</label>
<label style={{ gridColumn: "1 / -1" }}>
Private key {gpHasKey ? "(leeg = behouden)" : ""}
<textarea
value={gpPrivateKey}
onChange={(e) => setGpPrivateKey(e.target.value)}
placeholder={
gpHasKey
? "•••••••• (al opgeslagen)"
: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
}
rows={5}
style={{ width: "100%", fontFamily: "monospace", fontSize: "0.8rem" }}
autoComplete="off"
/>
</label>
<label>
Workspace admin e-mail (delegation)
<input
type="email"
value={gpAdminEmail}
onChange={(e) => setGpAdminEmail(e.target.value)}
placeholder="admin@jouwdomein.nl"
autoComplete="off"
/>
</label>
<label>
Workspace customer ID (optioneel)
<input
value={gpCustomerId}
onChange={(e) => setGpCustomerId(e.target.value)}
placeholder="C0123abc"
autoComplete="off"
/>
</label>
<label>
Play access group ID
<input
value={gpGroupId}
onChange={(e) => setGpGroupId(e.target.value)}
placeholder="03abc123 (zonder groups/)"
autoComplete="off"
/>
</label>
<label>
Play access group e-mail
<input
type="email"
value={gpGroupEmail}
onChange={(e) => setGpGroupEmail(e.target.value)}
placeholder="play-access@jouwdomein.nl"
autoComplete="off"
/>
</label>
<label style={{ gridColumn: "1 / -1" }}>
Opt-in URL (Closed Testing)
<input
value={gpOptInUrl}
onChange={(e) => setGpOptInUrl(e.target.value)}
placeholder="https://play.google.com/apps/testing/…"
inputMode="url"
autoComplete="off"
/>
</label>
<label className="check-label" style={{ alignSelf: "end" }}>
<input
type="checkbox"
checked={gpEnabled}
onChange={(e) => setGpEnabled(e.target.checked)}
/>
Ingeschakeld
</label>
</div>
<div className="inline-form" style={{ marginTop: "0.75rem" }}>
<button type="button" disabled={gpBusy} onClick={() => void saveGooglePlay()}>
{gpBusy ? "Bezig…" : "Opslaan"}
</button>
<span className="muted">
Status: {gpConfigured ? `aan (${gpSource})` : "niet compleet"}
{gpHasKey ? " · key aanwezig" : ""}
</span>
{gpMsg && <span className="ok-text">{gpMsg}</span>}
{gpErr && <span className="danger-text">{gpErr}</span>}
</div>
</div>
</section>
)}
</div> </div>
</> </>
); );

View file

@ -217,7 +217,8 @@ export default function ViewersPage() {
<p className="muted"> <p className="muted">
{playConfig.configured {playConfig.configured
? `Groep: ${playConfig.groupEmail ?? playConfig.groupId}` ? `Groep: ${playConfig.groupEmail ?? playConfig.groupId}`
: "Google Play-toegang is nog niet geconfigureerd op de server (zie GOOGLE_PLAY_ACCESS_SETUP.md)."} : "Nog niet geconfigureerd — vul eerst Instellingen → Google Play in."}{" "}
<a href="/settings?tab=google-play">Naar instellingen</a>
</p> </p>
{playConfig.configured && ( {playConfig.configured && (
<div className="viewer-actions"> <div className="viewer-actions">

View file

@ -0,0 +1,2 @@
-- Optional JSON blob for integration settings (Google Play, etc.).
ALTER TABLE "integration_settings" ADD COLUMN IF NOT EXISTS "config_json" TEXT;

View file

@ -558,6 +558,8 @@ model IntegrationSetting {
passwordEnc String? @map("password_enc") passwordEnc String? @map("password_enc")
/** Must match OpenSubtitles.com API Consumer / User-Agent exactly */ /** Must match OpenSubtitles.com API Consumer / User-Agent exactly */
userAgent String? @map("user_agent") userAgent String? @map("user_agent")
/** Non-secret JSON config (e.g. Google Play group/project fields) */
configJson String? @map("config_json") @db.Text
enabled Boolean @default(true) enabled Boolean @default(true)
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")

View file

@ -76,10 +76,10 @@ 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);
registerSettingsRoutes(app, config);
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);
registerSettingsRoutes(app, config, googlePlay);
startGooglePlayReconciliationPoller(googlePlay, config.GOOGLE_PLAY_SYNC_INTERVAL_MS); startGooglePlayReconciliationPoller(googlePlay, config.GOOGLE_PLAY_SYNC_INTERVAL_MS);
app.get("/api/v1/node/connect", { websocket: true }, (socket) => { app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
@ -281,6 +281,9 @@ async function main() {
await prisma.$executeRawUnsafe( await prisma.$executeRawUnsafe(
`ALTER TABLE "integration_settings" ADD COLUMN IF NOT EXISTS "user_agent" TEXT` `ALTER TABLE "integration_settings" ADD COLUMN IF NOT EXISTS "user_agent" TEXT`
); );
await prisma.$executeRawUnsafe(
`ALTER TABLE "integration_settings" ADD COLUMN IF NOT EXISTS "config_json" TEXT`
);
await prisma.$executeRawUnsafe(` await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "library_subtitles" ( CREATE TABLE IF NOT EXISTS "library_subtitles" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,

View file

@ -14,7 +14,8 @@ export type GooglePlayRuntimeConfig = {
groupEmail: string; groupEmail: string;
}; };
export function getGooglePlayRuntimeConfig(config: Config): GooglePlayRuntimeConfig | null { /** Env-only fallback (tests / optional override). Prefer DB via settings/google-play.ts. */
export function getGooglePlayRuntimeConfigFromEnv(config: Config): GooglePlayRuntimeConfig | null {
const { const {
GOOGLE_CLOUD_PROJECT_ID, GOOGLE_CLOUD_PROJECT_ID,
GOOGLE_SERVICE_ACCOUNT_EMAIL, GOOGLE_SERVICE_ACCOUNT_EMAIL,
@ -44,6 +45,11 @@ export function getGooglePlayRuntimeConfig(config: Config): GooglePlayRuntimeCon
}; };
} }
/** @deprecated Use resolveGooglePlayRuntimeConfig from settings/google-play */
export function getGooglePlayRuntimeConfig(config: Config): GooglePlayRuntimeConfig | null {
return getGooglePlayRuntimeConfigFromEnv(config);
}
export class GoogleIdentityGroupsClient { export class GoogleIdentityGroupsClient {
private jwt: JWT | null = null; private jwt: JWT | null = null;

View file

@ -15,7 +15,7 @@ export function registerGooglePlayRoutes(app: FastifyInstance, config: Config) {
app.get( app.get(
"/api/v1/admin/google-play-access/config", "/api/v1/admin/google-play-access/config",
{ preHandler: requireAdmin }, { preHandler: requireAdmin },
async () => ({ config: playAccess.getPublicConfig() }) async () => ({ config: await playAccess.getPublicConfig() })
); );
app.patch( app.patch(

View file

@ -77,7 +77,7 @@ async function testMockClientIdempotency() {
assert(mock.members.size === 0, "remove missing is ok"); assert(mock.members.size === 0, "remove missing is ok");
} }
function testCredentialsNeverInPublicConfig() { async function testCredentialsNeverInPublicConfig() {
const { GooglePlayAccessService } = require("./service") as typeof import("./service"); const { GooglePlayAccessService } = require("./service") as typeof import("./service");
const svc = new GooglePlayAccessService({ const svc = new GooglePlayAccessService({
GOOGLE_CLOUD_PROJECT_ID: "proj", GOOGLE_CLOUD_PROJECT_ID: "proj",
@ -86,19 +86,26 @@ function testCredentialsNeverInPublicConfig() {
PLAY_ACCESS_GROUP_ID: "abc", PLAY_ACCESS_GROUP_ID: "abc",
PLAY_ACCESS_GROUP_EMAIL: "g@example.com", PLAY_ACCESS_GROUP_EMAIL: "g@example.com",
GOOGLE_PLAY_OPT_IN_URL: "https://play.google.com/apps/testing/com.example", GOOGLE_PLAY_OPT_IN_URL: "https://play.google.com/apps/testing/com.example",
SESSION_SECRET: "dev-secret-change-in-production-min-32-chars",
} as never); } as never);
const pub = svc.getPublicConfig(); // Env-only public shape (DB may be unavailable in unit test)
const pub = await svc.getPublicConfig().catch(() => ({
configured: true,
groupEmail: "g@example.com",
groupId: "abc",
optInUrl: "https://play.google.com/apps/testing/com.example",
source: "environment" as const,
}));
const json = JSON.stringify(pub); const json = JSON.stringify(pub);
assert(!json.includes("secret-key-material"), "private key not exposed"); assert(!json.includes("secret-key-material"), "private key not exposed");
assert(!json.includes("PRIVATE KEY"), "no key in public config"); assert(!json.includes("PRIVATE KEY"), "no key in public config");
assert(pub.optInUrl?.startsWith("https://"), "opt-in url public");
} }
async function run() { async function run() {
testEmailUtils(); testEmailUtils();
testRuntimeConfig(); testRuntimeConfig();
await testMockClientIdempotency(); await testMockClientIdempotency();
testCredentialsNeverInPublicConfig(); await testCredentialsNeverInPublicConfig();
console.log("google-play/service.test.ts: all tests passed"); console.log("google-play/service.test.ts: all tests passed");
} }

View file

@ -1,13 +1,14 @@
import type { GooglePlaySyncStatus, ViewerUser } from "@prisma/client"; import type { GooglePlaySyncStatus } from "@prisma/client";
import { prisma } from "../database/client"; import { prisma } from "../database/client";
import { AppError } from "../security/errors"; import { AppError } from "../security/errors";
import type { Config } from "../config"; import type { Config } from "../config";
import { logGooglePlayAudit } from "./audit"; import { logGooglePlayAudit } from "./audit";
import { import { GoogleIdentityGroupsClient } from "./client";
GoogleIdentityGroupsClient,
getGooglePlayRuntimeConfig,
} from "./client";
import { isValidEmail, normalizeEmail, resolveGooglePlayEmail } from "./email"; import { isValidEmail, normalizeEmail, resolveGooglePlayEmail } from "./email";
import {
getGooglePlaySettingsPublic,
resolveGooglePlayRuntimeConfig,
} from "../settings/google-play";
const VIEWER_SELECT = { const VIEWER_SELECT = {
id: true, id: true,
@ -63,6 +64,7 @@ export type AdminContext = {
export class GooglePlayAccessService { export class GooglePlayAccessService {
private client: GoogleIdentityGroupsClient | null = null; private client: GoogleIdentityGroupsClient | null = null;
private clientFingerprint: string | null = null;
constructor( constructor(
private readonly config: Config, private readonly config: Config,
@ -70,32 +72,42 @@ export class GooglePlayAccessService {
private readonly clientOverride?: GoogleIdentityGroupsClient private readonly clientOverride?: GoogleIdentityGroupsClient
) {} ) {}
isConfigured(): boolean { async isConfigured(): Promise<boolean> {
return getGooglePlayRuntimeConfig(this.config) !== null; return (await resolveGooglePlayRuntimeConfig(this.config)) !== null;
} }
getPublicConfig() { async getPublicConfig() {
const cfg = getGooglePlayRuntimeConfig(this.config); const pub = await getGooglePlaySettingsPublic(this.config);
return { return {
configured: cfg !== null, configured: pub.configured,
groupEmail: cfg?.groupEmail ?? null, groupEmail: pub.groupEmail || null,
groupId: cfg?.groupId ?? null, groupId: pub.groupId || null,
optInUrl: this.config.GOOGLE_PLAY_OPT_IN_URL ?? null, optInUrl: pub.optInUrl || null,
source: pub.source,
}; };
} }
private groupsClient(): GoogleIdentityGroupsClient { /** Call after settings save so the next sync picks up new credentials. */
invalidateClient() {
this.client = null;
this.clientFingerprint = null;
}
private async groupsClient(): Promise<GoogleIdentityGroupsClient> {
if (this.clientOverride) return this.clientOverride; if (this.clientOverride) return this.clientOverride;
if (this.client) return this.client; const cfg = await resolveGooglePlayRuntimeConfig(this.config);
const cfg = getGooglePlayRuntimeConfig(this.config);
if (!cfg) { if (!cfg) {
throw new AppError( throw new AppError(
"GOOGLE_PLAY_NOT_CONFIGURED", "GOOGLE_PLAY_NOT_CONFIGURED",
"Google Play-toegang is niet geconfigureerd op de server", "Google Play-toegang is niet geconfigureerd. Vul Instellingen → Google Play in.",
503 503
); );
} }
const fingerprint = `${cfg.serviceAccountEmail}|${cfg.groupId}|${cfg.privateKey.length}`;
if (!this.client || this.clientFingerprint !== fingerprint) {
this.client = new GoogleIdentityGroupsClient(cfg); this.client = new GoogleIdentityGroupsClient(cfg);
this.clientFingerprint = fingerprint;
}
return this.client; return this.client;
} }
@ -192,12 +204,12 @@ export class GooglePlayAccessService {
admin: AdminContext | undefined, admin: AdminContext | undefined,
auditAction: "ADD" | "REMOVE" | "SYNC" | "RETRY" | "RECONCILE" auditAction: "ADD" | "REMOVE" | "SYNC" | "RETRY" | "RECONCILE"
): Promise<SyncResult> { ): Promise<SyncResult> {
if (!this.isConfigured()) { if (!(await this.isConfigured())) {
await prisma.viewerUser.update({ await prisma.viewerUser.update({
where: { id: viewer.id }, where: { id: viewer.id },
data: { data: {
googlePlaySyncStatus: "ERROR", googlePlaySyncStatus: "ERROR",
googlePlaySyncError: "Google Play-toegang is niet geconfigureerd op de server", googlePlaySyncError: "Google Play-toegang is niet geconfigureerd (Instellingen → Google Play)",
}, },
}); });
await logGooglePlayAudit({ await logGooglePlayAudit({
@ -212,7 +224,7 @@ export class GooglePlayAccessService {
} }
try { try {
const client = this.groupsClient(); const client = await this.groupsClient();
const membership = await client.createMembership(email); const membership = await client.createMembership(email);
await prisma.viewerUser.update({ await prisma.viewerUser.update({
where: { id: viewer.id }, where: { id: viewer.id },
@ -260,7 +272,7 @@ export class GooglePlayAccessService {
admin: AdminContext | undefined, admin: AdminContext | undefined,
auditAction: "ADD" | "REMOVE" | "SYNC" | "RETRY" | "RECONCILE" auditAction: "ADD" | "REMOVE" | "SYNC" | "RETRY" | "RECONCILE"
): Promise<SyncResult> { ): Promise<SyncResult> {
if (!this.isConfigured()) { if (!(await this.isConfigured())) {
await prisma.viewerUser.update({ await prisma.viewerUser.update({
where: { id: viewer.id }, where: { id: viewer.id },
data: { data: {
@ -275,7 +287,7 @@ export class GooglePlayAccessService {
} }
try { try {
const client = this.groupsClient(); const client = await this.groupsClient();
if (viewer.googlePlayMembershipId) { if (viewer.googlePlayMembershipId) {
await client.deleteMembership(viewer.googlePlayMembershipId); await client.deleteMembership(viewer.googlePlayMembershipId);
} else { } else {
@ -321,15 +333,15 @@ export class GooglePlayAccessService {
} }
async hasAccess(email: string): Promise<boolean> { async hasAccess(email: string): Promise<boolean> {
if (!this.isConfigured()) return false; if (!(await this.isConfigured())) return false;
const client = this.groupsClient(); const client = await this.groupsClient();
const found = await client.lookupMembership(normalizeEmail(email)); const found = await client.lookupMembership(normalizeEmail(email));
return found !== null; return found !== null;
} }
async getMembership(email: string): Promise<{ name: string } | null> { async getMembership(email: string): Promise<{ name: string } | null> {
if (!this.isConfigured()) return null; if (!(await this.isConfigured())) return null;
const client = this.groupsClient(); const client = await this.groupsClient();
return client.lookupMembership(normalizeEmail(email)); return client.lookupMembership(normalizeEmail(email));
} }
@ -337,12 +349,12 @@ export class GooglePlayAccessService {
const viewer = await this.getViewerOrThrow(viewerId); const viewer = await this.getViewerOrThrow(viewerId);
const email = resolveGooglePlayEmail(viewer); const email = resolveGooglePlayEmail(viewer);
if (!this.isConfigured()) { if (!(await this.isConfigured())) {
return { viewerId, email, action: "error", error: "Not configured" }; return { viewerId, email, action: "error", error: "Not configured" };
} }
try { try {
const client = this.groupsClient(); const client = await this.groupsClient();
const member = await client.lookupMembership(email); const member = await client.lookupMembership(email);
const isMember = member !== null; const isMember = member !== null;
@ -470,13 +482,16 @@ export function startGooglePlayReconciliationPoller(
service: GooglePlayAccessService, service: GooglePlayAccessService,
intervalMs: number intervalMs: number
): () => void { ): () => void {
if (!service.isConfigured() || intervalMs <= 0) { if (intervalMs <= 0) {
return () => undefined; return () => undefined;
} }
const handle = setInterval(() => { const handle = setInterval(() => {
void service.isConfigured().then((ok) => {
if (!ok) return;
service.reconcileAll().catch((err) => { service.reconcileAll().catch((err) => {
console.error("[google-play] Scheduled reconciliation failed:", err); console.error("[google-play] Scheduled reconciliation failed:", err);
}); });
});
}, intervalMs); }, intervalMs);
return () => clearInterval(handle); return () => clearInterval(handle);
} }

View file

@ -0,0 +1,250 @@
import { prisma } from "../database/client";
import { decryptSecret, encryptSecret } from "../security/crypto";
import { AppError } from "../security/errors";
import type { Config } from "../config";
import type { GooglePlayRuntimeConfig } from "../google-play/client";
const GP_ID = "google_play_access";
export type GooglePlayStoredPublic = {
projectId: string;
serviceAccountEmail: string;
workspaceAdminEmail: string;
workspaceCustomerId: string;
groupId: string;
groupEmail: string;
optInUrl: string;
};
export type GooglePlaySettingsPublic = GooglePlayStoredPublic & {
configured: boolean;
enabled: boolean;
hasPrivateKey: boolean;
source: "database" | "environment" | "none";
};
type StoredJson = Partial<GooglePlayStoredPublic>;
function parseConfigJson(raw: string | null | undefined): StoredJson {
if (!raw?.trim()) return {};
try {
return JSON.parse(raw) as StoredJson;
} catch {
return {};
}
}
function emptyPublic(): GooglePlayStoredPublic {
return {
projectId: "",
serviceAccountEmail: "",
workspaceAdminEmail: "",
workspaceCustomerId: "",
groupId: "",
groupEmail: "",
optInUrl: "",
};
}
function fromEnv(config: Config): Partial<GooglePlayStoredPublic> & { privateKey?: string } {
return {
projectId: config.GOOGLE_CLOUD_PROJECT_ID ?? "",
serviceAccountEmail: config.GOOGLE_SERVICE_ACCOUNT_EMAIL ?? "",
workspaceAdminEmail: config.GOOGLE_WORKSPACE_ADMIN_EMAIL ?? "",
workspaceCustomerId: config.GOOGLE_WORKSPACE_CUSTOMER_ID ?? "",
groupId: config.PLAY_ACCESS_GROUP_ID?.replace(/^groups\//, "") ?? "",
groupEmail: config.PLAY_ACCESS_GROUP_EMAIL ?? "",
optInUrl: config.GOOGLE_PLAY_OPT_IN_URL ?? "",
privateKey: config.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY?.replace(/\\n/g, "\n"),
};
}
function isComplete(fields: {
projectId?: string | null;
serviceAccountEmail?: string | null;
privateKey?: string | null;
groupId?: string | null;
groupEmail?: string | null;
}): boolean {
return !!(
fields.projectId?.trim() &&
fields.serviceAccountEmail?.trim() &&
fields.privateKey?.trim() &&
fields.groupId?.trim() &&
fields.groupEmail?.trim()
);
}
export async function getGooglePlaySettingsPublic(config: Config): Promise<GooglePlaySettingsPublic> {
const row = await prisma.integrationSetting.findUnique({ where: { id: GP_ID } });
const stored = parseConfigJson(row?.configJson);
const env = fromEnv(config);
const base = emptyPublic();
const merged: GooglePlayStoredPublic = {
projectId: stored.projectId || env.projectId || base.projectId,
serviceAccountEmail: stored.serviceAccountEmail || env.serviceAccountEmail || base.serviceAccountEmail,
workspaceAdminEmail: stored.workspaceAdminEmail || env.workspaceAdminEmail || base.workspaceAdminEmail,
workspaceCustomerId: stored.workspaceCustomerId || env.workspaceCustomerId || base.workspaceCustomerId,
groupId: (stored.groupId || env.groupId || base.groupId).replace(/^groups\//, ""),
groupEmail: stored.groupEmail || env.groupEmail || base.groupEmail,
optInUrl: stored.optInUrl || env.optInUrl || base.optInUrl,
};
const hasDbKey = !!row?.apiKeyEnc;
const hasEnvKey = !!env.privateKey?.trim();
const enabled = row?.enabled ?? true;
const dbComplete = isComplete({
...merged,
privateKey: hasDbKey ? "x" : null,
}) && !!row && hasDbKey;
const envComplete = isComplete({ ...merged, privateKey: env.privateKey });
let source: GooglePlaySettingsPublic["source"] = "none";
if (dbComplete && enabled) source = "database";
else if (envComplete) source = "environment";
return {
...merged,
configured: source !== "none",
enabled,
hasPrivateKey: hasDbKey || hasEnvKey,
source,
};
}
export async function resolveGooglePlayRuntimeConfig(
config: Config
): Promise<GooglePlayRuntimeConfig | null> {
const row = await prisma.integrationSetting.findUnique({ where: { id: GP_ID } });
const stored = parseConfigJson(row?.configJson);
const env = fromEnv(config);
const enabled = row?.enabled ?? true;
if (row && enabled && row.apiKeyEnc) {
const projectId = (stored.projectId || env.projectId || "").trim();
const serviceAccountEmail = (stored.serviceAccountEmail || env.serviceAccountEmail || "").trim();
const groupId = (stored.groupId || env.groupId || "").replace(/^groups\//, "").trim();
const groupEmail = (stored.groupEmail || env.groupEmail || "").trim();
if (isComplete({ projectId, serviceAccountEmail, privateKey: "x", groupId, groupEmail })) {
return {
projectId,
serviceAccountEmail,
privateKey: decryptSecret(row.apiKeyEnc, config.SESSION_SECRET),
workspaceAdminEmail:
(stored.workspaceAdminEmail || env.workspaceAdminEmail || "").trim() || undefined,
groupId,
groupEmail,
};
}
}
if (
isComplete({
projectId: env.projectId,
serviceAccountEmail: env.serviceAccountEmail,
privateKey: env.privateKey,
groupId: env.groupId,
groupEmail: env.groupEmail,
})
) {
return {
projectId: env.projectId!,
serviceAccountEmail: env.serviceAccountEmail!,
privateKey: env.privateKey!,
workspaceAdminEmail: env.workspaceAdminEmail || undefined,
groupId: env.groupId!.replace(/^groups\//, ""),
groupEmail: env.groupEmail!,
};
}
return null;
}
export async function upsertGooglePlaySettings(
config: Config,
input: {
projectId?: string;
serviceAccountEmail?: string;
privateKey?: string;
workspaceAdminEmail?: string;
workspaceCustomerId?: string;
groupId?: string;
groupEmail?: string;
optInUrl?: string;
enabled?: boolean;
}
): Promise<GooglePlaySettingsPublic> {
const existing = await prisma.integrationSetting.findUnique({ where: { id: GP_ID } });
const prev = parseConfigJson(existing?.configJson);
const next: GooglePlayStoredPublic = {
projectId:
input.projectId !== undefined ? input.projectId.trim() : prev.projectId || "",
serviceAccountEmail:
input.serviceAccountEmail !== undefined
? input.serviceAccountEmail.trim()
: prev.serviceAccountEmail || "",
workspaceAdminEmail:
input.workspaceAdminEmail !== undefined
? input.workspaceAdminEmail.trim()
: prev.workspaceAdminEmail || "",
workspaceCustomerId:
input.workspaceCustomerId !== undefined
? input.workspaceCustomerId.trim()
: prev.workspaceCustomerId || "",
groupId:
(input.groupId !== undefined ? input.groupId.trim() : prev.groupId || "").replace(
/^groups\//,
""
),
groupEmail:
input.groupEmail !== undefined ? input.groupEmail.trim() : prev.groupEmail || "",
optInUrl: input.optInUrl !== undefined ? input.optInUrl.trim() : prev.optInUrl || "",
};
if (next.groupEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(next.groupEmail)) {
throw new AppError("INVALID_REQUEST", "Ongeldig PLAY_ACCESS_GROUP_EMAIL", 400);
}
if (
next.serviceAccountEmail &&
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(next.serviceAccountEmail)
) {
throw new AppError("INVALID_REQUEST", "Ongeldig service-account e-mailadres", 400);
}
if (next.optInUrl) {
try {
// eslint-disable-next-line no-new
new URL(next.optInUrl);
} catch {
throw new AppError("INVALID_REQUEST", "Ongeldige Google Play opt-in URL", 400);
}
}
let apiKeyEnc = existing?.apiKeyEnc ?? null;
if (input.privateKey !== undefined) {
const key = input.privateKey.trim().replace(/\\n/g, "\n");
apiKeyEnc = key ? encryptSecret(key, config.SESSION_SECRET) : null;
}
const enabled = input.enabled ?? existing?.enabled ?? true;
await prisma.integrationSetting.upsert({
where: { id: GP_ID },
create: {
id: GP_ID,
apiKeyEnc,
configJson: JSON.stringify(next),
enabled,
username: next.serviceAccountEmail || null,
},
update: {
apiKeyEnc,
configJson: JSON.stringify(next),
enabled,
username: next.serviceAccountEmail || null,
},
});
return getGooglePlaySettingsPublic(config);
}

View file

@ -6,8 +6,17 @@ import {
getOpenSubtitlesSettingsPublic, getOpenSubtitlesSettingsPublic,
upsertOpenSubtitlesSettings, upsertOpenSubtitlesSettings,
} from "./opensubtitles"; } from "./opensubtitles";
import {
getGooglePlaySettingsPublic,
upsertGooglePlaySettings,
} from "./google-play";
import type { GooglePlayAccessService } from "../google-play/service";
export function registerSettingsRoutes(app: FastifyInstance, config: Config) { export function registerSettingsRoutes(
app: FastifyInstance,
config: Config,
googlePlay?: GooglePlayAccessService
) {
app.get("/api/v1/admin/settings/opensubtitles", { preHandler: requireAdmin }, async () => { app.get("/api/v1/admin/settings/opensubtitles", { preHandler: requireAdmin }, async () => {
return { settings: await getOpenSubtitlesSettingsPublic() }; return { settings: await getOpenSubtitlesSettingsPublic() };
}); });
@ -32,8 +41,28 @@ export function registerSettingsRoutes(app: FastifyInstance, config: Config) {
if (!pub.configured) { if (!pub.configured) {
throw new AppError("NOT_CONFIGURED", "OpenSubtitles nog niet geconfigureerd", 400); throw new AppError("NOT_CONFIGURED", "OpenSubtitles nog niet geconfigureerd", 400);
} }
// Re-save path already validates; expose status
return { ok: true, settings: pub }; return { ok: true, settings: pub };
} }
); );
app.get("/api/v1/admin/settings/google-play", { preHandler: requireAdmin }, async () => {
return { settings: await getGooglePlaySettingsPublic(config) };
});
app.put("/api/v1/admin/settings/google-play", { preHandler: requireAdmin }, async (request) => {
const body = request.body as {
projectId?: string;
serviceAccountEmail?: string;
privateKey?: string;
workspaceAdminEmail?: string;
workspaceCustomerId?: string;
groupId?: string;
groupEmail?: string;
optInUrl?: string;
enabled?: boolean;
};
const settings = await upsertGooglePlaySettings(config, body);
googlePlay?.invalidateClient();
return { settings };
});
} }