From 65e8548872afb5023aa21e3bb9abc4f63fddf5a1 Mon Sep 17 00:00:00 2001 From: iFlip721 Date: Tue, 1 Sep 2026 08:07:05 -0400 Subject: [PATCH] dulo update feature for handling domain updates and authentication --- README.md | 14 +- server/src/backup/restoreBackup.ts | 9 + server/src/index.ts | 11 + server/src/models/Settings.ts | 8 + server/src/routes/settings.ts | 14 ++ server/src/routes/sources.ts | 105 ++++++++- server/src/settings/applyDuloDomain.ts | 42 ++++ server/src/settings/translate.ts | 15 ++ server/src/sources/adapters/dulo.ts | 80 ++++--- server/src/sources/adapters/dulo/auth.ts | 19 +- server/src/sources/adapters/dulo/config.ts | 163 ++++++++++++++ .../src/sources/adapters/dulo/loginBrowser.ts | 14 +- server/src/sources/adapters/dulo/pairing.ts | 12 +- .../sources/adapters/dulo/supabaseConfig.ts | 84 ++++--- src/components/DuloAuthPanel.vue | 208 +++++++++++++++++- src/components/DuloLoginDrawer.vue | 3 +- src/composables/useSettings.ts | 28 +++ 17 files changed, 715 insertions(+), 114 deletions(-) create mode 100644 server/src/settings/applyDuloDomain.ts create mode 100644 server/src/sources/adapters/dulo/config.ts diff --git a/README.md b/README.md index 6b5de70..34af149 100644 --- a/README.md +++ b/README.md @@ -241,9 +241,17 @@ creating the **first admin account**. After that: Chromium signs you in; only tokens are stored). The server then **keeps the session alive on its own**, rotating the token ahead of each expiry — and it **auto-discovers dulo's current Supabase config at runtime**, so when dulo migrates its Supabase project (rotating the public URL + anon key) the session - self-heals on its next refresh with no re-capture and nothing to configure. If you captured the session + self-heals on its next refresh with no re-capture and no key to bump. If you captured the session from your own browser (pair/paste), just **close that dulo tab — don't sign out**: signing out of - dulo.tv revokes the very session you handed over. + dulo revokes the very session you handed over. + + dulo also **rebrands onto new domains** periodically, and that one *is* operator-configurable: the + **Domain** field on the same panel (Settings → Advanced → Dulo.tv Authentication) drives every + dulo-facing hop — catalog fetch, playback-session mint, Supabase bundle scrape, the pairing bookmarklet, + the streamed login, and the SSRF apex. **Auto-detect** follows a redirect from the old domain (it finds a + rebrand that left a 301 behind; a hard cut-over has to be typed in), and **Test** probes a candidate + without saving it. Saving a *changed* domain **signs the dulo session out** — a captured session belongs + to the site it came from — so re-pair afterwards. 3. **Sync now** to populate channels, then optionally add **EPG Sources** and link guide data on the **Channel Mapping** screen. 4. Create **Users** with per-user access lists — each gets a personal **tokenized `.m3u` + XMLTV guide @@ -475,7 +483,7 @@ All adapters implement the `SourceAdapter` contract (`server/src/sources/types.t | `direct` | Imported | — | Identity (passthrough) | — | — | | `hdhomerun` | HDHomeRun | — | Catalog import (playback dormant — needs remux) | — | — | | `local` | Local Now | — | Sentinel → rotating CDN | — | — | -| `dulo` | dulo.tv | session | `dulo://` sentinel → playbackUrl | — | yes | +| `dulo` | dulo.tv (default; operator-set) | session | `dulo://` sentinel → playbackUrl | — | yes | | `dlhd` | DaddyLive | — | `watch.php` → 3-hop scrape, 6 providers | yes | yes | | `tubi` | Tubi.TV | — | `tubi://` → Tubi API | yes (inline) | — | | `xumo` | Xumo Play | — | broadcast.json → 3-hop API | yes | — | diff --git a/server/src/backup/restoreBackup.ts b/server/src/backup/restoreBackup.ts index 427c351..d0cff13 100644 --- a/server/src/backup/restoreBackup.ts +++ b/server/src/backup/restoreBackup.ts @@ -11,6 +11,7 @@ import { bootInitSources } from '../sources/seed.js'; import { startScheduler, removeAllCronjobs } from '../scheduler/index.js'; import { duloAuth } from '../sources/adapters/dulo/auth.js'; import { applyDnsFromSettings } from '../settings/applyDns.js'; +import { applyDuloDomainFromSettings } from '../settings/applyDuloDomain.js'; import { logger } from '../sources/core/logger.js'; // Thrown when an uploaded/stored buffer is not a recognizable backup — the routes map it to 400 bad_backup. @@ -118,6 +119,14 @@ export async function applyPostRestore(): Promise { } catch (err) { logger.warn('settings', `post-restore: dns re-apply failed (continuing): ${(err as Error).message}`); } + // A restored backup can carry a different Settings.duloDomain — re-hydrate the adapter cache from it. + // Phase 'mongo' deliberately, NOT 'update': the restore already replaced the playlistauths row wholesale + // (and invalidates the auth cache below), so signing the restored session out would be wrong. + try { + await applyDuloDomainFromSettings('mongo'); + } catch (err) { + logger.warn('settings', `post-restore: dulo domain re-apply failed (continuing): ${(err as Error).message}`); + } try { await startScheduler(); } catch (err) { diff --git a/server/src/index.ts b/server/src/index.ts index 6894ac0..6022fc7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -41,6 +41,7 @@ import { logsRouter } from './routes/logs.js'; import { startLogStore, stopLogStore, attachLogs, closeAllLogs } from './logs/logStore.js'; import { applyDnsFromSettings } from './settings/applyDns.js'; import { applyDlhdPlayerFromSettings } from './settings/applyDlhdPlayer.js'; +import { applyDuloDomainFromSettings } from './settings/applyDuloDomain.js'; import { logger } from './sources/core/logger.js'; import { startProxySidecar, stopProxySidecar, EDGE } from './proxy/sidecar.js'; import { internalRouter } from './routes/internal.js'; @@ -104,6 +105,16 @@ async function main() { logger.error('startup', `dlhd player default apply error (continuing): ${(err as Error).message}`); } + // Hydrate the dulo adapter's domain cache from the persisted settings. MUST run before + // startDuloKeepalive() below, so the keepalive's first token refresh already targets the configured + // domain. Boot never signs the session out — it only mirrors what is already stored. Non-fatal + // (falls back to the committed default domain). + try { + await applyDuloDomainFromSettings('mongo'); + } catch (err) { + logger.error('startup', `dulo domain apply error (continuing): ${(err as Error).message}`); + } + // Register persisted cron jobs (cronjobs collection) with the scheduler. Non-fatal: a scheduler // failure must not prevent the API from serving. try { diff --git a/server/src/models/Settings.ts b/server/src/models/Settings.ts index 3e2a6a3..0bbac21 100644 --- a/server/src/models/Settings.ts +++ b/server/src/models/Settings.ts @@ -1,4 +1,5 @@ import { Schema, model } from 'mongoose'; +import { DULO_DEFAULT_DOMAIN } from '../sources/adapters/dulo/config.js'; // Settings — a single application-settings document. Deterministic _id ('app') makes every read/write // a singleton upsert (same idempotency rule as the synced collections). It holds operator-facing values @@ -54,6 +55,12 @@ export interface SettingsDoc { darkMode: boolean; videoPlayer: 'inapp' | 'ultimate' | 'debug'; // which player the slide-out renders ('inapp' default; 'ultimate' = popup player window; 'debug' = diagnostic HUD) dlhdPlayer: number; // source-wide default DaddyLive player (0 = Auto/first; 1..N) for dlhd channels without a per-channel override + // The domain dulo is currently on, as a bare host (no scheme/path) — e.g. 'dulo.tv'. dulo rebrands + // periodically, so every dulo-facing hop (catalog, playback-session, Supabase bundle scrape, pairing, + // streamed login, SSRF apex) derives from this instead of a compile-time const. Edited on + // Settings -> Advanced -> Dulo.tv Authentication; pushed into the adapter cache by + // settings/applyDuloDomain.ts. NOT to be confused with `domain` above, which is masqueradarr's OWN base URL. + duloDomain: string; nameservers: string | null; // comma-separated outbound-fetch resolver IP(s); null/blank = OS resolver (DEFAULT_NAMESERVERS 8.8.8.8,8.8.4.4 seeds first boot) logLevel: number; // GLOBAL 1|2|3 log verbosity — app + Rust proxy engine (default 2; formerly dnsLogLevel) maxmindAccountId: string | null; // MaxMind GeoLite2 web-service account id (null = geo disabled) @@ -78,6 +85,7 @@ const SettingsSchema = new Schema( darkMode: { type: Boolean, required: true, default: true }, videoPlayer: { type: String, required: true, default: 'inapp' }, dlhdPlayer: { type: Number, required: true, default: 0 }, // 0 = Auto; 1..N = source-wide default DaddyLive player + duloDomain: { type: String, required: true, default: DULO_DEFAULT_DOMAIN }, nameservers: { type: String, default: null }, logLevel: { type: Number, required: true, default: 2 }, maxmindAccountId: { type: String, default: null }, diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts index 3597d1c..7feb192 100644 --- a/server/src/routes/settings.ts +++ b/server/src/routes/settings.ts @@ -3,6 +3,7 @@ import { Settings, SETTINGS_ID, type SettingsDoc } from '../models/Settings.js'; import { envDefaults, toRuntimeSettings, toExternalPatch } from '../settings/translate.js'; import { applyDnsFromSettings } from '../settings/applyDns.js'; import { applyDlhdPlayerFromSettings } from '../settings/applyDlhdPlayer.js'; +import { applyDuloDomainFromSettings } from '../settings/applyDuloDomain.js'; import { cascadePlaylistUrls } from './playlists.js'; import { logger } from '../sources/core/logger.js'; @@ -89,6 +90,19 @@ settingsRouter.put('/', async (req, res, next) => { } } + // Push the dulo domain into the adapter's cache so every dulo hop (catalog, playback-session, Supabase + // discovery, pairing, streamed login) retargets live. A CHANGED domain also resets Supabase discovery + // and signs the dulo session out — a session belongs to the domain it was captured on, so the operator + // is sent back through pairing rather than hitting an opaque playback failure later. Best-effort: a + // cascade hiccup must not fail the write (same contract as the domain cascade above). + if ('duloDomain' in $set) { + try { + await applyDuloDomainFromSettings('update'); + } catch (err) { + logger.error('settings', `dulo domain re-apply failed (continuing): ${(err as Error).message}`); + } + } + res.json(toRuntimeSettings(doc)); } catch (err) { next(err); diff --git a/server/src/routes/sources.ts b/server/src/routes/sources.ts index 2971d51..d9c7fcf 100644 --- a/server/src/routes/sources.ts +++ b/server/src/routes/sources.ts @@ -21,6 +21,16 @@ import { createMetrics, snapshotOne, type Metrics } from '../sources/core/metric import { syncLive, resetSource, ensureShellRow } from '../sources/seed.js'; import { duloAuth } from '../sources/adapters/dulo/auth.js'; import { duloPairing, buildBookmarklet, buildSnippet } from '../sources/adapters/dulo/pairing.js'; +import { + DULO_DEFAULT_DOMAIN, + browserHeadersFor, + catalogUrlFor, + getDomain, + getOrigin, + normalizeDomain, + originFor, +} from '../sources/adapters/dulo/config.js'; +import { scrapeSupabaseConfig } from '../sources/adapters/dulo/supabaseConfig.js'; import type { Request, Response } from 'express'; import { Playlist } from '../models/Playlist.js'; import { grantPlaylistToAdmins } from '../security/adminAccess.js'; @@ -121,6 +131,99 @@ sourcesRouter.post('/api/sources/:id/provision', async (req, res, next) => { } }); +// ── dulo domain (Settings → Advanced → Dulo.tv Authentication) ──────────────── +// dulo REBRANDS periodically, so the domain it lives on is an operator setting (Settings.duloDomain) rather +// than a compile-time const. These two endpoints only HELP the operator find and verify a candidate — +// NEITHER PERSISTS ANYTHING. The save goes through PUT /api/settings, which is where the cascade lives +// (reset Supabase discovery + sign the dulo session out, since a session belongs to the domain it was +// captured on). Admin-only via the /api/sources prefix (index.ts adminOnlyRoutes). +// +// SSRF: both fetch an OPERATOR-SUPPLIED host server-side, so every candidate goes through normalizeDomain() +// first — it strips scheme/path/port/userinfo and rejects IP literals plus private/loopback targets. This +// is the gate that actually runs on user input (the adapter's isAllowedUpstream is not wired up today). +const DOMAIN_PROBE_TIMEOUT_MS = 10_000; + +// Probe a candidate domain: does it serve dulo's Live TV catalog, and is it a dulo frontend build? The +// bundle scrape is the stronger signal — any site can 404, but only dulo's build carries an +// `sb_publishable_` key next to a supabase.co project URL. +sourcesRouter.post('/api/sources/dulo/domain/test', async (req, res, next) => { + try { + const body = (req.body ?? {}) as Record; + const parsed = normalizeDomain(typeof body.domain === 'string' ? body.domain : ''); + if (!parsed.ok) return res.status(400).json({ error: parsed.error }); + + const domain = parsed.domain; + const endpoint = catalogUrlFor(domain); + let httpStatus: number | null = null; + let channelCount: number | null = null; + let error: string | null = null; + try { + const r = await fetch(endpoint, { + headers: browserHeadersFor(originFor(domain)), + signal: AbortSignal.timeout(DOMAIN_PROBE_TIMEOUT_MS), + }); + httpStatus = r.status; + if (r.ok) { + const parsedBody = (await r.json()) as { channels?: unknown[] }; + channelCount = Array.isArray(parsedBody.channels) ? parsedBody.channels.length : 0; + } else { + error = `catalog returned HTTP ${r.status}`; + } + } catch (err) { + error = (err as Error).message; + } + + // Cache-free scrape (scrapeSupabaseConfig, not discoverSupabaseConfig) so probing a candidate can never + // poison the ACTIVE session's Supabase config. + const supabase = await scrapeSupabaseConfig(originFor(domain)); + + res.json({ + domain, + endpoint, + ok: channelCount !== null && channelCount > 0, + httpStatus, + channelCount, + supabaseFound: !!supabase, + supabaseUrl: supabase?.supabaseUrl ?? null, + error, + }); + } catch (err) { + next(err); + } +}); + +// Where did dulo move to? Follow redirects from the currently configured domain and, if different, from the +// committed default. A rebrand that leaves a 301/302 on the old host is discoverable this way; a hard +// cut-over (old host simply dead) is not — `detected: null` then, and the SPA says so. +sourcesRouter.post('/api/sources/dulo/domain/detect', async (_req, res, next) => { + try { + const current = getDomain(); + const candidates = [...new Set([current, DULO_DEFAULT_DOMAIN])]; + const tried: Array<{ from: string; landed: string | null; httpStatus: number | null; error: string | null }> = []; + + for (const from of candidates) { + try { + const r = await fetch(originFor(from), { + redirect: 'follow', + headers: browserHeadersFor(originFor(from)), + signal: AbortSignal.timeout(DOMAIN_PROBE_TIMEOUT_MS), + }); + const landedParsed = normalizeDomain(new URL(r.url).hostname); + const landed = landedParsed.ok ? landedParsed.domain : null; + tried.push({ from, landed, httpStatus: r.status, error: null }); + if (landed && landed !== from) { + return res.json({ detected: landed, from, sameAsCurrent: landed === current, tried }); + } + } catch (err) { + tried.push({ from, landed: null, httpStatus: null, error: (err as Error).message }); + } + } + res.json({ detected: null, from: current, sameAsCurrent: true, tried }); + } catch (err) { + next(err); + } +}); + // ── dulo Live TV authentication ─────────────────────────────────────────────── // dulo gates Live TV streams behind a Supabase session (no static stream URLs). The SPA captures the // already signed-in session from dulo.tv and POSTs the tokens here — only tokens are stored, never a @@ -180,7 +283,7 @@ sourcesRouter.post('/api/sources/dulo/auth/pair', (req, res) => { code, expiresAt, callbackUrl, - duloUrl: 'https://dulo.tv', + duloUrl: getOrigin(), // follows Settings.duloDomain — the SPA links the user to the right site bookmarklet: buildBookmarklet(code, callbackUrl), snippet: buildSnippet(code, callbackUrl), }); diff --git a/server/src/settings/applyDuloDomain.ts b/server/src/settings/applyDuloDomain.ts new file mode 100644 index 0000000..9a1b897 --- /dev/null +++ b/server/src/settings/applyDuloDomain.ts @@ -0,0 +1,42 @@ +// Bridge between the persisted `settings` singleton and the dulo adapter's module-level domain cache. +// dulo rebrands periodically, so the domain it lives on is an operator setting (Settings.duloDomain) rather +// than a compile-time const; this reads it into config.setDomain() so the synchronous hot paths +// (upstreamHeaders / isAllowedUpstream) and every dulo fetch resolve it with NO DB hit. Mirrors +// applyDlhdPlayer: called after connect (boot, source 'mongo') and on every Settings PUT that touches +// duloDomain (source 'update'). Kept out of dulo/config.ts (a Mongo-free leaf) so config never imports the +// models layer. +// +// A CHANGED domain on 'update' cascades twice: +// · resetSupabaseDiscovery() — the cached Supabase project pair was scraped from the OLD site and its +// cooldown would otherwise suppress a re-scrape for minutes. +// · duloAuth.signOut() — a captured session belongs to the domain it was captured on; rather than +// letting playback fail opaquely later, drop it and send the operator back through the pairing flow. +// Boot ('mongo') NEVER signs out — it is just hydrating the cache from what is already stored. + +import { Settings, SETTINGS_ID, type SettingsDoc } from '../models/Settings.js'; +import { DULO_DEFAULT_DOMAIN, getDomain, setDomain } from '../sources/adapters/dulo/config.js'; +import { resetSupabaseDiscovery } from '../sources/adapters/dulo/supabaseConfig.js'; +import { duloAuth } from '../sources/adapters/dulo/auth.js'; +import { logger } from '../sources/core/logger.js'; + +const tag = 'dulo:auth'; + +export async function applyDuloDomainFromSettings( + source: 'mongo' | 'update', +): Promise<{ domain: string; changed: boolean }> { + const doc = (await Settings.findOne({ _id: SETTINGS_ID }, { duloDomain: 1 }).lean()) as Pick< + SettingsDoc, + 'duloDomain' + > | null; + const changed = setDomain(doc?.duloDomain || DULO_DEFAULT_DOMAIN); + const domain = getDomain(); + + if (changed && source === 'update') { + resetSupabaseDiscovery(); + await duloAuth.signOut(); + logger.warn(tag, `dulo domain changed to ${domain} — session signed out, re-pair required`); + } else if (changed) { + logger.info(tag, `dulo domain set to ${domain}`); + } + return { domain, changed }; +} diff --git a/server/src/settings/translate.ts b/server/src/settings/translate.ts index 6044917..a84a1f5 100644 --- a/server/src/settings/translate.ts +++ b/server/src/settings/translate.ts @@ -19,6 +19,7 @@ import { isIP } from 'node:net'; import type { SettingsDoc } from '../models/Settings.js'; +import { DULO_DEFAULT_DOMAIN, normalizeDomain } from '../sources/adapters/dulo/config.js'; import { zoneOffsetString } from './zoneOffset.js'; // First-provision default for the outbound-fetch DNS resolver(s). Hardcoded (the NAMESERVER env was @@ -62,6 +63,10 @@ export function envDefaults(): SettingsData { videoPlayer: asVideoPlayerMode(process.env.VIDEO_PLAYER), // Source-wide default DaddyLive player (0 = Auto). Seedable from DLHD_PLAYER; clamped to a non-negative int. dlhdPlayer: Math.max(0, Math.trunc(Number(process.env.DLHD_PLAYER)) || 0), + // The domain dulo is currently on. Deliberately NOT env-derived: dulo identity is kept out of infra + // config (the old DULO_API / DULO_API_BASE overrides were retired with this field), so the committed + // default seeds first boot and the operator edits it on the Settings screen thereafter. + duloDomain: DULO_DEFAULT_DOMAIN, // nameservers: hardcoded first-provision default (no longer env-derived — the NAMESERVER env was // dropped). 8.8.8.8,8.8.4.4 (Google public DNS) is written into the singleton on first insert so a // working outbound-fetch resolver is ALWAYS present out of the box; the operator edits it on the @@ -92,6 +97,7 @@ export function toRuntimeSettings(doc: SettingsDoc): RuntimeSettings { darkMode: doc.darkMode, videoPlayer: asVideoPlayerMode(doc.videoPlayer), dlhdPlayer: typeof doc.dlhdPlayer === 'number' ? doc.dlhdPlayer : 0, // source-wide default DaddyLive player (0 = Auto) + duloDomain: doc.duloDomain || DULO_DEFAULT_DOMAIN, // bare host; not secret — returned for the Settings UI nameservers: doc.nameservers ?? null, // not secret — returned verbatim for the Settings UI logLevel: typeof doc.logLevel === 'number' ? doc.logLevel : 2, maxmindAccountId: doc.maxmindAccountId ?? null, @@ -154,6 +160,15 @@ export function toExternalPatch(body: unknown): PatchResult { } $set.dlhdPlayer = v; } + // duloDomain: the host dulo is currently on. Normalized (scheme/path/port/userinfo stripped, lowercased) + // and gated by the SAME validator the Test/Auto-detect endpoints use — it rejects IP literals and + // private/loopback targets, which matters because those endpoints server-side-fetch this value. + if (b.duloDomain !== undefined) { + if (typeof b.duloDomain !== 'string') return { ok: false, error: 'duloDomain (string) required' }; + const parsed = normalizeDomain(b.duloDomain); + if (!parsed.ok) return { ok: false, error: `duloDomain: ${parsed.error}` }; + $set.duloDomain = parsed.domain; + } // nameservers: optional comma-separated resolver IP(s). null or '' clears it (stored null → OS resolver); // a non-empty string must be a comma list of valid IPs (isIP), else 400 — a bad value never reaches dns.ts. if (b.nameservers !== undefined) { diff --git a/server/src/sources/adapters/dulo.ts b/server/src/sources/adapters/dulo.ts index 225251d..f229b1e 100644 --- a/server/src/sources/adapters/dulo.ts +++ b/server/src/sources/adapters/dulo.ts @@ -10,41 +10,27 @@ // · isEntryUrl() → true for that sentinel // · resolveStream() → duloAuth.resolvePlayback(channelId) → the fresh playbackUrl (the real master) // -// The resolved playbackUrl is served through dulo's own proxy (/proxy/hls/, gotcha.dulo.tv / live-gateway) +// The resolved playbackUrl is served through dulo's own proxy (/proxy/hls/, gotcha. / live-gateway) // or an external host (tstrm.org / vixproxy). Its exact host can't be known until resolved, so the SSRF -// gate allows *.dulo.tv plus any host LEARNED from a playlist we legitimately resolved/fetched -// (onPlaylistChildHost), the same dynamic-allow approach dlhd uses. Auth is established out-of-band by the -// SPA capture flow → POST /api/sources/dulo/auth (see routes/sources.ts). +// gate allows the active dulo domain (and its subdomains) plus any host LEARNED from a playlist we +// legitimately resolved/fetched (onPlaylistChildHost) — the shared _fast/dynamicAllow set, owned by +// ./dulo/config.ts. Auth is established out-of-band by the SPA capture flow → POST /api/sources/dulo/auth +// (see routes/sources.ts). +// +// dulo REBRANDS periodically, so no dulo URL is a const here: the domain is an operator setting +// (Settings.duloDomain) and every endpoint/header is derived from ./dulo/config.ts at use time. import { readFileSync } from 'node:fs'; import { snapshotFile, DULO_EPG_ADDON_FILE } from '../paths.js'; import { applyEpgCrosswalk } from '../epgCrosswalk.js'; import { duloAuth } from './dulo/auth.js'; +import { getCatalogUrl, browserHeaders, duloAllow } from './dulo/config.js'; import type { SourceAdapter } from '../types.js'; import type { SourceChannelDoc } from '../../models/SourceChannel.js'; const SNAPSHOT = snapshotFile('dulo'); -const DULO_ORIGIN = 'https://dulo.tv'; -const DULO_API = process.env.DULO_API || 'https://dulo.tv/api/live-tv/channels'; -const UA = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'; const ENTRY_PREFIX = 'dulo://channel/'; -// Hosts allowed for direct (non-entry) proxy hops. *.dulo.tv is static; additional playbackUrl hosts are -// learned at runtime from playlists we resolved/fetched (trust roots at dulo's authenticated response). -const EXTRA_HOSTS = new Set( - (process.env.DULO_EXTRA_HOSTS || '') - .split(',') - .map((h) => h.trim().toLowerCase()) - .filter(Boolean), -); -const dynamicHosts = new Set(); - -function hostAllowed(host: string): boolean { - const h = host.toLowerCase(); - return h === 'dulo.tv' || h.endsWith('.dulo.tv') || EXTRA_HOSTS.has(h) || dynamicHosts.has(h); -} - function toIso(ts: unknown): string | null { if (!ts || typeof ts !== 'string') return null; const d = new Date(ts); @@ -61,22 +47,38 @@ const duloAdapter: SourceAdapter = { // (The catalog is metadata-only now — no stream URLs — so this needs no auth; the stream is resolved // lazily at play time via resolveStream().) async listChannels() { + const endpoint = getCatalogUrl(); // follows Settings.duloDomain — reported in meta so a sync shows it try { - const res = await fetch(DULO_API, { headers: { 'User-Agent': UA, Origin: DULO_ORIGIN, Referer: `${DULO_ORIGIN}/live` } }); + const res = await fetch(endpoint, { headers: browserHeaders() }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = (await res.json()) as { channels?: any[] }; const raw = body.channels || []; if (!raw.length) throw new Error('empty channel list'); - return { raw, meta: { endpoint: DULO_API, live: true, fetchedAt: new Date().toISOString() } }; + return { raw, meta: { endpoint, live: true, fetchedAt: new Date().toISOString() } }; } catch (err) { - const snap = JSON.parse(readFileSync(SNAPSHOT, 'utf8')) as { channels?: any[] }; + const reason = (err as Error).message; + // Offline fallback. UNLIKE every other source, dulo has no committed snapshot, so this read normally + // throws ENOENT — which turned a wrong/dead domain into an opaque file error. Compose a message that + // names the endpoint actually tried and points at the setting. Deliberately NOT an empty channel + // list: a sync with zero rows would wipe the catalog. `npm run rebuild:seed` commits a snapshot and + // restores the intended soft (warn, not fail) fallback. + let snap: { channels?: any[] }; + try { + snap = JSON.parse(readFileSync(SNAPSHOT, 'utf8')) as { channels?: any[] }; + } catch (snapErr) { + throw new Error( + `dulo catalog unreachable at ${endpoint} (${reason}), and no offline snapshot is available ` + + `(${(snapErr as Error).message}). If dulo has changed domain, set the new one under ` + + `Settings → Advanced → Dulo.tv Authentication.`, + ); + } return { raw: snap.channels || [], meta: { - endpoint: DULO_API, + endpoint, live: false, fallback: 'dulo.snapshot.json', - reason: (err as Error).message, + reason, fetchedAt: new Date().toISOString(), }, }; @@ -133,23 +135,15 @@ const duloAdapter: SourceAdapter = { proxy: { upstreamHeaders() { - // Browser-like headers: dulo is bot-gated and the memfs/proxy hosts check Origin. The Bearer is - // deliberately NOT sent on CDN hops — the resolved playbackUrl is expected to be self-authenticating - // (token in the URL). If a real account shows segments need it, add it here. - return { 'User-Agent': UA, Origin: DULO_ORIGIN, Referer: `${DULO_ORIGIN}/live` }; - }, - isAllowedUpstream(url: string) { - try { - const u = new URL(url); - return (u.protocol === 'https:' || u.protocol === 'http:') && hostAllowed(u.hostname); - } catch { - return false; - } + // Browser-like headers: dulo is bot-gated and the memfs/proxy hosts check Origin. Built from the + // ACTIVE domain at call time. The Bearer is deliberately NOT sent on CDN hops — the resolved + // playbackUrl is expected to be self-authenticating (token in the URL). If a real account shows + // segments need it, add it here. + return browserHeaders(); }, + isAllowedUpstream: (url: string) => duloAllow.isAllowedUpstream(url), // Learn each child host of a playlist we resolved/fetched so its segments pass the SSRF gate. - onPlaylistChildHost: (host: string) => { - if (host) dynamicHosts.add(host.toLowerCase()); - }, + onPlaylistChildHost: (host: string) => duloAllow.onPlaylistChildHost(host), relabelSegmentContentType(_url: string, contentType: string) { return contentType || 'application/octet-stream'; // plain TS — pass the upstream type through }, diff --git a/server/src/sources/adapters/dulo/auth.ts b/server/src/sources/adapters/dulo/auth.ts index 6a113a2..dabbb81 100644 --- a/server/src/sources/adapters/dulo/auth.ts +++ b/server/src/sources/adapters/dulo/auth.ts @@ -32,15 +32,12 @@ import { logger } from '../../core/logger.js'; // env/infra config, we resolve them here (captured-with-session → runtime-discovered → committed seed) and // discover the current pair from dulo's live bundle when a refresh 401s at the apikey gate. See supabaseConfig.ts. import { currentAnonKey, currentSupabaseUrl, discoverSupabaseConfig } from './supabaseConfig.js'; +// Where dulo lives today. dulo rebrands periodically, so the domain is an operator setting +// (Settings.duloDomain) cached in ./config.ts — read through getApiBase()/browserHeaders() at USE time so +// a domain change is honored without a restart. The old DULO_API_BASE env override is gone with it. +import { getApiBase, browserHeaders } from './config.js'; -const DULO_ORIGIN = 'https://dulo.tv'; -const DULO_BASE = process.env.DULO_API_BASE || 'https://dulo.tv/api'; const DEVICE_NAME = process.env.DULO_DEVICE_NAME || 'Masqueradarr'; - -// Default UA when a session carries no captured UA (paste/handoff). Kept reasonably current for coherence -// with the server-side API calls; a per-session `userAgent` (loginBrowser capture) overrides this. -export const UA = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; const REFRESH_MARGIN_MS = 60_000; // refresh when <60s of access_token life remains const TRANSIENT_BACKOFF_MS = 60_000; // after a transient refresh failure, don't retry for this long @@ -91,10 +88,6 @@ export interface CapturePayload { origin?: 'streamed' | 'paste' | 'handoff' | null; } -function browserHeaders(ua: string | null | undefined, extra: Record = {}): Record { - return { 'User-Agent': ua || UA, Origin: DULO_ORIGIN, Referer: `${DULO_ORIGIN}/live`, ...extra }; -} - function decodeJwt(token: string): { exp?: number; iss?: string; ref?: string } { try { const part = token.split('.')[1]; @@ -411,7 +404,7 @@ class PlaylistAuthState { } this.activating = (async () => { const post = (token: string) => - fetch(`${DULO_BASE}/live-tv/activate-device`, { + fetch(`${getApiBase()}/live-tv/activate-device`, { method: 'POST', headers: browserHeaders(s.userAgent, { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }), body: JSON.stringify({ deviceFingerprint: s.deviceFingerprint, deviceName: s.deviceName || DEVICE_NAME }), @@ -463,7 +456,7 @@ class PlaylistAuthState { const token = await this.ensureFreshToken(); const s = await this.ensureDeviceLoaded(token); const post = (tok: string) => - fetch(`${DULO_BASE}/live-tv/playback-session`, { + fetch(`${getApiBase()}/live-tv/playback-session`, { method: 'POST', headers: browserHeaders(s.userAgent, { 'Content-Type': 'application/json', Authorization: `Bearer ${tok}` }), body: JSON.stringify({ deviceFingerprint: s.deviceFingerprint, channelId }), diff --git a/server/src/sources/adapters/dulo/config.ts b/server/src/sources/adapters/dulo/config.ts new file mode 100644 index 0000000..9c5b69b --- /dev/null +++ b/server/src/sources/adapters/dulo/config.ts @@ -0,0 +1,163 @@ +// config.ts — the single place that knows which domain dulo is on today. Mirrors adapters/dlhd/config.ts. +// +// dulo periodically REBRANDS onto a new domain. Previously `dulo.tv` was a compile-time const repeated in +// five files, so a rebrand broke the catalog fetch, the playback-session mint, the Supabase bundle scrape, +// the pairing bookmarklet and the streamed login all at once — and the only fix was a code change plus a +// redeploy. The active domain is now an OPERATOR SETTING (Settings.duloDomain, edited on +// Settings -> Advanced -> Dulo.tv Authentication) cached here at module level. +// +// Everything that points at dulo reads it through the getters below at USE time — never captured at +// import — so a setDomain() hop is honored everywhere instantly. The module-level cache (rather than a +// per-call Mongo read) is REQUIRED, not an optimization: SourceAdapter.upstreamHeaders() and +// isAllowedUpstream() are synchronous and sit on the hot proxy path, so they cannot await the DB. +// +// This is a Mongo-FREE leaf — it must never import the models layer. The Settings bridge lives in +// settings/applyDuloDomain.ts (same split as dlhd/config.ts <- settings/applyDlhdPlayer.ts). + +import { createDynamicAllow, type DynamicAllow } from '../_fast/dynamicAllow.js'; +import { isPrivateHost } from '../../core/ssrf.js'; + +/** The committed default — dulo's domain as last known. Also the Settings.duloDomain schema default. */ +export const DULO_DEFAULT_DOMAIN = 'dulo.tv'; + +// The active dulo domain, as a bare lowercase host (no scheme, no path, no port). Read it only through +// the getters; write it only through setDomain(). +let _domain = DULO_DEFAULT_DOMAIN; + +/** The active domain, e.g. "dulo.tv". Always read at use time. */ +export function getDomain(): string { + return _domain; +} + +// The `*For(domain)` variants below exist so the Settings "Test" probe can hit a CANDIDATE domain that is +// not (yet) the active one, without duplicating dulo's URL shapes in the route layer. The no-argument +// getters are these bound to the active domain. + +/** The origin for an arbitrary dulo domain, e.g. "https://dulo.tv". */ +export function originFor(domain: string): string { + return `https://${domain}`; +} + +/** The metadata-only Live TV catalog endpoint for an arbitrary domain (no auth — streams mint per play). */ +export function catalogUrlFor(domain: string): string { + return `${originFor(domain)}/api/live-tv/channels`; +} + +/** The active origin, e.g. "https://dulo.tv". */ +export function getOrigin(): string { + return originFor(_domain); +} + +/** dulo's REST base, e.g. "https://dulo.tv/api" (live-tv/activate-device, live-tv/playback-session). */ +export function getApiBase(): string { + return `${getOrigin()}/api`; +} + +/** The metadata-only Live TV catalog endpoint (no auth — the stream is minted per play). */ +export function getCatalogUrl(): string { + return catalogUrlFor(_domain); +} + +/** The Referer dulo's API + memfs/proxy hosts expect (they gate on Origin/Referer). */ +export function getReferer(): string { + return `${getOrigin()}/live`; +} + +/** dulo's sign-in page — the URL the streamed-login Chromium lands on. */ +export function getLoginUrl(): string { + return `${getOrigin()}/login`; +} + +/** dulo's Live TV page — navigated to after sign-in to provoke the client's activate-device call. */ +export function getLiveUrl(): string { + return `${getOrigin()}/live`; +} + +// A normal desktop-browser User-Agent — dulo is bot-gated. Single source of truth for every dulo hop +// (the adapter, the auth calls and the Supabase discovery scrape each used to carry their own copy, and +// two of them had drifted to different Chrome majors). A per-session captured UA overrides it. +export const UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; + +/** Browser-like headers for an arbitrary dulo origin (candidate probing). */ +export function browserHeadersFor( + origin: string, + ua?: string | null, + extra: Record = {}, +): Record { + return { 'User-Agent': ua || UA, Origin: origin, Referer: `${origin}/live`, ...extra }; +} + +/** Browser-like headers for any dulo hop. `ua` is the session's captured UA when there is one. */ +export function browserHeaders( + ua?: string | null, + extra: Record = {}, +): Record { + return browserHeadersFor(getOrigin(), ua, extra); +} + +// ── SSRF allow-set ──────────────────────────────────────────────────────────────────────────────────── +// The shared per-source dynamic allow-set (adapters/_fast/dynamicAllow.ts): seeded with the active domain +// (exact-or-subdomain match, so *.dulo.tv is covered), grown at runtime with every host seen inside a +// playlist we legitimately resolved, and always blocking private/loopback targets. setDomain() folds the +// new apex in, exactly as dlhd's setBase() does with UPSTREAM_ALLOW. +// +// NOTE: SourceAdapter.isAllowedUpstream/onPlaylistChildHost are currently called by nothing in the Node +// tree — the live gate is Rust-side (proxy/src/proxy.rs ssrf_ok, seeded from the resolve grant's target). +// This keeps the contract honest and correct for the day it is re-wired; it is not the gate that runs +// today. The guard that DOES run on user input is normalizeDomain() below. +export const duloAllow: DynamicAllow = createDynamicAllow([ + DULO_DEFAULT_DOMAIN, + ...(process.env.DULO_EXTRA_HOSTS || '') + .split(',') + .map((h) => h.trim().toLowerCase()) + .filter(Boolean), +]); + +/** + * Normalize an operator-typed domain into a bare lowercase host. + * + * Accepts "dulo.tv", "https://Dulo.TV/", "HTTPS://dulo.tv/live?x=1" — scheme, path, query, userinfo and + * port are all stripped. Rejects IP literals and private/loopback targets: the Test and Auto-detect + * endpoints (routes/sources.ts) server-side-fetch whatever comes back from here, so this is a real SSRF + * boundary, not cosmetic validation. + */ +export function normalizeDomain(raw: string): { ok: true; domain: string } | { ok: false; error: string } { + const v = String(raw ?? '').trim(); + if (!v) return { ok: false, error: 'domain is required' }; + let u: URL; + try { + u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(v) ? v : `https://${v}`); + } catch { + return { ok: false, error: `"${v}" is not a valid domain` }; + } + if (u.protocol !== 'https:' && u.protocol !== 'http:') { + return { ok: false, error: 'only http(s) domains are supported' }; + } + const host = u.hostname.toLowerCase(); // hostname drops userinfo + port; IPv6 stays bracketed + if (!host) return { ok: false, error: `"${v}" is not a valid domain` }; + if (isPrivateHost(host)) return { ok: false, error: `"${host}" is a private or loopback address` }; + if (host.includes(':') || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) { + return { ok: false, error: 'an IP address is not a valid dulo domain — use a hostname' }; + } + // At least one dot and a plausible TLD label (allows punycode "xn--…" TLDs). + if (!/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*$/.test(host)) { + return { ok: false, error: `"${host}" is not a valid domain name` }; + } + return { ok: true, domain: host }; +} + +/** + * Switch the active dulo domain. Keeps the SSRF allow-set in sync so the new apex is immediately + * proxyable. Called at boot and on every Settings save that touches duloDomain + * (settings/applyDuloDomain.ts). Returns true when the value actually CHANGED — the caller uses that to + * decide whether to reset Supabase discovery and force a re-authentication. + */ +export function setDomain(next: string): boolean { + const parsed = normalizeDomain(next); + if (!parsed.ok) return false; + const changed = parsed.domain !== _domain; + _domain = parsed.domain; + duloAllow.allow(_domain); + return changed; +} diff --git a/server/src/sources/adapters/dulo/loginBrowser.ts b/server/src/sources/adapters/dulo/loginBrowser.ts index 8ddd9c4..6735141 100644 --- a/server/src/sources/adapters/dulo/loginBrowser.ts +++ b/server/src/sources/adapters/dulo/loginBrowser.ts @@ -10,7 +10,7 @@ // Google" gate blocks headless. The same CDP session lets us read the token call off the page's network. // // Recon (2026-06-12, see the plan): dulo is a Vite SPA ("amri.gg"); its login is a full page at -// https://dulo.tv/login (email/password + Google/Discord OAuth); it stores the Supabase session under a +// /login (email/password + Google/Discord OAuth); it stores the Supabase session under a // CUSTOM `amri-*` localStorage key (NOT `sb-*-auth-token`); the Supabase URL/anon key live in the bundle and // are not exposed before sign-in. So capture is host-agnostic (match the GoTrue token path, read the apikey // header) and the localStorage fallback scans every key for a value carrying an access_token. @@ -23,12 +23,12 @@ import type { Browser, BrowserContext, Page, CDPSession, HTTPResponse, KeyInput } from 'puppeteer-core'; import { WebSocket } from 'ws'; import { duloAuth, type CapturePayload } from './auth.js'; +import { getDomain, getLoginUrl, getLiveUrl } from './config.js'; import { logger } from '../../core/logger.js'; +// dulo rebrands periodically, so the sign-in / live URLs and the app host are derived from the operator +// setting (Settings.duloDomain, cached in ./config.ts) at NAVIGATION time, never captured at import. const tag = 'dulo:login'; -const LOGIN_URL = 'https://dulo.tv/login'; -const LIVE_URL = 'https://dulo.tv/live'; // navigated to after sign-in to provoke the client's activate-device -const APP_HOST = 'dulo.tv'; const VIEWPORT_W = 1280; const VIEWPORT_H = 800; const HARD_CAP_MS = 5 * 60_000; // a session may not linger past this, even if the WS stays open @@ -240,7 +240,7 @@ class DuloLoginBrowser { sendJson(session.ws, { type: 'status', state: 'live' }); try { - await session.page.goto(LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await session.page.goto(getLoginUrl(), { waitUntil: 'domcontentloaded', timeout: 30_000 }); } catch (err) { // Don't fail the session on a slow/blocked nav — the screencast shows whatever rendered (incl. a // bot-gate/CAPTCHA the user can solve live). @@ -357,7 +357,7 @@ class DuloLoginBrowser { const u = new URL(url); // Only trust the response origin as the GoTrue base when it isn't the dulo app host (where it'd be a // proxied path); otherwise let duloAuth.signIn derive the base from the JWT `iss` claim. - if (u.host !== APP_HOST) supabaseUrl = u.origin; + if (u.hostname !== getDomain()) supabaseUrl = u.origin; anonKey = res.request().headers()['apikey'] ?? null; } catch { /* ignore — signIn backfills from the JWT */ @@ -447,7 +447,7 @@ class DuloLoginBrowser { await new Promise((r) => setTimeout(r, 1500)); if (session.finalized || session.tornDown) return; // device captured (or torn down) during the wait try { - await page.goto(LIVE_URL, { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await page.goto(getLiveUrl(), { waitUntil: 'domcontentloaded', timeout: 30_000 }); } catch (err) { // The screencast still shows whatever rendered (incl. a "use this device" prompt the user can click). logger.warn(tag, `live-tv navigation issue (device provoke): ${(err as Error).message}`); diff --git a/server/src/sources/adapters/dulo/pairing.ts b/server/src/sources/adapters/dulo/pairing.ts index 0ffd4a9..fc2b98e 100644 --- a/server/src/sources/adapters/dulo/pairing.ts +++ b/server/src/sources/adapters/dulo/pairing.ts @@ -4,7 +4,7 @@ // durable way to authenticate a social dulo account is to let the user sign in with their OWN real browser // (where Google just works) and hand the resulting Supabase session back to masqueradarr. This module mints a // short-lived, single-use PAIRING CODE and builds the one-click bookmarklet / console snippet the user runs on -// dulo.tv to POST their session to the code-gated callback (routes/sources.ts). +// dulo to POST their session to the code-gated callback (routes/sources.ts). // // SECURITY: the bookmarklet carries only the pairing CODE (high-entropy, single-use, ~10-min TTL) + the // callback URL — NEVER the admin's session token. A leaked code can at most establish dulo auth on THIS @@ -12,6 +12,7 @@ // gate) precisely so the user's own browser can reach it; the code is the bearer. import { randomBytes } from 'node:crypto'; +import { getDomain } from './config.js'; const CODE_TTL_MS = 10 * 60 * 1000; // 10 minutes const codes = new Map(); // code → expiresAt (ms epoch) @@ -45,16 +46,17 @@ export const duloPairing = { // The client-side harvester: find the dulo Supabase session in localStorage, then POST it to the code-gated // callback — falling back to copying it to the clipboard when a direct POST can't work (mixed content on a // plain-http LAN instance, or a network/CORS failure). Built server-side with the code + callback baked in via -// JSON.stringify (safe escaping). Runs on dulo.tv, so masqueradarr's CSP never applies to it. +// JSON.stringify (safe escaping). Runs on dulo's own site, so masqueradarr's CSP never applies to it. function harvesterBody(code: string, callbackUrl: string): string { const CB = JSON.stringify(callbackUrl); const CODE = JSON.stringify(code); + const DOMAIN = JSON.stringify(getDomain()); // follows Settings.duloDomain — the mint is per-request return ( - `(function(){var CB=${CB},CODE=${CODE};` + + `(function(){var CB=${CB},CODE=${CODE},D=${DOMAIN};` + `function f(){for(var i=0;i-1){try{var o=JSON.parse(v),s=o.currentSession||o.session||o;` + `if(s&&s.access_token)return s}catch(e){}}}return null}` + - `var s=f();if(!s){alert("Sign in to dulo.tv first, then run this again.");return}` + + `var s=f();if(!s){alert("Sign in to "+D+" first, then run this again.");return}` + `function clip(){var t=JSON.stringify({access_token:s.access_token,refresh_token:s.refresh_token,expires_at:s.expires_at});` + `(navigator.clipboard?navigator.clipboard.writeText(t):Promise.reject()).then(function(){` + `alert("Copied your dulo session. Paste it into masqueradarr under Paste session.")},function(){` + @@ -68,7 +70,7 @@ function harvesterBody(code: string, callbackUrl: string): string { ); } -/** A draggable `javascript:` bookmarklet (drag to the bookmarks bar, click on dulo.tv). */ +/** A draggable `javascript:` bookmarklet (drag to the bookmarks bar, click it on dulo's site). */ export function buildBookmarklet(code: string, callbackUrl: string): string { return 'javascript:' + encodeURIComponent(harvesterBody(code, callbackUrl)); } diff --git a/server/src/sources/adapters/dulo/supabaseConfig.ts b/server/src/sources/adapters/dulo/supabaseConfig.ts index c2d8195..6bf2b32 100644 --- a/server/src/sources/adapters/dulo/supabaseConfig.ts +++ b/server/src/sources/adapters/dulo/supabaseConfig.ts @@ -11,7 +11,10 @@ // This module removes that manual step: when a refresh 401s at the key gate, auth.ts calls // discoverSupabaseConfig(), which reads dulo's CURRENT config straight from its live frontend bundle // (fetch homepage → find /assets/index-.js → grep the `sb_publishable_…` key + `.supabase.co` -// URL — exactly what a browser's supabase-js client is initialised with). The discovered pair is cached +// URL — exactly what a browser's supabase-js client is initialised with). The site it scrapes is the +// OPERATOR-CONFIGURED domain (./config.ts getOrigin()), read at use time, so a dulo rebrand redirects +// discovery too; changing that setting calls resetSupabaseDiscovery() to drop the old project's cache. +// The discovered pair is cached // in-process and persisted onto the session doc by the caller, so a dulo migration self-heals with no // human action and no dulo-specific values in docker-compose / .env. // @@ -19,14 +22,15 @@ // (streamed-login network intercept) → the runtime-discovered value → the committed offline SEED below. import { logger } from '../../core/logger.js'; +import { getOrigin, UA } from './config.js'; -const DULO_ORIGIN = 'https://dulo.tv'; const tag = 'dulo:auth'; // Committed OFFLINE SEED — dulo's public Supabase config as last verified (2026-07-22). This is only the // last-resort fallback (same role as each adapter's committed *.snapshot.json): discoverSupabaseConfig() // supersedes it at runtime. Bump it only if discovery is ever blocked (bot-gate) AND dulo has migrated — -// re-scrape from https://dulo.tv/assets/index-*.js. +// re-scrape from /assets/index-*.js. NOTE this is the Supabase PROJECT seed, and +// is independent of Settings.duloDomain (dulo can rebrand without migrating its Supabase project). const SEED_SUPABASE_URL = 'https://wsudbodtjjfenprwsagd.supabase.co'; const SEED_ANON_KEY = 'sb_publishable_521pnlSRNoR0xpBn6uiuHw_f78kT63_'; @@ -37,12 +41,11 @@ const DISCOVERY_MAX_BUNDLES = 6; // scan at most this many /assets/*.js chunks p const DISCOVERY_FETCH_TIMEOUT_MS = 10_000; // per-request abort so discovery can't hang a refresh // Bot-gate-friendly headers for the scrape (dulo checks these on its API; harmless on static assets). -const DISCOVERY_HEADERS: Record = { - 'User-Agent': - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', - Origin: DULO_ORIGIN, - Referer: `${DULO_ORIGIN}/`, -}; +// A function, not a const: the Origin/Referer must follow the configured domain at USE time. The Referer +// is the site ROOT here (the homepage is what we fetch), not the /live page browserHeaders() implies. +function discoveryHeaders(origin: string): Record { + return { 'User-Agent': UA, Origin: origin, Referer: `${origin}/` }; +} export interface SupabaseConfig { supabaseUrl: string; @@ -66,11 +69,20 @@ export function currentSupabaseUrl(): string { return discovered?.supabaseUrl || SEED_SUPABASE_URL; } -async function fetchText(url: string): Promise { +// Drop the discovered pair AND the cooldown. Called when the operator changes Settings.duloDomain +// (settings/applyDuloDomain.ts): the cached config was scraped from the OLD site and may belong to a +// decommissioned project, and the cooldown would otherwise suppress a re-scrape for up to +// DISCOVERY_COOLDOWN_MS. After this, the next key-gate 401 rediscovers against the new domain. +export function resetSupabaseDiscovery(): void { + discovered = null; + lastDiscoveryAt = 0; +} + +async function fetchText(url: string, origin: string): Promise { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), DISCOVERY_FETCH_TIMEOUT_MS); try { - const res = await fetch(url, { headers: DISCOVERY_HEADERS, signal: ctrl.signal }); + const res = await fetch(url, { headers: discoveryHeaders(origin), signal: ctrl.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.text(); } finally { @@ -81,40 +93,48 @@ async function fetchText(url: string): Promise { const KEY_RE = /sb_publishable_[A-Za-z0-9_-]+/; const URL_RE = /https:\/\/[a-z0-9]{20}\.supabase\.co/; // supabase project refs are 20-char lowercase alnum -// Fetch dulo's live frontend and extract its CURRENT Supabase project URL + publishable anon key. Best -// effort: returns the freshly-discovered pair, or the last cached one (possibly null) on any failure — -// never throws. Cooldown-gated unless opts.force. -export async function discoverSupabaseConfig(opts?: { force?: boolean }): Promise { - if (!opts?.force && lastDiscoveryAt && Date.now() - lastDiscoveryAt < DISCOVERY_COOLDOWN_MS) { - return discovered; - } - lastDiscoveryAt = Date.now(); +// The scrape itself, against an ARBITRARY origin: fetch that site's frontend and extract the Supabase +// project URL + publishable anon key its supabase-js client is initialised with. PURE — it touches neither +// the cache nor the cooldown, so the Settings "Test domain" probe can run it against a candidate domain +// without poisoning the active session's config. Best effort: returns null and never throws. +export async function scrapeSupabaseConfig(origin: string): Promise { try { // The homepage is a tiny SPA shell that lists the content-hashed bundle URLs. Parse it each time so a // dulo redeploy (which changes the hash) is handled automatically; try the `index-*` chunk first (that // is where the supabase client is initialised today), then any remaining chunk. - const html = await fetchText(DULO_ORIGIN); + const html = await fetchText(origin, origin); const assets = [...new Set([...html.matchAll(/\/assets\/[A-Za-z0-9._-]+\.js/g)].map((m) => m[0]))]; const ordered = [...assets.filter((a) => a.includes('index-')), ...assets.filter((a) => !a.includes('index-'))]; if (!ordered.length) { - logger.warn(tag, 'supabase discovery: no /assets/*.js bundles found on dulo homepage'); - return discovered; + logger.warn(tag, `supabase discovery: no /assets/*.js bundles found on ${origin}`); + return null; } for (const path of ordered.slice(0, DISCOVERY_MAX_BUNDLES)) { - const js = await fetchText(`${DULO_ORIGIN}${path}`).catch(() => ''); + const js = await fetchText(`${origin}${path}`, origin).catch(() => ''); const anonKey = js.match(KEY_RE)?.[0]; const supabaseUrl = js.match(URL_RE)?.[0]; - if (anonKey && supabaseUrl) { - const changed = !discovered || discovered.anonKey !== anonKey || discovered.supabaseUrl !== supabaseUrl; - discovered = { supabaseUrl, anonKey }; - if (changed) logger.ok(tag, `discovered current dulo supabase config (${supabaseUrl})`); - return discovered; - } + if (anonKey && supabaseUrl) return { supabaseUrl, anonKey }; } - logger.warn(tag, 'supabase discovery: no sb_publishable_ key found in dulo bundles'); - return discovered; + logger.warn(tag, `supabase discovery: no sb_publishable_ key found in ${origin} bundles`); + return null; } catch (err) { - logger.warn(tag, `supabase discovery failed: ${(err as Error).message}`); + logger.warn(tag, `supabase discovery failed for ${origin}: ${(err as Error).message}`); + return null; + } +} + +// The CACHING wrapper around the scrape, aimed at the currently configured dulo domain. Returns the +// freshly-discovered pair, or the last cached one (possibly null) on any failure — never throws. +// Cooldown-gated unless opts.force. +export async function discoverSupabaseConfig(opts?: { force?: boolean }): Promise { + if (!opts?.force && lastDiscoveryAt && Date.now() - lastDiscoveryAt < DISCOVERY_COOLDOWN_MS) { return discovered; } + lastDiscoveryAt = Date.now(); + const found = await scrapeSupabaseConfig(getOrigin()); + if (!found) return discovered; + const changed = !discovered || discovered.anonKey !== found.anonKey || discovered.supabaseUrl !== found.supabaseUrl; + discovered = found; + if (changed) logger.ok(tag, `discovered current dulo supabase config (${found.supabaseUrl})`); + return discovered; } diff --git a/src/components/DuloAuthPanel.vue b/src/components/DuloAuthPanel.vue index abe25b7..de7e324 100644 --- a/src/components/DuloAuthPanel.vue +++ b/src/components/DuloAuthPanel.vue @@ -1,19 +1,24 @@