feat(oauth): import favoris/abos Google (YouTube) et Twitch vers abonnements/likes
- Backend: server/oauth.mjs (auth URL, echange code, refresh, fetch
subscriptions/likes Google, follows Twitch), table oauth_connections
+ migration, routes /api/oauth/:provider/{url,callback,preview,import},
/api/oauth/{status,connections}
- Front: /library/import (connect, preview, import), service
oauth-import, lien depuis Abonnements
- Config: GOOGLE_CLIENT_ID/SECRET/REDIRECT_URI, TWITCH_REDIRECT_URI,
OAUTH_APP_BASE_URL documentes (.env.example, docker-compose),
passthrough docker-compose.local.yml, roadmap README cochee
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* OAuth Google / Twitch pour l'import des favoris et abonnements.
|
||||
*
|
||||
* - Google (YouTube) : scopes `youtube.readonly` (+ profil). Importe les
|
||||
* abonnements (`subscriptions.list?mine=true`) et les favoris
|
||||
* (`videos.list?myRating=like`).
|
||||
* - Twitch : scopes `user:read:follows user:read:subscriptions`. Importe les
|
||||
* chaînes suivies (`/helix/users/follows?from_id=`). Twitch n'a pas de
|
||||
* notion de "like" vidéo : seul l'import abonnements est proposé.
|
||||
*
|
||||
* Les tokens sont stockés en SQLite (instance locale / auto-hébergée).
|
||||
* Ne jamais logger access_token / refresh_token / client_secret.
|
||||
*/
|
||||
|
||||
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
const TWITCH_AUTH_URL = 'https://id.twitch.tv/oauth2/authorize';
|
||||
const TWITCH_TOKEN_URL = 'https://id.twitch.tv/oauth2/token';
|
||||
|
||||
const GOOGLE_SCOPES = [
|
||||
'openid',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/youtube.readonly',
|
||||
].join(' ');
|
||||
|
||||
const TWITCH_SCOPES = ['user:read:follows', 'user:read:subscriptions'].join(' ');
|
||||
|
||||
// state -> { userId, provider, createdAt } (mémoire, 10 min).
|
||||
const pendingStates = new Map();
|
||||
|
||||
function randomState() {
|
||||
try {
|
||||
const { randomBytes } = require('node:crypto');
|
||||
return randomBytes(16).toString('hex');
|
||||
} catch {}
|
||||
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
export function createOAuthState(userId, provider) {
|
||||
const state = randomState();
|
||||
pendingStates.set(state, { userId: String(userId), provider: String(provider), createdAt: Date.now() });
|
||||
// Purge opportuniste.
|
||||
try {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of pendingStates) {
|
||||
if (now - v.createdAt > 10 * 60 * 1000) pendingStates.delete(k);
|
||||
}
|
||||
} catch {}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function consumeOAuthState(state) {
|
||||
const entry = pendingStates.get(String(state || ''));
|
||||
if (!entry) return null;
|
||||
pendingStates.delete(String(state));
|
||||
if (Date.now() - entry.createdAt > 10 * 60 * 1000) return null;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function oauthStatus() {
|
||||
const googleId = String(process.env.GOOGLE_CLIENT_ID || '').trim();
|
||||
const googleSecret = String(process.env.GOOGLE_CLIENT_SECRET || '').trim();
|
||||
const twitchId = String(process.env.TWITCH_CLIENT_ID || '').trim();
|
||||
const twitchSecret = String(process.env.TWITCH_CLIENT_SECRET || '').trim();
|
||||
return {
|
||||
google: {
|
||||
configured: Boolean(googleId && googleSecret),
|
||||
missing: [...(!googleId ? ['GOOGLE_CLIENT_ID'] : []), ...(!googleSecret ? ['GOOGLE_CLIENT_SECRET'] : [])],
|
||||
},
|
||||
twitch: {
|
||||
configured: Boolean(twitchId && twitchSecret),
|
||||
missing: [...(!twitchId ? ['TWITCH_CLIENT_ID'] : []), ...(!twitchSecret ? ['TWITCH_CLIENT_SECRET'] : [])],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Base publique de l'app (pour la redirect_uri). Priorité au .env explicite. */
|
||||
export function appBaseUrl(req) {
|
||||
const explicit = String(process.env.OAUTH_APP_BASE_URL || '').trim().replace(/\/+$/, '');
|
||||
if (explicit) return explicit;
|
||||
try {
|
||||
const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim() || 'http';
|
||||
const host = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || req?.get?.('host') || '').trim();
|
||||
if (host) return `${proto}://${host}`;
|
||||
} catch {}
|
||||
return 'http://localhost:4200';
|
||||
}
|
||||
|
||||
export function redirectUriFor(provider, req) {
|
||||
const p = String(provider);
|
||||
if (p === 'google') {
|
||||
const explicit = String(process.env.GOOGLE_REDIRECT_URI || '').trim();
|
||||
if (explicit) return explicit;
|
||||
}
|
||||
if (p === 'twitch') {
|
||||
const explicit = String(process.env.TWITCH_REDIRECT_URI || '').trim();
|
||||
if (explicit) return explicit;
|
||||
}
|
||||
return `${appBaseUrl(req).replace(/\/+$/, '')}/api/oauth/${p}/callback`;
|
||||
}
|
||||
|
||||
export function buildAuthUrl(provider, state, req) {
|
||||
const p = String(provider);
|
||||
if (p === 'google') {
|
||||
const clientId = String(process.env.GOOGLE_CLIENT_ID || '').trim();
|
||||
if (!clientId) throw Object.assign(new Error('google_oauth_not_configured'), { status: 503 });
|
||||
const qs = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUriFor('google', req),
|
||||
response_type: 'code',
|
||||
scope: GOOGLE_SCOPES,
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
state,
|
||||
});
|
||||
return `${GOOGLE_AUTH_URL}?${qs.toString()}`;
|
||||
}
|
||||
if (p === 'twitch') {
|
||||
const clientId = String(process.env.TWITCH_CLIENT_ID || '').trim();
|
||||
if (!clientId) throw Object.assign(new Error('twitch_oauth_not_configured'), { status: 503 });
|
||||
const qs = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUriFor('twitch', req),
|
||||
response_type: 'code',
|
||||
scope: TWITCH_SCOPES,
|
||||
state,
|
||||
});
|
||||
return `${TWITCH_AUTH_URL}?${qs.toString()}`;
|
||||
}
|
||||
throw Object.assign(new Error('invalid_oauth_provider'), { status: 400 });
|
||||
}
|
||||
|
||||
async function postForm(url, params) {
|
||||
const body = new URLSearchParams(params);
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
const err = new Error(`oauth_token_failed_${resp.status}`);
|
||||
err.status = 502;
|
||||
err.details = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function exchangeCode(provider, code, req) {
|
||||
const p = String(provider);
|
||||
if (p === 'google') {
|
||||
const data = await postForm(GOOGLE_TOKEN_URL, {
|
||||
code: String(code),
|
||||
client_id: String(process.env.GOOGLE_CLIENT_ID || ''),
|
||||
client_secret: String(process.env.GOOGLE_CLIENT_SECRET || ''),
|
||||
redirect_uri: redirectUriFor('google', req),
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token || null,
|
||||
expiresAt: data.expires_in ? Date.now() + Number(data.expires_in) * 1000 : null,
|
||||
scopes: data.scope || GOOGLE_SCOPES,
|
||||
};
|
||||
}
|
||||
if (p === 'twitch') {
|
||||
const data = await postForm(TWITCH_TOKEN_URL, {
|
||||
code: String(code),
|
||||
client_id: String(process.env.TWITCH_CLIENT_ID || ''),
|
||||
client_secret: String(process.env.TWITCH_CLIENT_SECRET || ''),
|
||||
redirect_uri: redirectUriFor('twitch', req),
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token || null,
|
||||
expiresAt: data.expires_in ? Date.now() + Number(data.expires_in) * 1000 : null,
|
||||
scopes: Array.isArray(data.scope) ? data.scope.join(' ') : TWITCH_SCOPES,
|
||||
};
|
||||
}
|
||||
throw Object.assign(new Error('invalid_oauth_provider'), { status: 400 });
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(provider, refreshToken) {
|
||||
const p = String(provider);
|
||||
if (p === 'google') {
|
||||
const data = await postForm(GOOGLE_TOKEN_URL, {
|
||||
refresh_token: String(refreshToken),
|
||||
client_id: String(process.env.GOOGLE_CLIENT_ID || ''),
|
||||
client_secret: String(process.env.GOOGLE_CLIENT_SECRET || ''),
|
||||
grant_type: 'refresh_token',
|
||||
});
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token || refreshToken,
|
||||
expiresAt: data.expires_in ? Date.now() + Number(data.expires_in) * 1000 : null,
|
||||
};
|
||||
}
|
||||
if (p === 'twitch') {
|
||||
const data = await postForm(TWITCH_TOKEN_URL, {
|
||||
refresh_token: String(refreshToken),
|
||||
client_id: String(process.env.TWITCH_CLIENT_ID || ''),
|
||||
client_secret: String(process.env.TWITCH_CLIENT_SECRET || ''),
|
||||
grant_type: 'refresh_token',
|
||||
});
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token || refreshToken,
|
||||
expiresAt: data.expires_in ? Date.now() + Number(data.expires_in) * 1000 : null,
|
||||
};
|
||||
}
|
||||
throw Object.assign(new Error('invalid_oauth_provider'), { status: 400 });
|
||||
}
|
||||
|
||||
async function getJson(url, accessToken, extraHeaders = {}) {
|
||||
const resp = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${accessToken}`, ...extraHeaders },
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
const err = new Error(`oauth_api_failed_${resp.status}`);
|
||||
err.status = resp.status === 401 ? 401 : 502;
|
||||
err.details = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// -------------------- Google (YouTube) --------------------
|
||||
|
||||
export async function fetchGoogleProfile(accessToken) {
|
||||
const data = await getJson('https://www.googleapis.com/oauth2/v2/userinfo', accessToken);
|
||||
return {
|
||||
id: String(data.id || ''),
|
||||
displayName: String(data.name || data.email || 'Google'),
|
||||
avatarUrl: String(data.picture || ''),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchGoogleSubscriptions(accessToken, max = 50) {
|
||||
const out = [];
|
||||
let pageToken = '';
|
||||
while (out.length < max) {
|
||||
const qs = new URLSearchParams({
|
||||
part: 'snippet',
|
||||
mine: 'true',
|
||||
maxResults: String(Math.min(50, max - out.length)),
|
||||
order: 'alphabetical',
|
||||
});
|
||||
if (pageToken) qs.set('pageToken', pageToken);
|
||||
const data = await getJson(`https://www.googleapis.com/youtube/v3/subscriptions?${qs.toString()}`, accessToken);
|
||||
for (const item of Array.isArray(data?.items) ? data.items : []) {
|
||||
const sn = item?.snippet || {};
|
||||
const channelId = sn?.resourceId?.channelId || '';
|
||||
if (!channelId) continue;
|
||||
out.push({
|
||||
provider: 'youtube',
|
||||
externalId: channelId,
|
||||
title: sn?.title || channelId,
|
||||
handle: null,
|
||||
avatarUrl: sn?.thumbnails?.default?.url || sn?.thumbnails?.medium?.url || null,
|
||||
url: `https://www.youtube.com/channel/${channelId}`,
|
||||
});
|
||||
}
|
||||
pageToken = data?.nextPageToken || '';
|
||||
if (!pageToken) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function fetchGoogleLiked(accessToken, max = 25) {
|
||||
const qs = new URLSearchParams({
|
||||
part: 'snippet,contentDetails',
|
||||
myRating: 'like',
|
||||
maxResults: String(Math.min(50, max)),
|
||||
});
|
||||
const data = await getJson(`https://www.googleapis.com/youtube/v3/videos?${qs.toString()}`, accessToken);
|
||||
const out = [];
|
||||
for (const item of Array.isArray(data?.items) ? data.items : []) {
|
||||
const id = item?.id || '';
|
||||
const sn = item?.snippet || {};
|
||||
if (!id) continue;
|
||||
out.push({
|
||||
provider: 'youtube',
|
||||
videoId: String(id),
|
||||
title: sn?.title || '',
|
||||
thumbnail: sn?.thumbnails?.medium?.url || sn?.thumbnails?.default?.url || '',
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// -------------------- Twitch --------------------
|
||||
|
||||
function twitchClientId() {
|
||||
return String(process.env.TWITCH_CLIENT_ID || '').trim();
|
||||
}
|
||||
|
||||
export async function fetchTwitchProfile(accessToken) {
|
||||
const data = await getJson('https://api.twitch.tv/helix/users', accessToken, { 'Client-Id': twitchClientId() });
|
||||
const u = Array.isArray(data?.data) ? data.data[0] : null;
|
||||
if (!u) throw Object.assign(new Error('twitch_profile_failed'), { status: 502 });
|
||||
return {
|
||||
id: String(u.id || ''),
|
||||
displayName: String(u.display_name || u.login || 'Twitch'),
|
||||
avatarUrl: String(u.profile_image_url || ''),
|
||||
login: String(u.login || ''),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchTwitchFollows(accessToken, twitchUserId, max = 100) {
|
||||
const out = [];
|
||||
let cursor = '';
|
||||
while (out.length < max) {
|
||||
const qs = new URLSearchParams({
|
||||
from_id: String(twitchUserId),
|
||||
first: String(Math.min(100, max - out.length)),
|
||||
});
|
||||
if (cursor) qs.set('after', cursor);
|
||||
const data = await getJson(`https://api.twitch.tv/helix/users/follows?${qs.toString()}`, accessToken, {
|
||||
'Client-Id': twitchClientId(),
|
||||
});
|
||||
const list = Array.isArray(data?.data) ? data.data : [];
|
||||
// Enrichit les logins via /helix/users?id= (display_name + avatar).
|
||||
const ids = list.map((f) => f?.to_id).filter(Boolean).slice(0, 100);
|
||||
let usersById = new Map();
|
||||
if (ids.length) {
|
||||
try {
|
||||
const uqs = new URLSearchParams();
|
||||
ids.forEach((id) => uqs.append('id', String(id)));
|
||||
const udata = await getJson(`https://api.twitch.tv/helix/users?${uqs.toString()}`, accessToken, {
|
||||
'Client-Id': twitchClientId(),
|
||||
});
|
||||
for (const u of Array.isArray(udata?.data) ? udata.data : []) {
|
||||
usersById.set(String(u.id), u);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
for (const f of list) {
|
||||
const toId = String(f?.to_id || '');
|
||||
if (!toId) continue;
|
||||
const u = usersById.get(toId);
|
||||
const login = String(u?.login || f?.to_name || '');
|
||||
out.push({
|
||||
provider: 'twitch',
|
||||
externalId: login || toId,
|
||||
twitchUserId: toId,
|
||||
title: String(u?.display_name || f?.to_name || login || toId),
|
||||
handle: login || null,
|
||||
avatarUrl: String(u?.profile_image_url || ''),
|
||||
url: login ? `https://www.twitch.tv/${login}` : '',
|
||||
});
|
||||
}
|
||||
cursor = data?.pagination?.cursor || '';
|
||||
if (!cursor || !list.length) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user