From 3a2edb1e309df2ea47ca3e896f97392afc892cd0 Mon Sep 17 00:00:00 2001 From: Cesar Mendivil Date: Sun, 1 Mar 2026 23:38:02 -0700 Subject: [PATCH] Add YouTube OAuth callback URL configuration and enhance token handling --- docker-compose.yml | 4 ++++ docker-entrypoint.sh | 1 + public/oauth2callback.html | 11 +++++----- src/utils/ytOAuth.js | 25 ++++++++++++++++++++--- src/views/Publication/Services/Youtube.js | 24 +++++++++++++++------- 5 files changed, 49 insertions(+), 16 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ec7d2e2..3cd9170 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,10 @@ services: # Debe coincidir con lo que tienes en developers.facebook.com FB_OAUTH_CALLBACK_URL: "https://djmaster.nextream.sytes.net/oauth/facebook/callback.htm" + # URL EXACTA registrada en Google Console como "Authorized redirect URI" + # Debe coincidir con lo que tienes en console.cloud.google.com + YT_OAUTH_CALLBACK_URL: "https://djmaster.nextream.sytes.net/oauth2callback" + # Clave de cifrado para tokens almacenados (cámbiala en producción) FB_ENCRYPTION_SECRET: "restreamer-ui-fb-secret-key-32x!" diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 13dfd6f..84e1e96 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -13,6 +13,7 @@ window.__RESTREAMER_CONFIG__ = { YTDLP_URL: "${YTDLP_URL:-}", FB_SERVER_URL: "${FB_SERVER_URL:-}", FB_OAUTH_CALLBACK_URL: "${FB_OAUTH_CALLBACK_URL:-}", + YT_OAUTH_CALLBACK_URL: "${YT_OAUTH_CALLBACK_URL:-}", }; EOF diff --git a/public/oauth2callback.html b/public/oauth2callback.html index 011d229..1e0c069 100644 --- a/public/oauth2callback.html +++ b/public/oauth2callback.html @@ -135,14 +135,13 @@ function sendCode() { if (ackReceived || attempts >= maxAttempts) return; attempts++; + // redirect_uri = esta misma página (origin + pathname), exactamente como está registrada en Google + var thisUrl = window.location.origin + window.location.pathname; + var msg = { service: 'youtube', code: code, state: state, redirect_uri: thisUrl }; try { - window.opener.postMessage( - { service: 'youtube', code: code, state: state }, - window.location.origin // mismo origen - ); + window.opener.postMessage(msg, window.location.origin); } catch (e) { - // Si falla por política de origen, intentar con '*' - try { window.opener.postMessage({ service: 'youtube', code: code, state: state }, '*'); } catch (e2) {} + try { window.opener.postMessage(msg, '*'); } catch (e2) {} } setTimeout(sendCode, 600); } diff --git a/src/utils/ytOAuth.js b/src/utils/ytOAuth.js index 81bd7bb..5fd3fcc 100644 --- a/src/utils/ytOAuth.js +++ b/src/utils/ytOAuth.js @@ -266,10 +266,29 @@ const refreshAccount = async (accountKey) => { } }; -/** Obtener token válido (refresca automáticamente si está expirado) */ +/** Obtener token válido (refresca automáticamente si está expirado). + * Si el access_token no está en caché local (porque viene del servidor cifrado), + * lo obtiene del endpoint /yt/accounts/:key/token. + */ const getValidToken = async (accountKey) => { - const account = getAccount(accountKey); - if (!account || !account.access_token) throw new Error('Account not connected: ' + accountKey); + let account = getAccount(accountKey); + if (!account) throw new Error('Account not connected: ' + accountKey); + + // Si no hay access_token en caché local, obtenerlo del servidor + if (!account.access_token) { + const serverAccount = await getAccountToken(accountKey); + if (serverAccount && serverAccount.access_token) { + // Guardar en caché para futuras llamadas + const cache = _readCache(); + if (!cache.accounts) cache.accounts = {}; + cache.accounts[accountKey] = { ...(cache.accounts[accountKey] || {}), ...serverAccount }; + _writeCache(cache); + account = cache.accounts[accountKey]; + } else { + throw new Error('Could not retrieve token for account: ' + accountKey); + } + } + const isExpired = account.token_expiry && Date.now() > account.token_expiry - 60000; if (isExpired) return await refreshAccount(accountKey); return account.access_token; diff --git a/src/views/Publication/Services/Youtube.js b/src/views/Publication/Services/Youtube.js index 7a0e67d..c8dad00 100644 --- a/src/views/Publication/Services/Youtube.js +++ b/src/views/Publication/Services/Youtube.js @@ -116,8 +116,9 @@ function Service(props) { // Cuenta conectada a ESTA publication (guardada en ytOAuth store) const connectedAccount = settings.account_key ? ytOAuth.getAccount(settings.account_key) : null; - const isConnected = !!(connectedAccount && connectedAccount.access_token); - const isTokenExpired = isConnected && connectedAccount.token_expiry + // isConnected: tiene token local O tiene token en servidor (has_access_token) + const isConnected = !!(connectedAccount && (connectedAccount.access_token || connectedAccount.has_access_token)); + const isTokenExpired = isConnected && connectedAccount && connectedAccount.token_expiry ? Date.now() > connectedAccount.token_expiry - 60000 : false; @@ -174,7 +175,12 @@ function Service(props) { return; } - const redirectUri = window.location.origin + '/oauth2callback'; + // Usar YT_OAUTH_CALLBACK_URL si está definida (producción con dominio real) + // De lo contrario construir desde window.location.origin (desarrollo) + const _rtCfg = (typeof window !== 'undefined' && window.__RESTREAMER_CONFIG__) || {}; + const redirectUri = _rtCfg.YT_OAUTH_CALLBACK_URL + ? _rtCfg.YT_OAUTH_CALLBACK_URL + : window.location.origin + '/oauth2callback'; const oauthState = btoa(JSON.stringify({ service: 'youtube', ts: Date.now() })); const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth?' + new URLSearchParams({ client_id: globalCreds.client_id, @@ -237,13 +243,17 @@ function Service(props) { if (event.data.code) { try { + // Usar el redirect_uri que envió el callback (URL exacta de la página de callback) + // Esto garantiza que coincida exactamente con el registrado en Google Console + const callbackRedirectUri = event.data.redirect_uri || redirectUri; + // Intercambiar code por tokens vía servidor (incluye IDs de Restreamer) const exchangeResp = await fetch('/fb-server/yt/exchange', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: event.data.code, - redirect_uri: redirectUri, + redirect_uri: callbackRedirectUri, restreamer_channel_id: props.channelId || '', restreamer_publication_id: props.publicationId || '', }), @@ -263,7 +273,7 @@ function Service(props) { code: event.data.code, client_id: localCreds.client_id, client_secret: localCreds.client_secret, - redirect_uri: redirectUri, + redirect_uri: callbackRedirectUri, grant_type: 'authorization_code', }).toString(), }); @@ -411,8 +421,8 @@ function Service(props) { if (!bResp.ok) throw new Error('Broadcast: ' + (bData.error ? bData.error.message : `HTTP ${bResp.status}`)); const broadcastId = bData.id; - // 2) LiveStream - const sResp = await fetch(`${YT_API}/liveStreams?part=snippet,cdn,status`, { + // 2) LiveStream — contentDetails debe estar en ?part= para poder enviarlo en el body + const sResp = await fetch(`${YT_API}/liveStreams?part=snippet,cdn,status,contentDetails`, { method: 'POST', headers, body: JSON.stringify({ snippet: { title: settings.title },