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:
parent
4d96940b20
commit
4f76e39124
12 changed files with 601 additions and 69 deletions
|
|
@ -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`.
|
||||
- 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 |
|
||||
|-----------|-----------|--------------|
|
||||
| `GOOGLE_CLOUD_PROJECT_ID` | Ja | Google Cloud project ID |
|
||||
| `GOOGLE_SERVICE_ACCOUNT_EMAIL` | Ja | Service account e-mail |
|
||||
| `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY` | Ja | PEM private key ( `\n` als `\n` in env) |
|
||||
| `PLAY_ACCESS_GROUP_ID` | Ja | Group resource ID (zonder `groups/` prefix mag ook) |
|
||||
| `PLAY_ACCESS_GROUP_EMAIL` | Ja | E-mailadres van de Google Group |
|
||||
| `GOOGLE_WORKSPACE_ADMIN_EMAIL` | Meestal ja | Admin voor domain-wide delegation |
|
||||
| `GOOGLE_WORKSPACE_CUSTOMER_ID` | Optioneel | Workspace customer ID |
|
||||
| `GOOGLE_PLAY_OPT_IN_URL` | Aanbevolen | Publieke Closed Testing opt-in URL |
|
||||
| `GOOGLE_PLAY_SYNC_INTERVAL_MS` | Optioneel | Reconciliatie-interval (default: 86400000 = 24u) |
|
||||
Optioneel blijven environment variables werken als fallback (bijv. voor lokale development).
|
||||
|
||||
### Velden in Instellingen
|
||||
|
||||
| Veld | Verplicht | Beschrijving |
|
||||
|------|-----------|--------------|
|
||||
| Project ID | Ja | Google Cloud project ID |
|
||||
| Service account e-mail | Ja | Service account e-mail |
|
||||
| Private key | Ja | PEM private key |
|
||||
| Workspace admin e-mail | Meestal ja | Admin voor domain-wide delegation |
|
||||
| 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
|
||||
|
||||
- **Lokaal:** `apps/master-api/.env`
|
||||
- **Productie/Dokploy:** omgeving van de master-api container (`deploy/master/.env` template)
|
||||
- **Productie (aanbevolen):** Admin UI → Instellingen → Google Play
|
||||
- **Lokaal fallback:** `apps/master-api/.env`
|
||||
- **Dokploy env:** alleen nodig als je Settings-UI niet wilt gebruiken
|
||||
|
||||
## Google Cloud — handmatige stappen
|
||||
|
||||
|
|
@ -174,14 +195,12 @@ Body PATCH:
|
|||
|
||||
## 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`
|
||||
2. `GOOGLE_SERVICE_ACCOUNT_EMAIL`
|
||||
3. `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY`
|
||||
4. `GOOGLE_WORKSPACE_ADMIN_EMAIL` (indien Workspace)
|
||||
5. `PLAY_ACCESS_GROUP_ID`
|
||||
6. `PLAY_ACCESS_GROUP_EMAIL`
|
||||
7. `GOOGLE_PLAY_OPT_IN_URL`
|
||||
1. Maak Google Cloud service account + group + Play Closed Testing-koppeling (stappen hierboven)
|
||||
2. Open adminpaneel → **Instellingen → Google Play**
|
||||
3. Vul project, service account, private key, group ID/e-mail en opt-in URL in
|
||||
4. Opslaan
|
||||
5. Ga naar **Kijkers** en zet Store access aan voor een testgebruiker
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||
import Link from "next/link";
|
||||
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 {
|
||||
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";
|
||||
}
|
||||
|
||||
|
|
@ -47,6 +49,7 @@ const TABS: Array<{ id: SettingsTab; label: string; short: string }> = [
|
|||
{ id: "shelves", label: "Planken", short: "Planken" },
|
||||
{ id: "download-station", label: "Download Station", short: "DS" },
|
||||
{ id: "opensubtitles", label: "OpenSubtitles", short: "Subs" },
|
||||
{ id: "google-play", label: "Google Play", short: "Play" },
|
||||
];
|
||||
|
||||
const emptyConfig = (nodeId = ""): DsConfig => ({
|
||||
|
|
@ -99,6 +102,22 @@ export default function SettingsPage() {
|
|||
const [osErr, setOsErr] = 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(() => {
|
||||
fetch("/api/v1/admin/library/shelves", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
|
|
@ -141,11 +160,33 @@ export default function SettingsPage() {
|
|||
.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(() => {
|
||||
loadShelves();
|
||||
loadDs();
|
||||
loadOpenSubtitles();
|
||||
}, [loadShelves, loadDs, loadOpenSubtitles]);
|
||||
loadGooglePlay();
|
||||
}, [loadShelves, loadDs, loadOpenSubtitles, loadGooglePlay]);
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<>
|
||||
<Nav />
|
||||
|
|
@ -570,6 +649,125 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -217,7 +217,8 @@ export default function ViewersPage() {
|
|||
<p className="muted">
|
||||
{playConfig.configured
|
||||
? `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>
|
||||
{playConfig.configured && (
|
||||
<div className="viewer-actions">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -558,6 +558,8 @@ model IntegrationSetting {
|
|||
passwordEnc String? @map("password_enc")
|
||||
/** Must match OpenSubtitles.com API Consumer / User-Agent exactly */
|
||||
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)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
|
|
|||
|
|
@ -76,10 +76,10 @@ async function main() {
|
|||
await registerStremioRoutes(app, config);
|
||||
await registerAdminRoutes(app, config);
|
||||
await registerInstallRoutes(app, config);
|
||||
registerSettingsRoutes(app, config);
|
||||
const downloads = registerDownloadRoutes(app, config);
|
||||
registerViewerRoutes(app, config, downloads);
|
||||
const googlePlay = registerGooglePlayRoutes(app, config);
|
||||
registerSettingsRoutes(app, config, googlePlay);
|
||||
startGooglePlayReconciliationPoller(googlePlay, config.GOOGLE_PLAY_SYNC_INTERVAL_MS);
|
||||
|
||||
app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
|
||||
|
|
@ -281,6 +281,9 @@ async function main() {
|
|||
await prisma.$executeRawUnsafe(
|
||||
`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(`
|
||||
CREATE TABLE IF NOT EXISTS "library_subtitles" (
|
||||
"id" TEXT NOT NULL,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ export type GooglePlayRuntimeConfig = {
|
|||
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 {
|
||||
GOOGLE_CLOUD_PROJECT_ID,
|
||||
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 {
|
||||
private jwt: JWT | null = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export function registerGooglePlayRoutes(app: FastifyInstance, config: Config) {
|
|||
app.get(
|
||||
"/api/v1/admin/google-play-access/config",
|
||||
{ preHandler: requireAdmin },
|
||||
async () => ({ config: playAccess.getPublicConfig() })
|
||||
async () => ({ config: await playAccess.getPublicConfig() })
|
||||
);
|
||||
|
||||
app.patch(
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ async function testMockClientIdempotency() {
|
|||
assert(mock.members.size === 0, "remove missing is ok");
|
||||
}
|
||||
|
||||
function testCredentialsNeverInPublicConfig() {
|
||||
async function testCredentialsNeverInPublicConfig() {
|
||||
const { GooglePlayAccessService } = require("./service") as typeof import("./service");
|
||||
const svc = new GooglePlayAccessService({
|
||||
GOOGLE_CLOUD_PROJECT_ID: "proj",
|
||||
|
|
@ -86,19 +86,26 @@ function testCredentialsNeverInPublicConfig() {
|
|||
PLAY_ACCESS_GROUP_ID: "abc",
|
||||
PLAY_ACCESS_GROUP_EMAIL: "g@example.com",
|
||||
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);
|
||||
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);
|
||||
assert(!json.includes("secret-key-material"), "private key not exposed");
|
||||
assert(!json.includes("PRIVATE KEY"), "no key in public config");
|
||||
assert(pub.optInUrl?.startsWith("https://"), "opt-in url public");
|
||||
}
|
||||
|
||||
async function run() {
|
||||
testEmailUtils();
|
||||
testRuntimeConfig();
|
||||
await testMockClientIdempotency();
|
||||
testCredentialsNeverInPublicConfig();
|
||||
await testCredentialsNeverInPublicConfig();
|
||||
console.log("google-play/service.test.ts: all tests passed");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import type { GooglePlaySyncStatus, ViewerUser } from "@prisma/client";
|
||||
import type { GooglePlaySyncStatus } from "@prisma/client";
|
||||
import { prisma } from "../database/client";
|
||||
import { AppError } from "../security/errors";
|
||||
import type { Config } from "../config";
|
||||
import { logGooglePlayAudit } from "./audit";
|
||||
import {
|
||||
GoogleIdentityGroupsClient,
|
||||
getGooglePlayRuntimeConfig,
|
||||
} from "./client";
|
||||
import { GoogleIdentityGroupsClient } from "./client";
|
||||
import { isValidEmail, normalizeEmail, resolveGooglePlayEmail } from "./email";
|
||||
import {
|
||||
getGooglePlaySettingsPublic,
|
||||
resolveGooglePlayRuntimeConfig,
|
||||
} from "../settings/google-play";
|
||||
|
||||
const VIEWER_SELECT = {
|
||||
id: true,
|
||||
|
|
@ -63,6 +64,7 @@ export type AdminContext = {
|
|||
|
||||
export class GooglePlayAccessService {
|
||||
private client: GoogleIdentityGroupsClient | null = null;
|
||||
private clientFingerprint: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
|
|
@ -70,32 +72,42 @@ export class GooglePlayAccessService {
|
|||
private readonly clientOverride?: GoogleIdentityGroupsClient
|
||||
) {}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return getGooglePlayRuntimeConfig(this.config) !== null;
|
||||
async isConfigured(): Promise<boolean> {
|
||||
return (await resolveGooglePlayRuntimeConfig(this.config)) !== null;
|
||||
}
|
||||
|
||||
getPublicConfig() {
|
||||
const cfg = getGooglePlayRuntimeConfig(this.config);
|
||||
async getPublicConfig() {
|
||||
const pub = await getGooglePlaySettingsPublic(this.config);
|
||||
return {
|
||||
configured: cfg !== null,
|
||||
groupEmail: cfg?.groupEmail ?? null,
|
||||
groupId: cfg?.groupId ?? null,
|
||||
optInUrl: this.config.GOOGLE_PLAY_OPT_IN_URL ?? null,
|
||||
configured: pub.configured,
|
||||
groupEmail: pub.groupEmail || null,
|
||||
groupId: pub.groupId || 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.client) return this.client;
|
||||
const cfg = getGooglePlayRuntimeConfig(this.config);
|
||||
const cfg = await resolveGooglePlayRuntimeConfig(this.config);
|
||||
if (!cfg) {
|
||||
throw new AppError(
|
||||
"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
|
||||
);
|
||||
}
|
||||
const fingerprint = `${cfg.serviceAccountEmail}|${cfg.groupId}|${cfg.privateKey.length}`;
|
||||
if (!this.client || this.clientFingerprint !== fingerprint) {
|
||||
this.client = new GoogleIdentityGroupsClient(cfg);
|
||||
this.clientFingerprint = fingerprint;
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
|
||||
|
|
@ -192,12 +204,12 @@ export class GooglePlayAccessService {
|
|||
admin: AdminContext | undefined,
|
||||
auditAction: "ADD" | "REMOVE" | "SYNC" | "RETRY" | "RECONCILE"
|
||||
): Promise<SyncResult> {
|
||||
if (!this.isConfigured()) {
|
||||
if (!(await this.isConfigured())) {
|
||||
await prisma.viewerUser.update({
|
||||
where: { id: viewer.id },
|
||||
data: {
|
||||
googlePlaySyncStatus: "ERROR",
|
||||
googlePlaySyncError: "Google Play-toegang is niet geconfigureerd op de server",
|
||||
googlePlaySyncError: "Google Play-toegang is niet geconfigureerd (Instellingen → Google Play)",
|
||||
},
|
||||
});
|
||||
await logGooglePlayAudit({
|
||||
|
|
@ -212,7 +224,7 @@ export class GooglePlayAccessService {
|
|||
}
|
||||
|
||||
try {
|
||||
const client = this.groupsClient();
|
||||
const client = await this.groupsClient();
|
||||
const membership = await client.createMembership(email);
|
||||
await prisma.viewerUser.update({
|
||||
where: { id: viewer.id },
|
||||
|
|
@ -260,7 +272,7 @@ export class GooglePlayAccessService {
|
|||
admin: AdminContext | undefined,
|
||||
auditAction: "ADD" | "REMOVE" | "SYNC" | "RETRY" | "RECONCILE"
|
||||
): Promise<SyncResult> {
|
||||
if (!this.isConfigured()) {
|
||||
if (!(await this.isConfigured())) {
|
||||
await prisma.viewerUser.update({
|
||||
where: { id: viewer.id },
|
||||
data: {
|
||||
|
|
@ -275,7 +287,7 @@ export class GooglePlayAccessService {
|
|||
}
|
||||
|
||||
try {
|
||||
const client = this.groupsClient();
|
||||
const client = await this.groupsClient();
|
||||
if (viewer.googlePlayMembershipId) {
|
||||
await client.deleteMembership(viewer.googlePlayMembershipId);
|
||||
} else {
|
||||
|
|
@ -321,15 +333,15 @@ export class GooglePlayAccessService {
|
|||
}
|
||||
|
||||
async hasAccess(email: string): Promise<boolean> {
|
||||
if (!this.isConfigured()) return false;
|
||||
const client = this.groupsClient();
|
||||
if (!(await this.isConfigured())) return false;
|
||||
const client = await this.groupsClient();
|
||||
const found = await client.lookupMembership(normalizeEmail(email));
|
||||
return found !== null;
|
||||
}
|
||||
|
||||
async getMembership(email: string): Promise<{ name: string } | null> {
|
||||
if (!this.isConfigured()) return null;
|
||||
const client = this.groupsClient();
|
||||
if (!(await this.isConfigured())) return null;
|
||||
const client = await this.groupsClient();
|
||||
return client.lookupMembership(normalizeEmail(email));
|
||||
}
|
||||
|
||||
|
|
@ -337,12 +349,12 @@ export class GooglePlayAccessService {
|
|||
const viewer = await this.getViewerOrThrow(viewerId);
|
||||
const email = resolveGooglePlayEmail(viewer);
|
||||
|
||||
if (!this.isConfigured()) {
|
||||
if (!(await this.isConfigured())) {
|
||||
return { viewerId, email, action: "error", error: "Not configured" };
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.groupsClient();
|
||||
const client = await this.groupsClient();
|
||||
const member = await client.lookupMembership(email);
|
||||
const isMember = member !== null;
|
||||
|
||||
|
|
@ -470,13 +482,16 @@ export function startGooglePlayReconciliationPoller(
|
|||
service: GooglePlayAccessService,
|
||||
intervalMs: number
|
||||
): () => void {
|
||||
if (!service.isConfigured() || intervalMs <= 0) {
|
||||
if (intervalMs <= 0) {
|
||||
return () => undefined;
|
||||
}
|
||||
const handle = setInterval(() => {
|
||||
void service.isConfigured().then((ok) => {
|
||||
if (!ok) return;
|
||||
service.reconcileAll().catch((err) => {
|
||||
console.error("[google-play] Scheduled reconciliation failed:", err);
|
||||
});
|
||||
});
|
||||
}, intervalMs);
|
||||
return () => clearInterval(handle);
|
||||
}
|
||||
|
|
|
|||
250
apps/master-api/src/settings/google-play.ts
Normal file
250
apps/master-api/src/settings/google-play.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -6,8 +6,17 @@ import {
|
|||
getOpenSubtitlesSettingsPublic,
|
||||
upsertOpenSubtitlesSettings,
|
||||
} 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 () => {
|
||||
return { settings: await getOpenSubtitlesSettingsPublic() };
|
||||
});
|
||||
|
|
@ -32,8 +41,28 @@ export function registerSettingsRoutes(app: FastifyInstance, config: Config) {
|
|||
if (!pub.configured) {
|
||||
throw new AppError("NOT_CONFIGURED", "OpenSubtitles nog niet geconfigureerd", 400);
|
||||
}
|
||||
// Re-save path already validates; expose status
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue